From 20be26b1c07882130f123a17873ad1d58fbf9f6a Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 21 Sep 2026 15:13:19 +0200 Subject: [PATCH 001/141] docs(brief): refute the M1.D.47 overlap on 44 and 45 (S5/G1) The digest input path holds zero ResolvedType references across its four functions and 86 lines - schemaDigestOf, tagSetDesc, computeLayout, schemaDigestFor - with the extractor shown to fire on isRuleArenaType in the same execution, so the zeros are results and not silences. types.zig names schemaDigest zero times; registry.zig names ResolvedType zero times; ResolvedType exists in two files, one of them a single test helper. computeLayout derives FieldKind from the AST type node, so the chain is AST -> FieldKind -> Layout -> digest and the type checker sits beside it, not upstream. The premise came from a homonym: M1.D.47's zone is a value's memory lifetime, M1.D.45's storage is @storage(.sparse), the ECS storage mode, which registry.zig:198-200 declares OUT of the hash by ARCH-005. So it is one entry for one gesture, and 44 and 45 are a second, disjoint gesture on schemaDigestOf. The 105-site figure costed option 1 of the S4/G8 arbitration, a recursive optional payload, and not the plan's deliverable; it reproduces under no selector tried - 68/6, 90/15, 298/12, 214/2. The gesture is not single: the ambiguous variants are built at eight sites, four of them resolving a type NODE, which carries no zone and cannot. Those four sit under namedTypeToResolved, 37 call sites, most of which cannot supply one. M1.D.47 is not started and the arbitration is raised. Floor re-derived from the suite and unchanged at 2379, measured twice under a live ZLS watch build; lint conservation OK at 2379; fmt clean. Zero .zig touched. Co-Authored-By: Claude Opus 5 --- briefs/m1.d-phase-1-debt.md | 138 ++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index f5d53d22..9357e502 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -3527,6 +3527,144 @@ collision `M1.D.17`/`M1.D.18` sur la fragmentation des chunks ; celle-ci est une seconde, distincte. Elle est signalée et non renumérotée : la table appartient au corpus. +### S5/G1 — the claimed overlap of `M1.D.47` on `44` and `45` is REFUTED, and the gesture is not single + +**S5's subject is the fourteen entries M1.D created and did not treat**, under a +rule this milestone did not have: a debt found during S5 closes during S5, and one +that cannot is a STOP and an arbitration, never a new number. G1 owed one thing — +measure the overlap before writing anything — and the measurement refuses the +premise, so nothing was written. + +**Branch base verified at creation.** `phase-1/debt/m1d-created` from `main` at +`c3e6d31`, read at that moment. The frozen header names +`phase-1/debt/phase-1-debt`, which is M1.D's first branch and merged; the divergence +is reported here rather than edited into a frozen section. + +**The spec set was enumerated on disk before anything was opened**, on S2's rule, +and the enumeration is what identifies the current copy — not a line count, and not +a habit. A filesystem sweep of all seven files across every location: + +| file | `weld-spec/` | drop `m1.d-phase-1-debt/` | other copies | +|---|---|---|---| +| `engine-phase-1-plan.md` | **`8fb85acb…` / 648** | `f4779da6…` / 633 | 31 more, 21 states | +| `engine-ecs-internals.md` | `02b87d8c…` / 1435 | identical | 18 more | +| `etch-memory-model.md` | **`145aaecb…` / 798** | **absent** | 7 more, all 787 | +| `etch-reference-part1.md` | `77ada73f…` / 2220 | identical | 12 more | +| `engine-zig-conventions.md` | **`46649704…` / 1440** | `40f5b107…` / 1432 | 17 more | +| `engine-invariants.md` | `a95a527f…` / 1059 | identical | 10 more | +| `engine-platform.md` | **`f7c7975e…` / 915** | `bed07c1d…` / 887 | 3 more | + +`weld-spec/` is the freshest and unique-highest for all seven, and the plan there +is `8fb85acb…` at 648 lines — **byte for byte the delivered lot the closure journal +names**. `etch-memory-model.md` at `145aaecb…` / 798 is the copy S4/G8 cited for the +snapshot contract, absent from the drop entirely. Three of the seven would have been +read superseded had the drop been preferred. + +#### The overlap: measured four ways, and it is empty + +`M1.D.47` is `ResolvedType` missing the storage ZONE of a value. `M1.D.44` and +`M1.D.45` are both `schemaDigestOf` — the tag table's contents unhashed, and +`@storage`/`@requires` unhashed. The claim under test is that carrying the zone +closes all three. + +| measurement | result | +|---|---| +| `src/etch/types.zig`, home of `ResolvedType` → `schemaDigest` / `ComponentDesc` refs | **0** | +| `src/core/ecs/registry.zig`, home of `schemaDigestOf` → `ResolvedType` refs | **0** | +| the four functions forming the ENTIRE digest input path, 86 lines — `schemaDigestOf`, `tagSetDesc`, `computeLayout`, `schemaDigestFor` | **0** `ResolvedType` refs | +| files holding `ResolvedType` at all | **2**: `types.zig` (213 occurrences) and `interp.zig` (**1**, inside the test helper `countDiagCode`) | + +**The four zeros are results and not silences**, the extractor having been fired on a +positive control in the same execution: the same brace-counting body reader returns +1 on `isRuleArenaType`, which is `M1.D.47`'s own manifestation predicate. + +And the path is not merely disjoint, it is structurally elsewhere: `computeLayout` +derives each `FieldKind` from the AST type node directly — `ast.typeNodeKind`, +`ast.named_types`, `fieldKindFromTypeName` — so the digest chain is **AST → +FieldKind → Layout → digest**, and `ResolvedType` is nowhere on it. The type checker +is not upstream of the digest; it is beside it. + +#### The premise was born of a homonym, and both senses are written in the tree + +`M1.D.47`'s **zone** is the memory lifetime of a runtime value — rule arena against +persistent heap. `M1.D.45`'s **`storage`** is `@storage(.sparse)`, the ECS storage +MODE, resolved at `types.zig:96-112` into a `StorageKind`. They are not the same +quantity, and `registry.zig:198-200` says so about its own hash in its own words — +*« Storage mode is OUT of it … a property of this registry and not of the layout +(`ARCH-005`) »*. One English word over two quantities is exactly what produces a set +named differently from the set counted. + +**So it is not five entries for one gesture. It is one entry for one gesture, and +`44`/`45` are a second, disjoint gesture on `schemaDigestOf`** — which is also why +they sit together and can be taken together, on a file `M1.D.47` never opens. + +#### The `105 sites` figure measures a different change, and is not reproducible + +The session names `M1.D.47` *the heaviest of the lot, measured at 105 sites*. That +figure comes from S4/G8, where it costed **option 1 of that arbitration** — giving +`ResolvedType.optional` a recursive payload so `Spec?` is representable — and not +the plan's deliverable for `M1.D.47`, which is *porter la zone dans le type*. Two +different changes. + +Nor does it reproduce. Candidate selectors, all run: + +| selector | hits / files | +|---|---| +| `.optional` over `src/etch/` | 68 / 6 | +| `.optional` over `src/ tests/ tools/` | 90 / 15 | +| `optional` (any) over `src/etch/` | 298 / 12 | +| `ResolvedType` over the tree | 214 / **2** | + +None is 105 / 7. The figure is reported as unreproducible rather than rounded to a +neighbour, and the set `M1.D.47` actually touches is **213 occurrences in one +file**, plus one test helper. + +#### The gesture is NOT single, and the reason is at the construction sites + +The conditional — *then `47` if the gesture is unique* — is not met, and this is the +measurement that decides it rather than an impression of size. The ambiguous +variants are built at **eight** sites, and they split in two: + +| kind | sites | is the zone available? | +|---|---|---| +| **type-annotation resolution** — `namedTypeToResolved` (`:4225`, `:4232`, `:4238`), `substituteGeneric` (`:6989`) | 4 | **no** | +| value-expression synthesis — map literal `:6450`, `Set.from` `:7303`, `active_extensions` `:7678`, slicing `:7897` | 4 | yes | + +The first four resolve a **type NODE** — pure syntax. `int[]` on a resource field +and `int[]` on a local binding are the same node, so the zone is not in the input +and cannot be: a type node has no zone. `namedTypeToResolved(self, type_node) +ResolvedType` has **37 call sites**, and most of them — a field declaration, a fn +parameter, a return type — genuinely do not know a zone to supply. + +So carrying the zone is not a field added to a payload. It is either a zone +parameter threaded through 37 callers that mostly cannot answer, or a propagation +dimension the checker does not have. The mechanical half is small — 8 constructions, +~45 match sites, 4 exhaustive switches over `ResolvedType`, each owing a decision by +construction. The half that decides the entry is where the zone COMES FROM, and it +is an analysis, not an edit. + +**`M1.D.47` is therefore not started**, and the arbitration is raised rather than +resolved: the plan's own deliverable line says carrying the zone *« referme les +trois sans exemption par provenance »*, and the measurement says the zone must be +supplied by provenance at the four sites where the ambiguity is born. That is the +entry's own premise meeting the code. + +#### Gates + +`zig build test` **exit 0**, 316/316 steps, `2360/2379 tests passed (19 skipped)` · +`zig build lint` **exit 0**, *conservation OK … at 2379* · `zig fmt --check` +**exit 0**. Each read from its OWN exit code and never through a pipe, on the +family this milestone paid for five times. + +**Floor re-derived FROM THE SUITE and unchanged at 2379**, which is the expected +result for a gate that delivers no program line and is stated because it was +MEASURED — twice, and the `dead-tests` closure arrives at 2379 independently. It +was measured twice for a named reason rather than for ceremony: a ZLS `--watch` +build runner is live in this environment and shares `.zig-cache`, which is the +contention that gave 2371 / 2372 / 2369 at S4/G6. The two readings agree and the +closure agrees with both, so the total is a total. Diff: **one markdown file, zero +`.zig` touched**, checked mechanically. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree From bfc2d4a0cbb9d2e38a61812ba5ae613bce4412f4 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 21 Sep 2026 15:37:36 +0200 Subject: [PATCH 002/141] docs(brief): classify the 37 namedTypeToResolved sites (S5/G2) Of 37 call sites, exactly ONE both admits an ambiguous collection type and knows the zone - lookupFieldType's .resource arm at :8178, which dispatches on the receiver and therefore holds the provenance already. THIRTEEN admit one without knowing it: twelve parameter/return sites and one let annotation. The remaining 22 are moot or derivative, and that is a measurement of the gate at :3896, where collection field types are resource-only and component/struct/event are rejected at :3917 - probed, 0 errors on resource and 1 undefined_symbol on each of the other three, which is the positive half that makes the zeros results. At the twelve parameter and return sites the zone is NOT DETERMINATE, which is stronger than unknown. The corpus holds zero collection parameters and zero collection returns against 18 let annotations, and zero in a corpus is not illegality: fn f(xs: int[]), fn g() -> int[], fn h(m: [int: int]) and fn i(s: Set) all check with 0 errors. The deciding case is one program in which a single fn total(xs: int[]) takes an arena literal AND get(Bag).items at two call sites, 0 errors. There is no single answer for that site to know. So a zone variant meaning unknown re-arms the S4/G8 defect one level down, and the measurement points elsewhere: the zone is a property of a VALUE, not of a declared type. The thirteen sites describe signatures, which range over both zones by design. The open question is the SHAPE, not the blast radius, and it is architecture rather than a debt-milestone correction. One site is unmeasured and its own control says so: the cast target at :6255. types.zig:6256 must reject a non-builtin target, the probe reported 0 errors for a as int[], and its control 1 as bool - which that same line must reject - also reported 0. The probe never reached the arm, so all four readings are silences. Reported unmeasured rather than resolved on a blind instrument. Both probes appended to types.zig, run, reverted; file byte-identical at sha256 4e2e9e88 before and after each, zero ZZPROBE residue in the tree. Floor re-derived from the suite after the reverts and unchanged at 2379, which is what says the tree came back; lint conservation OK at 2379; fmt clean. Co-Authored-By: Claude Opus 5 --- briefs/m1.d-phase-1-debt.md | 116 ++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index 9357e502..fc7058dd 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -3665,6 +3665,122 @@ contention that gave 2371 / 2372 / 2369 at S4/G6. The two readings agree and the closure agrees with both, so the total is a total. Diff: **one markdown file, zero `.zig` touched**, checked mechanically. +### S5/G2 — the 37 call sites classified, and the zone is not a property a declared type can hold + +**The arbitration is accepted as re-posed**: provenance must not be READ at the +verdict, and supplying it AT CONSTRUCTION is the opposite gesture — it is what +makes the later read unnecessary. G2 measures what that costs and nothing else; +no correction is written. + +#### The gate that makes the whole classification cheap + +Collection field types are legal on **`resource` only** — the gate is +`types.zig:3896` (`if (origin == .resource)`) and the rejection is `:3917`, +*« collection / composite field types are not supported in E1 »*. Measured rather +than read: a probe over the four field owners returns **0 errors on `resource`** +and **1 `undefined_symbol` each on component, struct and event**. That positive +half is what makes the zeros below results — the instrument refuses on a case +known to be refused, in the same execution. + +So at every site resolving a field of a component, a struct or an event, an +ambiguous collection type **cannot arrive**, and the zone question is moot there +by construction rather than by convention. + +#### The 37, by family + +| family | sites | can an ambiguous collection arrive? | is the zone knowable? | +|---|---|---|---| +| **resource field read** — `lookupFieldType`'s `.resource` arm, `:8178` | **1** | **yes** | **YES — persistent** | +| parameter / return type — `:4383`, `:4393`, `:4463`, `:4472`, `:4548`, `:4943`, `:4956`, `:6770`, `:6874`, `:6885`, `:7865`, `:7875` | **12** | **yes**, measured | **no — and not merely unknown** | +| `let` type annotation — `:5258` | **1** | **yes**, 18 in the corpus | no — the initialiser decides | +| field of a component / struct / event — `:8156`, `:8191`, `:8205`, `:1961`, `:2444`, `:5161`, `:5184`, `:7080`, `:7144`, `:5107`, `:1783`, `:3053` | 12 | **no** — rejected at `:3917` | moot | +| element / key / value / payload recursion INSIDE `namedTypeToResolved` — `:4216`, `:4223`, `:4229`, `:4230`, `:4236`, `:4245` | 6 | resolves a scalar | owns none — the parent's | +| generic substitution — `:6984`, `:6992` | 2 | via the instantiation | the instantiation's | +| `const` / field default — `:4089`, `:4104` | 2 | no — a collection field `continue`s past the default check at `:3915` | moot | +| **cast target — `:6255`** | 1 | **UNMEASURED** | — | + +**So of 37, exactly ONE both admits an ambiguous collection and knows the zone, +and THIRTEEN admit one without knowing it.** The remaining 22 are moot or +derivative, and that is a measurement of the gate at `:3896`, not an optimism. + +#### The one site that knows is the one that dispatches on provenance + +`lookupFieldType(receiver_type, field_name)` switches on the RECEIVER — `.component`, +`.resource`, `.struct_t`, `.event_t` — so at `:8178` the site is holding the fact +that this field belongs to a resource. That is precisely *fournie à la +construction*: the zone is not read off the value later, it is stamped where the +site already knows it. `get(Inv).items` reaches exactly there. + +#### At the twelve parameter and return sites the zone is NOT DETERMINATE, which is stronger than unknown + +The corpus contains **zero** collection-typed parameters and **zero** collection +return types, against 18 `let` annotations — and **zero in a corpus is not +illegality**, which the probe settled: `fn f(xs: int[])`, `fn g() -> int[]`, +`fn h(m: [int: int])` and `fn i(s: Set)` all type-check with **0 errors**. + +And the decisive case, one program: + +```etch +resource Bag { items: int[] } +fn total(xs: int[]) -> int { return 0 } +rule r { let a: int[] = [1, 2]; let x = total(a); let y = total(get(Bag).items) } +``` + +**0 errors.** One declared parameter type accepts an arena literal AND a +resource-owned persistent collection, at two call sites, in one program. So the +parameter's zone is not a fact the site failed to know — **there is no single +answer to know.** A zone written into the declared type there would be false at +one of the two call sites. + +#### What becomes of the type at those sites — the measurement's own answer + +A zone variant meaning *unknown*, behaving permissively, **re-arms the exact defect +S4/G8 closed**: `.unknown` carried two senses under one tag, was read as safe, and +the price was a SIGABRT reachable from ordinary code. Making the zone optional and +permissive would reproduce that shape one level down. + +What the measurement points to instead is that **the zone is not a property of a +declared type at all.** It is a property of a VALUE. The one site that can stamp it +describes a value being read; the thirteen that cannot describe a SIGNATURE, and a +signature ranges over both zones by design — the `total` program is the witness. + +That reframes the cost G1 reported. The 37 callers do **not** each owe a zone: 22 +are moot or derivative, 13 describe signatures and have none to give, and 1 +describes a value and has one. **The open question is therefore the SHAPE and not +the blast radius** — whether the zone rides beside the type on the value-describing +paths, or whether `ResolvedType` splits into a declared type and a value type. Both +are architecture; neither is a debt-milestone correction, and G2 writes neither. + +#### One site is unmeasured, and the control is what says so + +`:6255`, the cast target. `types.zig:6256` rejects a non-builtin target, so a +collection should be refused there — and my probe reported **0 errors** for +`a as int[]`. Its own controls refute it rather than the code: `1 as bool`, which +that same line must reject, **also reported 0**. The probe never reached the cast +arm, so all four of its readings are silences and neither claim is established. The +cast position is reported UNMEASURED rather than resolved on a blind instrument — +the eighth instance of the register's zero-selector family in this milestone, and +the second this session, after a `--include='*.etch'` glob that zsh ate and that +returned `0` for 213 existing files. + +#### Probe hygiene + +Both probes were appended to `src/etch/types.zig`, run, and reverted; the file is +byte-identical at `sha256 4e2e9e88…` before and after each, checked by digest, and +`grep -rl ZZPROBE` over `src tests tools build.zig` returns **0 files**. The first +probe died of its own format string (`{s:24s}`) and measured nothing — caught +because a probe that fails to compile reads as a detection when nothing was +detected. + +#### Gates + +`zig build test` **exit 0**, 316/316 steps, `2360/2379 tests passed (19 skipped)` · +`zig build lint` **exit 0**, *conservation OK … at 2379* · `zig fmt --check` +**exit 0**, each read from its own exit code. **Floor re-derived FROM THE SUITE +after the reverts and unchanged at 2379**, which is the check that matters here: +two probes were appended and removed, so the total is what says the tree came back. +Diff: one markdown file, **zero `.zig` touched**. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree From 25b5ba980fcd5ba3fb76d2252f9c7711d001eb66 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 21 Sep 2026 17:16:36 +0200 Subject: [PATCH 003/141] feat(etch): close four Phase 1 debt entries (M1.D/S5/G3) M1.D.44 - tag identity enters the schema digest. ComponentDesc gains content_digest, hashed; TagTable.contentDigest hashes the leaf count then each path by bit index, PLACING leaves at their own index rather than iterating the map, since a bit_index is a pure function of declaration order and hash order would vary build to build. Three TagSet descriptor producers were enumerated, not one: the interpreter's tagSetDesc, the codegen emission whose comment claims it mirrors the interpreter exactly, and a Tier 0 test literal. desc.fields was measured and REFUSED as the carrier - componentFields has ten readers, several on the scene-serialization path, which walk fields by kind. The false refusal is named and pinned: appending a tag inside one word is safe and is now refused, because a whole-table digest cannot tell a surviving prefix from a changed table. Accepted deliberately; an append crossing a word boundary was already refused. The test pinning the defect as an accepted adjacent case inverts to REFUSED, declared here. Counter-factual with the surviving subset named first: dropping content_digest from the hash reddens rename, reorder and append, exactly three, and the green twin stays green. M1.D.45 - both out. storage by arbitration (ARCH-005). requires by measurement taken before deciding: it never appears beside a size, alignment or offset in registry.zig, and every closure consumer is forward-looking, so a reload editing only the requires set leaves live bytes valid and meaning what they meant. Residual raised rather than filed: a newly added requisite is an invariant live entities may violate unchecked, which is not a layout problem. M1.D.38 - the E0217 emission is removed rather than relocated. It was dead by an early return fourteen lines above; relocating it above that return would fire for two undeclared symbols, where undefined_symbol is the true diagnostic. The code stays declared with no producer and its reason, and what single-module mode answers is pinned so the wrong route reddens. M1.D.39 - the set was FOUR, not the three the entry names. The three shapes are three callers of one parseCallArgList; the grammar also gives annotations an arg_list, and parseAnnotations carries its own loop with the same defect. Enumerating the function's callers gives three, the grammar production's users four. Both fixed, both independently witnessed: the test is red before the fix and red again when only the annotation break is removed. Floor re-derived from the suite 2379 -> 2385, windows 2383. The dead-tests bilateral control refused the first attempt and the declared value was moved to the measured one. Green at Debug/f32, ReleaseSafe and -Dphysics_f64=true; lint and fmt exit 0. Co-Authored-By: Claude Opus 5 --- briefs/m1.d-phase-1-debt.md | 138 ++++++++++++++++++++++++ src/core/ecs/registry.zig | 31 ++++++ src/etch/diagnostics.zig | 8 +- src/etch/interp.zig | 25 +++-- src/etch/parser.zig | 57 ++++++++++ src/etch/tags.zig | 44 ++++++++ src/etch/types.zig | 27 +++-- src/etch/zig_codegen/lower.zig | 16 ++- tests/etch/diagnostic_coverage_test.zig | 23 ++++ tests/etch/hot_reload_test.zig | 121 ++++++++++++++++++--- tools/weld_lint/dead_tests.zig | 8 +- 11 files changed, 462 insertions(+), 36 deletions(-) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index fc7058dd..1632019d 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -3781,6 +3781,144 @@ after the reverts and unchanged at 2379**, which is the check that matters here: two probes were appended and removed, so the total is what says the tree came back. Diff: one markdown file, **zero `.zig` touched**. +### S5/G3 — bloc 2: `M1.D.44`, `45`, `38`, `39`, and a set that was four where the entry said three + +Four entries closed. Every correctif that bears on a set was enumerated at the +code first, and **one of the four enumerations changed the answer**. + +#### `M1.D.44` — tag identity enters the digest, and where it could NOT go is the design + +`schemaDigestOf` hashes name, size, alignment and each field's (name, kind, +offset). `TagSet` carries `fields = &.{}` because a bitfield is not a struct, so +those four say how many WORDS of tags exist and never WHICH tag owns which bit. + +**The set, enumerated: THREE producers of a `TagSet` descriptor, not one** — +`interp.zig`'s `tagSetDesc` (read by both arms), the CODEGEN emission at +`lower.zig:1216`, and a Tier 0 test literal at `command_buffer.zig:444`. The +codegen one matters because its own comment claims it "mirrors the interpreter's +registration exactly", so a member the interpreter sets and it omits makes that +claim quietly false; it emits the digest as a literal. The test literal takes the +default and needed nothing. + +`ComponentDesc` gains `content_digest: u64 = 0`, hashed. It is the only slot +available, and that is a measurement rather than a preference: the four hashed +members are `name` (the lookup key), `size`, `alignment` and `fields` — and +**`fields` was measured and REFUSED**. Encoding 256 tag leaves as synthetic +fields would have bought a prefix check, and `componentFields` has **ten +readers**, several on the scene-serialization path, which walk fields by `kind` +to materialise values. Changing what that accessor means for ten callers to buy +an append allowance is not a debt-milestone fix. + +`TagTable.contentDigest` hashes the leaf count, then per bit index the path's +length and bytes. **The map is never iterated to produce the output** — leaves +are PLACED at their own index in a dense scratch array and read back in index +order — which is the property this file's own header makes load-bearing: a +`bit_index` is a pure function of declaration order, and hashing in hash-map +order would make the digest vary build to build. That the array fills completely +is asserted, not assumed. + +**The false refusal is named AND pinned, not declared.** A whole-table digest +cannot tell *the prefix survived* from *the table changed*, so APPENDING a tag +inside one word — which leaves every existing bit's meaning intact and is +therefore safe — is now refused. Accepted deliberately, in this repository's own +direction, and it is less of a change in kind than it looks: an append CROSSING a +word boundary was already refused, so "adding a tag may refuse your reload" was +already the behaviour, at 1 time in 64 rather than always. It has its own test +saying so. + +**The test that pinned the defect as ACCEPTED inverts** — *« a reload renaming +tags within one word is accepted — the adjacent case »* becomes REFUSED, declared +here as the discipline requires. Beside it: reorder, which a digest over names +alone would pass; and a GREEN TWIN, an identical reload still accepted, without +which the two refusals are satisfied by a digest that refuses every tag reload. + +**Counter-factual, surviving subset named before running**: dropping +`content_digest` from the hash reddens rename, reorder and append — **exactly +three** — and the green twin STAYS GREEN. + +#### `M1.D.45` — both out, one by arbitration and one by measurement + +`storage` stays out on Guy's ruling: `ARCH-005` permits a `table`/`sparse` change +and `registry.zig` already declares it out. + +`requires` stays out on a measurement taken before deciding, as instructed. +**It never appears beside a size, an alignment or an offset anywhere in +`registry.zig`** — it changes nothing about layout — and every consumer of the +closure is FORWARD-looking: `World.addComponent*`, the removal guards, the +observer arm, each gating the next operation. Nothing re-validates entities +already spawned. So a reload editing only `@requires` leaves every live entity's +bytes valid and meaning what they meant, and folding it into a LAYOUT digest +would fire a refusal under a message naming a layout that did not change. + +**The residual is real and is raised rather than filed**: a newly added requisite +is an invariant that already-live entities may violate, and nothing checks them. +That is not a layout problem and does not belong to this digest. Both decisions +are written at `schemaDigestOf` where the next reader meets them. + +#### `M1.D.38` — the emission is REMOVED rather than relocated + +The entry is exact: `validateTraitImpl` returns on `!trait_local` fourteen lines +before testing `!trait_local and !type_local`, so `E0217` was false by +construction. + +Relocating the test above that return WOULD make it fire — and would be wrong. +With no cross-module trait resolution, *not local* and *not declared* are one +predicate, so the only programs reaching it name two undeclared symbols, for +which `undefined_symbol` is the true diagnostic and *orphan impl* a worse one +wearing the right name. **Reachability bought by giving the code a meaning §7.4 +does not give it is not reachability.** + +So the emission goes, `E0217` stays DECLARED with no producer and its reason at +the declaration, and what single-module mode actually answers is pinned by a test +— so a later attempt to make it reachable by that wrong route reddens. + +#### `M1.D.39` — and the enumeration changed the answer: FOUR sites, not three + +The entry says the parser refuses the trailing comma "in the three argument +shapes". Measured: the three shapes — function call, method call, widget element +— are three CALLERS of one `parseCallArgList`, each verified at the code to +expect `)` immediately after, so one break covers them. + +**But the grammar gives `arg_list` to a fourth user.** `annotation = "@" , IDENT , +[ "(" , [ arg_list ] , ")" ]` at l.2274 reaches the same production, and +`parseAnnotations` carries its OWN argument loop with the same defect and does not +route through `parseCallArgList` at all. **Enumerating the function's callers +gives three; enumerating the GRAMMAR PRODUCTION's users gives four** — the unit +error avoided by counting the set the entry names rather than the one nearest to +hand. + +Both are fixed and **both are independently witnessed**: the call-args test was +RED before the fix and green after, and removing the ANNOTATION break alone +reddens it again. Beside it a twin proving the fix does not weaken what it sits +next to — a positional argument after a named one is still refused, trailing comma +or not — because the repair works by breaking the loop on `)`, and §3.3's ordering +rule is exactly the rule whose message the defect was borrowing. + +The test's own claim was corrected before landing: it said *all three argument +shapes* while driving two plus an annotation. It now states what it exercises and +what it covers structurally. + +#### Gates + +`zig build test` **exit 0**, 316/316, `2366/2385 tests passed (19 skipped)` · +ReleaseSafe **exit 0** · `-Dphysics_f64=true` **exit 0** · `zig build lint` +**exit 0**, *conservation OK … at 2385* · `zig fmt --check` **exit 0**. Each read +from its own exit code. + +**Floor re-derived FROM THE SUITE: 2379 → 2385**, windows 2383 by the guard's own +`only_on` table. The `dead-tests` bilateral control REFUSED the first attempt — +*CONSERVATION FAILED — closure gives 2385, declares 2379* — which is the control +doing its job, and the declared value was moved to the measured one rather than +the other way round. + +**Two incidents, self-reported.** The linter refused two comments of mine +carrying a milestone identifier, which §12 admits in no file — caught by the gate, +not by re-reading. And `${PIPESTATUS[0]}` reported an EMPTY exit status for a +piped `zig build`: in zsh the array is `$pipestatus` and is 1-indexed, so the +guard against reading a pipeline's last command returned nothing at all rather +than the wrong thing. Loud, and re-run without the pipe. Same family as the four +already on this record, and the third occurrence since it was written down. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree diff --git a/src/core/ecs/registry.zig b/src/core/ecs/registry.zig index d0938896..80c81752 100644 --- a/src/core/ecs/registry.zig +++ b/src/core/ecs/registry.zig @@ -185,6 +185,23 @@ pub const ComponentDesc = struct { /// computed once by `finalizeRequires` and read per add, never re-walked per /// add (`engine-ecs-internals.md` §3). requires: []const []const u8 = &.{}, + /// Digest of identity the four layout members CANNOT express, folded into + /// `schemaDigestOf`. Zero for every component whose identity is fully + /// described by its name, size, alignment and fields — which is all of them + /// but `TagSet`. + /// + /// `TagSet` is a bitfield and not a struct, so it carries `fields = &.{}` + /// and its size alone says how many WORDS of tags exist, never WHICH tag + /// owns which bit. Renaming or reordering tags inside one word therefore + /// left the digest identical while every live entity's bits changed meaning. + /// The producer is `TagTable.contentDigest`; nothing else sets this today. + /// + /// **Runtime-only, like the digest it feeds.** No cooked artifact carries + /// it: a `SchemaEntry` holds name, size and alignment, so widening this + /// tuple invalidates no scene and demands no re-cook. That is what makes it + /// safe to change the hash input at all — a confrontation is always between + /// two values of the SAME computation, never against a stored one. + content_digest: u64 = 0, }; /// The 64-bit schema identity of `desc` (`engine-ecs-internals.md` §13), over @@ -198,6 +215,19 @@ pub const ComponentDesc = struct { /// - **Storage mode is OUT of it.** `table` or `sparse` is a property of this /// registry and not of the layout (`ARCH-005`), so changing it provokes /// neither refusal nor migration. +/// - **`requires` is OUT of it too, and the reason is measured rather than +/// inherited from the line above.** It never appears beside a size, an +/// alignment or an offset anywhere in this file: it changes NOTHING about +/// layout, so a reload that only edits `@requires` leaves every live entity's +/// bytes valid and meaning exactly what they meant. Every consumer of the +/// closure — `World.addComponent*`, the removal guards, the observer arm — is +/// FORWARD-looking and gates the next operation; nothing re-validates entities +/// already spawned. So a newly added requisite is an invariant already-live +/// entities may violate unchecked, which is a real residual and is NOT a +/// layout problem: folding it in here would make this refusal fire under a +/// message that names a layout that did not change. +/// - **`content_digest` IS in it**, because it exists precisely to carry the +/// identity the four members above cannot. /// /// Sensitive to a field added in EXISTING padding, since offsets enter the hash. /// @@ -215,6 +245,7 @@ pub fn schemaDigestOf(desc: ComponentDesc) u64 { h.update(std.mem.asBytes(&k)); h.update(std.mem.asBytes(&f.offset)); } + h.update(std.mem.asBytes(&desc.content_digest)); return h.final(); } diff --git a/src/etch/diagnostics.zig b/src/etch/diagnostics.zig index d323fac5..8a7624a9 100644 --- a/src/etch/diagnostics.zig +++ b/src/etch/diagnostics.zig @@ -47,7 +47,13 @@ pub const DiagnosticCode = enum { ambiguous_trait_method, // E0211 AmbiguousTraitMethod incomplete_trait_impl, // E0214 IncompleteTraitImpl conditional_impl_condition_not_proven, // E0215 ConditionalImplConditionNotProven - orphan_impl, // E0217 OrphanImpl + /// E0217 OrphanImpl. DECLARED WITH NO PRODUCER, deliberately: the §7.4 + /// orphan rule needs a trait or type that RESOLVES while being foreign, and + /// with no cross-module trait resolution `not local` and `not declared` + /// coincide — so every program that could violate it is already answered by + /// `undefined_symbol`. The emission was removed from `validateTraitImpl` + /// rather than relocated; see the note there. + orphan_impl, immutable_receiver_for_mut_self, // E0220 ImmutableReceiverForMutSelfMethod closure_cannot_mutate_capture, // E0221 ClosureCannotMutateCapture collection_field_element_invalid, // E0222 CollectionFieldElementInvalid (resource collection field: unsupported element or nested collection) diff --git a/src/etch/interp.zig b/src/etch/interp.zig index e5854723..6bc1e240 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -1464,7 +1464,7 @@ pub const Interpreter = struct { const zeroed = try gpa.alloc(u8, size); defer gpa.free(zeroed); @memset(zeroed, 0); - const desc = tagSetDesc(size, zeroed); + const desc = tagSetDesc(size, zeroed, try tag_table.contentDigest(gpa)); // Idempotent on a hot-reload re-compile: reuse the // already-registered `TagSet` instead of erroring DuplicateComponent. if (world.registry.idOf("TagSet")) |existing| { @@ -1479,8 +1479,9 @@ pub const Interpreter = struct { const stored = world.registry.schemaDigest(existing) orelse ~candidate; if (stored != candidate) { std.log.warn( - "etch/hot-reload: 'TagSet' changed layout — reload REFUSED, previous image kept. " ++ - "live: size={d}; new: size={d} ({d} tag(s))", + "etch/hot-reload: 'TagSet' changed layout or tag identity — reload REFUSED, " ++ + "previous image kept. live: size={d}; new: size={d} ({d} tag(s)). " ++ + "An unchanged size means the tags themselves were renamed or reordered.", .{ world.registry.componentSize(existing), size, tag_table.leaf_count }, ); return error.SchemaChanged; @@ -7295,13 +7296,20 @@ pub const RegKind = enum { component, resource }; /// /// `default_bytes` is the caller's, because `registerComponentRaw` stores it; the /// digest does not read it (see `schemaDigestFor`). -fn tagSetDesc(size: u16, default_bytes: []const u8) weld_core.ecs.registry.ComponentDesc { +/// +/// `content_digest` is `TagTable.contentDigest` and carries WHICH TAG OWNS WHICH +/// BIT, which no other member can: `size` says how many words exist and +/// `fields` is empty because a bitfield is not a struct. Without it a reload +/// renaming or reordering tags inside one word kept the digest and silently +/// redefined every live entity's bits. +fn tagSetDesc(size: u16, default_bytes: []const u8, content_digest: u64) weld_core.ecs.registry.ComponentDesc { return .{ .name = "TagSet", .size = size, .alignment = 8, .default_bytes = default_bytes, .fields = &.{}, + .content_digest = content_digest, }; } @@ -7417,11 +7425,14 @@ fn verifySchemas( if (tag_table.leaf_count > 0) { if (registry.idOf("TagSet")) |existing| { const size: u16 = @intCast(tag_table.words() * 8); - const candidate = weld_core.ecs.registry.schemaDigestOf(tagSetDesc(size, &.{})); + const candidate = weld_core.ecs.registry.schemaDigestOf( + tagSetDesc(size, &.{}, try tag_table.contentDigest(gpa)), + ); if ((registry.schemaDigest(existing) orelse ~candidate) != candidate) { std.log.warn( - "etch/hot-reload: 'TagSet' changed layout — reload REFUSED, previous image kept. " ++ - "live: size={d}; new: size={d} ({d} tag(s))", + "etch/hot-reload: 'TagSet' changed layout or tag identity — reload REFUSED, " ++ + "previous image kept. live: size={d}; new: size={d} ({d} tag(s)). " ++ + "An unchanged size means the tags themselves were renamed or reordered.", .{ registry.componentSize(existing), size, tag_table.leaf_count }, ); return error.SchemaChanged; diff --git a/src/etch/parser.zig b/src/etch/parser.zig index d2cb82a0..7ef40119 100644 --- a/src/etch/parser.zig +++ b/src/etch/parser.zig @@ -948,6 +948,13 @@ pub const Parser = struct { try self.arena.annot_args.append(self.gpa, arg); args_len += 1; if (!try self.match(.comma)) break; + // Same optional trailing comma: `annotation = "@" , + // IDENT , [ "(" , [ arg_list ] , ")" ]` reaches the SAME + // `arg_list` production. This loop is annotation-local + // and does not go through `parseCallArgList`, which is + // why enumerating the grammar's `arg_list` users rather + // than that function's callers is what finds it. + if (self.peek() == .rparen) break; } } _ = try self.expect(.rparen, "expected ')' to close annotation args"); @@ -6425,6 +6432,18 @@ pub const Parser = struct { try out.args.append(self.gpa, a.raw()); try out.names.append(self.gpa, name); if (!try self.match(.comma)) break; + // THE TRAILING COMMA IS PART OF THE GRAMMAR, not a tolerance: + // `arg_list = arg , { "," , arg } , [ "," ]`, and the same optional + // comma is already honoured in array, struct, map and match-arm + // literals. Without this break the loop went on to parse an argument + // that is not there, and the two diagnostics it produced both named + // the wrong cause — on a named list it reported the §3.3 ordering + // rule, which the program was obeying. + // + // Every argument shape passes through here — function call, method + // call, widget element — and all three expect `)` immediately after, + // so testing for it covers the set rather than a sample. + if (self.peek() == .rparen) break; } } @@ -9296,6 +9315,44 @@ test "parser rejects a positional argument after a named one (§3.3)" { try std.testing.expect(result.diagnostics.len > 0); } +test "parser accepts a trailing argument comma (grammar l.571)" { + const gpa = std.testing.allocator; + // `arg_list = arg , { "," , arg } , [ "," ]` — the trailing comma is + // OPTIONAL, and the same optional comma is already honoured in array, + // struct, map and match-arm literals. + // + // WHAT THIS EXERCISES AND WHAT IT DOES NOT, stated rather than implied: the + // function-call and method-call shapes are driven here directly, plus an + // annotation, whose arguments are parsed by a SEPARATE loop. The widget + // element shape is the third caller of `parseCallArgList` and is covered + // STRUCTURALLY — one function, three callers, each verified at the code to + // expect `)` immediately after — and not by a case of its own. + var result = try parse(gpa, + \\@tag(.a,) + \\rule r(entity: Entity) when entity has C { + \\ f(1, 2,) + \\ entity.m(1,) + \\ g(a: 1, b: 2,) + \\} + ); + defer result.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), result.diagnostics.len); +} + +test "parser still rejects a positional argument after a named one, trailing comma or not" { + const gpa = std.testing.allocator; + // THE GREEN TWIN'S OTHER HALF. The trailing-comma fix works by breaking the + // loop when `)` follows the comma, so it must NOT weaken the §3.3 ordering + // rule — which is the rule whose message the defect was borrowing. + var result = try parse(gpa, + \\rule r(entity: Entity) when entity has C { + \\ f(a: 1, 2,) + \\} + ); + defer result.deinit(gpa); + try std.testing.expect(result.diagnostics.len > 0); +} + test "parser builds a behavior tree with composites, when, and leaf forms" { const gpa = std.testing.allocator; var result = try parse(gpa, diff --git a/src/etch/tags.zig b/src/etch/tags.zig index ca5805e0..acc98475 100644 --- a/src/etch/tags.zig +++ b/src/etch/tags.zig @@ -92,6 +92,50 @@ pub const TagTable = struct { return (self.leaf_count + 63) / 64; } + /// Digest over WHICH TAG OWNS WHICH BIT — the identity `words()` cannot + /// express, since a size says how many words exist and never what they mean. + /// Feeds `ComponentDesc.content_digest`, so a reload that renames or + /// reorders tags inside one word is refused instead of silently redefining + /// every live entity's bits. + /// + /// **The map is never iterated to produce the output**, which is the + /// property this file's header makes load-bearing: a `bit_index` is a pure + /// function of declaration order, and hashing in hash-map order would make + /// the digest vary build to build. Leaves are PLACED at their own index in a + /// dense scratch array and read back in index order, so the iteration order + /// reaches nothing observable. That the array fills completely is asserted + /// rather than assumed — a hole would mean a duplicate or an out-of-range + /// index, and a digest over a partly-filled array is a digest over garbage. + /// + /// Hashes the leaf count first, then per index the path's length and bytes. + /// The length is explicit so that `a.bc` and `ab.c` cannot collide by + /// concatenation. + pub fn contentDigest(self: *const TagTable, gpa: std.mem.Allocator) !u64 { + var h = std.hash.Wyhash.init(0); + h.update(std.mem.asBytes(&self.leaf_count)); + if (self.leaf_count == 0) return h.final(); + + const slots = try gpa.alloc([]const u8, self.leaf_count); + defer gpa.free(slots); + for (slots) |*sl| sl.* = &.{}; + + var it = self.map.iterator(); + while (it.next()) |kv| { + const e = kv.value_ptr.*; + if (!e.is_leaf) continue; + std.debug.assert(e.bit_index < self.leaf_count); + slots[e.bit_index] = kv.key_ptr.*; + } + + for (slots) |path| { + std.debug.assert(path.len != 0); + const len: u32 = @intCast(path.len); + h.update(std.mem.asBytes(&len)); + h.update(path); + } + return h.final(); + } + /// Look up a dotted path; `null` if it names neither a leaf nor a namespace. pub fn lookup(self: *const TagTable, path: []const u8) ?Entry { return self.map.get(path); diff --git a/src/etch/types.zig b/src/etch/types.zig index 98cf87d7..1a470be0 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -3726,12 +3726,27 @@ pub const TypeChecker = struct { try self.emit(.undefined_symbol, .error_, span, "trait-impl target '{s}' is not a struct, component, resource, or Entity", .{type_slice}); } - // Orphan rule (§7.4): trait OR type local to the impl's module. In single-file mode - // (single module) the trait is always local, so this holds — the check - // is structural for the cross-module future. - if (!trait_local and !type_local) { - try self.emit(.orphan_impl, .error_, span, "orphan impl: neither trait '{s}' nor type '{s}' is defined in this module", .{ trait_slice, type_slice }); - } + // ORPHAN RULE (§7.4), AND IT HAS NO EXPRESSIBLE INSTANCE TODAY — stated + // here instead of standing as a condition that cannot hold. + // + // The rule is that an impl is legal only if the trait OR the type is + // local to this module, so its violation needs a trait or a type that + // resolves while being FOREIGN. Two things forbid that. First, the + // `!trait_local` arm above RETURNS, so any test placed here reads + // `trait_local == true` by construction and `!trait_local and …` is + // false whatever the type is. Second, and this survives moving the test + // above that return: with no cross-module trait resolution, `not local` + // and `not declared` are the same predicate, so the only programs that + // could reach it are ones naming two undeclared symbols — for which + // `undefined_symbol` is the true and more useful diagnostic, and + // `orphan impl` would be a worse one wearing the right name. + // + // So the emission is REMOVED rather than relocated: relocating it would + // buy reachability by giving the code a meaning §7.4 does not give it. + // `E0217` stays declared, with no producer, until a module can name a + // foreign trait — the same gap the resolver already records for + // imported-trait impls. What single-module mode answers is pinned by a test, + // so a later attempt to make this reachable by the wrong route reddens. // E0214: every abstract trait method (no default body) must be provided. const tdecl = self.arena.trait_decls.items[self.arena.itemData(trait_sym.?.item_id)]; diff --git a/src/etch/zig_codegen/lower.zig b/src/etch/zig_codegen/lower.zig index 799927b4..19451cfd 100644 --- a/src/etch/zig_codegen/lower.zig +++ b/src/etch/zig_codegen/lower.zig @@ -1214,10 +1214,19 @@ fn emitRegister(w: *Writer, ast: *const AstArena, tag_table: *const tags_mod.Tag // Register the builtin `TagSet` component when the program // declares any tag. The raw descriptor mirrors the interpreter's // `compileProgram` registration exactly (name "TagSet", size `@sizeOf`, - // align `@alignOf`, zeroed default, no named fields) so the runtime - // component id and layout are byte-identical across backends. The id is - // discarded — the rules look it up by name via `idOf("TagSet")`. + // align `@alignOf`, zeroed default, no named fields, and the tag-table + // content digest) so the runtime component id and layout are byte-identical + // across backends. The id is discarded — the rules look it up by name via + // `idOf("TagSet")`. + // + // The digest is emitted as a LITERAL because it is a pure function of the + // tag table this pass already holds, and it is emitted at all so this + // enumeration stays exhaustive: a member the interpreter sets and this one + // omits is the "mirrors exactly" claim above going quietly false. Nothing in + // a generated binary reloads, so it changes no behaviour here — which is the + // argument for keeping the two descriptors identical rather than against it. if (tag_table.leaf_count > 0) { + const content_digest = try tag_table.contentDigest(w.gpa); try w.line("{"); w.indentBy(1); try w.line("var __tagset_default: TagSet = .{};"); @@ -1228,6 +1237,7 @@ fn emitRegister(w: *Writer, ast: *const AstArena, tag_table: *const tags_mod.Tag try w.line(".alignment = @alignOf(TagSet),"); try w.line(".default_bytes = std.mem.asBytes(&__tagset_default),"); try w.line(".fields = &.{},"); + try w.printLine(".content_digest = {d},", .{content_digest}); w.indentBy(-1); try w.line("});"); w.indentBy(-1); diff --git a/tests/etch/diagnostic_coverage_test.zig b/tests/etch/diagnostic_coverage_test.zig index e5be9b40..98ebd0a8 100644 --- a/tests/etch/diagnostic_coverage_test.zig +++ b/tests/etch/diagnostic_coverage_test.zig @@ -561,3 +561,26 @@ test "W1740 empty track" { try std.testing.expect(parsedClean(c)); try expectAnyCode(c.diags.items, .empty_track); } + +test "E0217 has no producer: an impl naming two undeclared symbols answers undefined_symbol" { + const gpa = std.testing.allocator; + // THE ARBITRATION PINNED. `E0217 OrphanImpl` is declared and deliberately + // emitted by nothing: §7.4's rule needs a trait or a type that RESOLVES + // while being foreign, and with no cross-module trait resolution `not + // local` and `not declared` are one predicate. So the nearest program to a + // violation — an impl whose trait AND type are both unknown — is answered + // by `undefined_symbol`, which is the true diagnostic. + // + // This test exists so that making `E0217` reachable by RELOCATING its + // emission above the `!trait_local` return reddens here: that route would + // buy reachability by reporting "orphan impl" for two typos. + var c = try check(gpa, + \\impl Missing for Absent { fn f(self) { } } + ); + defer c.deinit(gpa); + try std.testing.expect(parsedClean(c)); + try expectAnyCode(c.diags.items, .undefined_symbol); + for (c.diags.items) |d| { + try std.testing.expect(d.code != .orphan_impl); + } +} diff --git a/tests/etch/hot_reload_test.zig b/tests/etch/hot_reload_test.zig index 15a08590..6c30ec0a 100644 --- a/tests/etch/hot_reload_test.zig +++ b/tests/etch/hot_reload_test.zig @@ -241,8 +241,38 @@ const src_tags_wide = \\} ; -/// Same width as `src_tags_narrow`, different tag NAMES. Feeds the adjacent -/// case pinned below. +/// Same width as `src_tags_narrow`, same NAMES, order SWAPPED. A reorder keeps +/// the leaf count and therefore the size, while permuting every `bit_index`. +const src_tags_reordered = + \\tags { + \\ a { t01, t00 } + \\} + \\component Counter { value: int = 0 } + \\rule tick(entity: Entity) + \\ when entity has Counter + \\{ + \\ entity.get_mut(Counter).value += 1 + \\} +; + +/// One MORE tag than `src_tags_narrow`, still inside the first word. Every +/// pre-existing bit keeps its index and its meaning, so this reload is SAFE and +/// is nevertheless refused — the measured cost of a whole-table digest, pinned +/// below rather than left to be discovered. +const src_tags_appended = + \\tags { + \\ a { t00, t01, t02 } + \\} + \\component Counter { value: int = 0 } + \\rule tick(entity: Entity) + \\ when entity has Counter + \\{ + \\ entity.get_mut(Counter).value += 1 + \\} +; + +/// Same width as `src_tags_narrow`, different tag NAMES. Feeds the refusal +/// pinned below. const src_tags_renamed = \\tags { \\ a { u00, u01 } @@ -272,7 +302,7 @@ test "a reload widening TagSet past a word boundary is refused" { try std.testing.expectEqual(size_before, world.registry.componentSize(cid)); } -test "a reload renaming tags within one word is accepted — the adjacent case" { +test "a reload renaming tags within one word is REFUSED" { const gpa = std.testing.allocator; var world = World.init(); defer world.deinit(gpa); @@ -280,22 +310,83 @@ test "a reload renaming tags within one word is accepted — the adjacent case" const cid = world.registry.idOf("TagSet").?; - // ADJACENT CASE, ACCEPTED AND OUT OF THE REFUSAL'S SCOPE. `schemaDigestOf` - // hashes name, size, alignment and each FIELD's (name, kind, offset); the - // `TagSet` descriptor carries `fields = &.{}` because it is a bitfield and - // not a struct. So tag IDENTITY is not expressible in the digest at all: - // renaming or reordering tags without crossing a word boundary keeps the - // same size, hence the same digest, and the reload is accepted while the - // bit assignment of live entities now denotes different tags. + // `schemaDigestOf` hashes name, size, alignment and each FIELD's + // (name, kind, offset); the `TagSet` descriptor carries `fields = &.{}` + // because a bitfield is not a struct. So the four layout members say how + // many WORDS of tags exist and never WHICH tag owns which bit, and a rename + // inside one word used to keep the size, hence the digest, hence the reload + // — while every live entity's bits silently changed meaning. // - // Refusing it needs a digest over the tag table's own content — a different - // mechanism from the layout digest this test's sibling exercises, and NOT a - // gap in it. Pinned as accepted so the boundary is observable rather than - // asserted in prose. - try reloadOn(gpa, &world, src_tags_renamed); + // `ComponentDesc.content_digest` carries that identity now. The size is + // asserted UNCHANGED beside the refusal, which is what makes this a content + // refusal and not the sibling test's layout one: nothing about the layout + // moved. + try std.testing.expectError(error.SchemaChanged, reloadOn(gpa, &world, src_tags_renamed)); try std.testing.expectEqual(@as(usize, 8), world.registry.componentSize(cid)); } +test "a reload reordering tags within one word is REFUSED" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + try reloadOn(gpa, &world, src_tags_narrow); + + const cid = world.registry.idOf("TagSet").?; + + // The SECOND half of the defect, and not the same case as a rename: here + // every tag NAME survives and only the declaration order moves, which + // permutes `bit_index` and therefore what each live bit denotes. A digest + // over the names alone would pass this; the digest is over (index, path) in + // index order, so it does not. + try std.testing.expectError(error.SchemaChanged, reloadOn(gpa, &world, src_tags_reordered)); + try std.testing.expectEqual(@as(usize, 8), world.registry.componentSize(cid)); +} + +test "an identical tag reload is still accepted — the green twin" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + try reloadOn(gpa, &world, src_tags_narrow); + + // Without this the two refusals above are satisfied by a digest that refuses + // EVERY tag reload, which would be a blanket and not a discrimination. The + // same source reloads clean and the component keeps its identity. + const cid = world.registry.idOf("TagSet").?; + try reloadOn(gpa, &world, src_tags_narrow); + try std.testing.expectEqual(cid, world.registry.idOf("TagSet").?); + try std.testing.expectEqual(@as(usize, 8), world.registry.componentSize(cid)); +} + +test "appending a tag inside one word is refused — the MEASURED COST, not a defect" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + try reloadOn(gpa, &world, src_tags_narrow); + + // THE ADJACENT CASE, SHOWN RATHER THAN DECLARED, and it is a FALSE REFUSAL. + // Appending `t02` leaves `t00` at bit 0 and `t01` at bit 1: every live + // entity's bits keep their meaning, so this reload is safe and is refused + // anyway, because a digest over the whole table cannot distinguish "the + // prefix survived" from "the table changed". + // + // Accepted deliberately, in the direction this repository already chose: a + // refused reload costs a restart, a missed rename silently redefines live + // data. It is also less of a change in kind than it looks — an append + // CROSSING a word boundary was already refused, so "adding a tag may refuse + // your reload" was already the behaviour, just 1 time in 64 rather than + // always. + // + // What would remove it is a PREFIX check — compare the stored table against + // the new table's first N entries — and that needs the old table stored, not + // just its digest. The one slot that already stores a list, `desc.fields`, + // was measured and REFUSED for it: `componentFields` has ten readers, + // several on the scene-serialization path, which walk fields by `kind` to + // materialise values. Turning `TagSet`'s empty field list into 256 synthetic + // tag-named fields to buy an append allowance would change what that + // accessor means for every one of them. + try std.testing.expectError(error.SchemaChanged, reloadOn(gpa, &world, src_tags_appended)); +} + const src_r3_partial = \\component Extra { x: int = 0 } \\component Counter { value: int = 0, extra: int = 0 } diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index eaa23f03..c93f9139 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -234,8 +234,8 @@ pub const uncollected = [_]Uncollected{ /// same table and not a second measurement, which is why the CI layer matters. pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // RE-DERIVED FROM THE SUITE, never from the closure. `zig build test --summary all` - // reported `2360/2379 tests passed (19 skipped)` on macOS when these values were - // last set, and the closure arrives at 2379 independently from the table above. + // reported `2366/2385 tests passed (19 skipped)` on macOS when these values were + // last set, and the closure arrives at 2385 independently from the table above. // Bumping either to match the other is the repair the failure message forbids: it // turns two computations of one quantity into arithmetic on itself, and the drift // it was built to catch becomes invisible. @@ -247,8 +247,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // // Windows is two lower by the `only_on = .windows` entries above. return switch (os) { - .windows => 2377, - else => 2379, + .windows => 2383, + else => 2385, }; } From 40d45cb9e3166b998dcf50db25bb3083c349f9f1 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 22 Sep 2026 00:43:32 +0200 Subject: [PATCH 004/141] feat(etch): refuse arena values at the async retention point (S5/G4) M1.D.48 - the four silences are closed under a control shape that fires: both negative controls fire and both INT controls stay silent in the same execution. SET, OPTIONAL, CLOSURE and a string concat of two literals all park in AsyncTask.result with zero diagnostics, so all SEVEN rule-arena forms of Value are reachable there and none was diagnosed. The string row is the one not asked for and it is the point: a concat built from a LOCAL was already refused while the identical "x" + "y" was not, though both park the same .string_run. The first was covered only because let a made it a local the sibling rule walks - coverage by accident of spelling. And the second retention point needs NO refusal, measured rather than assumed. An int? event field and an int[] event field are refused as FIELD TYPES, a struct-valued filter is refused by the filter checker, and an enum carries a discriminant. A string is the only arena form that can reach a captured filter, and captureEventFilter deep-copies exactly it. So stabilising strings alone is complete, not partial, and the fix lands at one point instead of two. The assumption is written into a test so it falls the day an optional or collection event field becomes legal. The refusal sits at .return_stmt under conc_branch == .race - where a value enters the parked result - and consults isRuleArenaType, the SAME predicate as the sibling local rule, so the two cannot drift on which forms are arena-backed. False-refusal cost measured before proposing: ZERO on the repository, the corpus holding no race construct at all. The cost is two constructed cases, both found by the green twin rejecting them: a bare string literal, not separable because a literal, a concat and a resource string are all builtin .string_; and a bare enum shorthand, which arrives .unknown and lands on the arbitrated arm instead. Both pinned in a test named for the cost. Counter-factual: disabling the refusal reddens the per-form and cost tests and leaves the green twin and the filter test green. M1.D.46 - measured first under a declaration-level negative control: all seven collisions were accepted with zero diagnostics. isReservedEngineTypeName is DERIVED from builtin_resources rather than listed, with a test walking the table, because a second list is how the two come to disagree silently. TagSet gains a named constant. E0101 is reused with a contextual message on the duplicate-test- name precedent. The green twin includes names that merely resemble reserved ones. Floor re-derived from the suite 2392, windows 2390; the control refused the first attempt. Green at Debug/f32, ReleaseSafe and -Dphysics_f64=true. Probe hygiene under the new rule: appended at end of file, retired by git checkout. The last probe also shared the file with real work, so it was cut at its marker and the removal VERIFIED on the diff - 284 insertions, zero deletions - which is the evidence a global replace could not give. Co-Authored-By: Claude Opus 5 --- briefs/m1.d-phase-1-debt.md | 121 ++++++++++++++ src/etch/types.zig | 284 +++++++++++++++++++++++++++++++++ tools/weld_lint/dead_tests.zig | 8 +- 3 files changed, 409 insertions(+), 4 deletions(-) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index 1632019d..3c9226f8 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -3919,6 +3919,127 @@ guard against reading a pipeline's last command returned nothing at all rather than the wrong thing. Loud, and re-run without the pipe. Same family as the four already on this record, and the third occurrence since it was written down. +### S5/G4 — bloc 1: the four silences closed, and the measurement moved the fix from two points to one + +#### The four silences, with the control shape that makes them readings + +Both negative controls fire and both INT controls stay silent in the SAME +execution, which is what separated the third probe attempt from the two before +it. Under that shape: + +| form at the `race`-return point | before | +|---|---| +| array literal, map literal, struct literal | 0 — known | +| **SET** (`Set.from([1, 2])`) | **0** | +| **OPTIONAL** (`some(41)`) | **0** | +| **CLOSURE** (`\|x: int\| x * 2`) | **0** | +| **string concat of two LITERALS** (`"x" + "y"`) | **0** | +| int / float / bool / bare string literal | 0 — correct, not arena | + +**The string row is the one that was not asked for and matters most.** A concat +built from a LOCAL — `let a = "x"; return a + "y"` — was already refused, and the +identical `"x" + "y"` was not, though both park the same `.string_run`. The first +was covered only because `let a` made it a local the sibling rule walks. That is +coverage by accident of spelling, measured rather than argued, and it is the +class four reviews exploited four times. + +**So all SEVEN rule-arena forms of `Value` are reachable at the parked result and +none was diagnosed.** + +#### And the second retention point needs no refusal — the measurement says so + +The entry reads *« le filtre `WakeCond` dont la stabilisation ne couvre que les +chaînes »*. That is exact about the CODE and the opposite of a gap, because only +a string can arrive. Measured on the four other candidates, each under the same +control shape: + +| an event field typed… | verdict | +|---|---| +| `int?` | **refused as a FIELD TYPE** (`undefined_symbol`) | +| `int[]` | **refused as a FIELD TYPE** — collections are resource-only | +| a struct | field legal, **filter on it refused** (`invalid_field_filter`) | +| an enum | accepted, and a discriminant — POD | +| `string` | the only arena form that arrives, and `captureEventFilter` deep-copies exactly it | + +So `captureEventFilter` stabilising strings alone is **complete**, not partial. +The fix is at ONE retention point, not two, and that is written into a test so +the day an optional or collection event field becomes legal, the assumption has +somewhere to fall. + +#### The refusal, keyed on what the point retains + +`.return_stmt` under `conc_branch == .race` is where a value enters +`AsyncTask.result`, which is a bare `Value` stabilising nothing. The refusal +consults `isRuleArenaType` — **the same predicate as the sibling local rule**, so +the two cannot drift on which forms count as arena-backed. + +**False-refusal cost, measured before proposing.** On the repository: **ZERO** — +the corpus contains **no `race` construct at all**, so nothing existing is +refused. The cost is exactly two constructed cases, and BOTH were found by the +green twin rejecting them rather than by reading the predicate: + +- a bare `"x"`, an AST-pool handle that is safe to park. Not separable here: + a literal, a concatenation and a resource-owned string are all + `builtin .string_`, and the zone is a property of the value. +- a bare enum shorthand `.b`, which has no expected type to resolve against and + arrives `.unknown` — so this is the OTHER arm, the arbitrated refusal of a type + whose safety cannot be established, reached by a value that is a POD + discriminant at runtime. + +Both are pinned in a test named for the cost. + +**Counter-factual, subset named before running**: disabling the refusal reddens +the per-form test and the cost test, and leaves the green twin and the filter +test GREEN — which is what shows the refusal keys on STORAGE and not on +suspension. + +#### `M1.D.46` — the reservation is derived, not listed + +Measured first, under a declaration-level negative control that fires: **all +seven collisions were accepted**, zero diagnostics — three time resources and +`TagSet`, in both declaration kinds, the registry being one namespace. + +The panic path is as the entry states, and the comment above it was the costly +half: the injection arm takes `continue` when `idOf` finds the user's entry, and +the offset resolution below it asserts *« The lookups cannot miss »* — which that +very `continue` is the case that falsifies. + +`isReservedEngineTypeName` is **DERIVED from `builtin_resources`** rather than +listed, so adding a builtin resource extends the reservation by itself; a test +walks the table and asserts exactly that, because a hand-kept second list is how +the two come to disagree, silently, until a user picks the new name. `TagSet` +gains a named constant so the reservation and the injection cannot disagree about +which name is taken. The bound is `component` and `resource` and is stated: those +are the two kinds that enter the registry by name. + +`E0101` is reused with a contextual message rather than a new code, on the +precedent set for duplicate `test` names — the fault IS a duplicate symbol, the +other declarant being the engine. + +Green twin includes two names that RESEMBLE reserved ones — `GameTimer`, +`TagSetup` — since the predicate compares whole bytes and not a prefix. +Counter-factual: disabling the refusal reddens exactly the refusal test and +leaves both the twin and the derivation test green. + +#### Gates + +`zig build test` **exit 0**, 316/316, `2373/2392 tests passed (19 skipped)` · +ReleaseSafe **exit 0** · `-Dphysics_f64=true` **exit 0** · `zig build lint` +**exit 0**, *conservation OK … at 2392* · `zig fmt --check` **exit 0**. Each read +from its own exit code. + +**Floor re-derived FROM THE SUITE: 2385 → 2392**, windows 2390. The `dead-tests` +control refused the first attempt at 2385 and the declared value was moved to the +measured one. + +**Probe hygiene under the new rule, and it is now mechanically checkable.** Every +probe was appended at end of file and retired by `git checkout --`, never by +rewriting its content. The last probe could not be retired that way — the file by +then also held the gate's real work — so it was cut at its own marker and the +removal VERIFIED on the diff: **284 insertions, ZERO deletions, one file**. A +pure-addition diff is the evidence that no existing line was touched, which is +precisely what the global replace of the previous gate could not show. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree diff --git a/src/etch/types.zig b/src/etch/types.zig index 1a470be0..778d7ce7 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -250,6 +250,30 @@ pub const builtin_resources = [_]BuiltinResource{ } }, }; +/// The builtin COMPONENT the interpreter injects when a program declares any +/// tag. Named here rather than spelled at each site so the reservation below and +/// the injection itself cannot disagree about which name is taken. +pub const tagset_component_name = "TagSet"; + +/// True iff `name` is one the engine itself registers into the ECS registry, so +/// a program declaring a `component` or `resource` under it would collide. +/// +/// DERIVED from `builtin_resources` rather than listed, so adding a builtin +/// resource extends the reservation by itself — a second list is how the two +/// come to disagree. The bound is `component` and `resource` and nothing else: +/// those are the two declaration kinds that enter the registry by name, which is +/// where `idOf` collides. A `struct` is by-value and never registered, and an +/// `event` lives in its own namespace. +/// +/// What the collision cost before this: the builtin injection arm in `interp.zig` +/// finds the user's entry with `idOf`, takes its `continue`, and the offset +/// resolution below it then unwraps `findField(gid, "dt").?` on a type that has +/// no such field — a panic, with no diagnostic, on an ordinary program. +pub fn isReservedEngineTypeName(name: []const u8) bool { + if (std.mem.eql(u8, name, tagset_component_name)) return true; + return builtinResourceByName(name) != null; +} + /// Descriptor lookup by resource name bytes. Consulted by the /// receiver-less `get(T)` resolution and by the builtin-resource field /// lookup; `interp.zig` iterates `builtin_resources` directly. @@ -3319,6 +3343,16 @@ pub const TypeChecker = struct { // ─── Pass 1 ────────────────────────────────────────────────────────── + /// Refuse a `component`/`resource` declaration that takes a name the engine + /// itself registers. Reuses `E0101` with a contextual message rather than + /// minting a code, on the precedent set for duplicate `test` names: the fault + /// IS a duplicate symbol, the other declarant simply being the engine. + fn refuseReservedEngineName(self: *TypeChecker, name: StringId, span: SourceSpan, kind_word: []const u8) !void { + const slice = self.arena.strings.slice(name); + if (!isReservedEngineTypeName(slice)) return; + try self.emit(.duplicate_symbol, .error_, span, "'{s}' is registered by the engine; a {s} may not take that name", .{ slice, kind_word }); + } + fn pass1Collect(self: *TypeChecker) !void { const kinds = self.arena.items.items(.kind); const datas = self.arena.items.items(.data); @@ -3332,6 +3366,7 @@ pub const TypeChecker = struct { switch (kind) { .component_decl => { const decl = self.arena.component_decls.items[data]; + try self.refuseReservedEngineName(decl.name, span, "component"); try self.registerSymbol(.component, decl.name, item_id, span); try self.validateAnnotations(decl.annotations_extra, decl.annotations_len, .component); try self.checkStorageAnnotation(decl); @@ -3342,6 +3377,7 @@ pub const TypeChecker = struct { }, .resource_decl => { const decl = self.arena.resource_decls.items[data]; + try self.refuseReservedEngineName(decl.name, span, "resource"); try self.registerSymbol(.resource, decl.name, item_id, span); try self.validateAnnotations(decl.annotations_extra, decl.annotations_len, .resource); try self.validateFieldsInDecl(decl.fields_start, decl.fields_len, .resource); @@ -5533,6 +5569,25 @@ pub const TypeChecker = struct { const value: NodeId = @bitCast(data); if (!value.isNone()) { const vt = self.synthHeadValue(value, ctx); + // A `race` BRANCH'S RETURN IS A RETENTION POINT. The winner's + // value is parked in `AsyncTask.result` and re-raised at the + // race site several ticks later, after the rule arena has been + // reset — and `result` is a bare `Value` that stabilises + // nothing, where the sibling retention point at least + // deep-copies a string. + // + // Keyed on what the point RETAINS and not on how the value was + // written, which is the whole correction: `return a + "y"` was + // already refused and `return "x" + "y"` was not, though both + // park the same `.string_run` — the first only because `let a` + // made it a local the sibling rule walks. The same predicate + // as that rule, so the two cannot drift apart on which forms + // count as arena-backed. + if (self.conc_branch) |ck| { + if (ck == .race and isRuleArenaType(vt)) { + try self.emit(.rule_arena_value_escapes, .error_, self.arena.exprSpan(value), "this 'race' branch returns a value stored in the rule arena; the winner's value is parked and re-raised at the race site after the arena has been reset", .{}); + } + } if (self.current_fn_return) |ret| { if (ret == .builtin and vt == .builtin and !self.literalTypeFits(ret.builtin, value, vt.builtin)) { try self.emit(.return_type_mismatch, .error_, self.arena.exprSpan(value), "return value type does not match the declared return type", .{}); @@ -13154,3 +13209,232 @@ test "an optional whose payload is not a builtin is still refused" { defer v.deinit(gpa); try expectAnyCode(v.diagnostics.items, .rule_arena_value_escapes); } + +/// Build a one-rule program whose body is `body`, with optional extra `decls`. +/// Shared by the race-return retention tests below so every case differs in +/// exactly the expression under test and in nothing else. +fn raceReturnProgram(gpa: std.mem.Allocator, decls: []const u8, body: []const u8) ![:0]u8 { + return std.fmt.allocPrintSentinel( + gpa, + "component C {{ out: int = 0 }}\n{s}async rule r(entity: Entity)\n when entity has C\n{{\n race {{ {{ return {s} }} await wait(1.0s) }}\n}}\n", + .{ decls, body }, + 0, + ); +} + +test "a race branch returning a rule-arena value is refused, per form" { + const gpa = std.testing.allocator; + + // THE RETENTION POINT, NOT THE PRODUCTION SITE. A `race` branch's `return` + // value is parked in `AsyncTask.result` and re-raised at the race site + // several ticks later, after the rule arena has been reset. `result` is a + // bare `Value` and stabilises NOTHING — where `captureEventFilter` at least + // deep-copies a string. + // + // The refusal is keyed on what the point RETAINS, which is why every form + // is listed: before this, `return a + "y"` was refused and + // `return "x" + "y"` was not, though both park the same `.string_run` — + // the first only because `let a` made it a local that the sibling rule + // already walks. Coverage by accident of spelling. + const forms = [_]struct { decls: []const u8, expr: []const u8 }{ + .{ .decls = "", .expr = "[1, 2, 3]" }, // array literal + .{ .decls = "", .expr = "[1: 2]" }, // map literal + .{ .decls = "struct S { v: int = 0 }\n", .expr = "S { v: 1 }" }, // struct literal + .{ .decls = "", .expr = "Set.from([1, 2])" }, // set + .{ .decls = "", .expr = "some(41)" }, // optional + .{ .decls = "", .expr = "|x: int| x * 2" }, // closure + .{ .decls = "", .expr = "\"x\" + \"y\"" }, // string with NO local + }; + for (forms) |f| { + const src = try raceReturnProgram(gpa, f.decls, f.expr); + defer gpa.free(src); + var c = try parseAndCheck(gpa, src); + defer c.deinit(gpa); + expectAnyCode(c.diagnostics.items, .rule_arena_value_escapes) catch |e| { + std.debug.print("form not refused: return {s}\n", .{f.expr}); + return e; + }; + } +} + +test "a race branch returning a NON-arena value is still accepted — the green twin" { + const gpa = std.testing.allocator; + + // NAMED BEFORE RUNNING. Without this the refusal above is satisfied by a + // rule that refuses every `race` return whatsoever, which would key on the + // SUSPENSION and not on the STORAGE — the distinction the whole entry is + // about. A bare string literal is in the AST pool (`.string_id`), not the + // rule arena, so it belongs on this side. + const forms = [_]struct { decls: []const u8, expr: []const u8 }{ + .{ .decls = "", .expr = "1" }, + .{ .decls = "", .expr = "2.5" }, + .{ .decls = "", .expr = "true" }, + .{ .decls = "", .expr = "1 + 2" }, + .{ .decls = "", .expr = "entity" }, + }; + for (forms) |f| { + const src = try raceReturnProgram(gpa, f.decls, f.expr); + defer gpa.free(src); + var c = try parseAndCheck(gpa, src); + defer c.deinit(gpa); + expectNoCode(c.diagnostics.items, .rule_arena_value_escapes) catch |e| { + std.debug.print("green twin wrongly refused: return {s}\n", .{f.expr}); + return e; + }; + } +} + +test "a race branch returning a bare string literal is refused — the MEASURED COST" { + const gpa = std.testing.allocator; + + // THE FALSE REFUSAL, SHOWN RATHER THAN DECLARED. A bare `"x"` is an + // AST-pool handle (`.string_id`), copy-stable and outliving the arena, so + // parking it is safe and it is refused anyway. + // + // It is NOT separable at this point: `isRuleArenaType` answers on the + // RESOLVED type, and a literal, a concatenation and a resource-owned string + // are all `builtin .string_` — the zone is a property of the value, not of + // the type. Splitting them here would need the same thing the sibling rule + // needs and does not have. + // + // Taken deliberately, and for a reason beyond consistency: both retention + // points now consult ONE predicate, so a form can never count as + // arena-backed at one and not at the other. Measured cost on the repository: + // ZERO — the corpus contains no `race` construct at all, so nothing existing + // is refused by this. The cost is exactly this constructed case. + const src = try raceReturnProgram(gpa, "", "\"x\""); + defer gpa.free(src); + var c = try parseAndCheck(gpa, src); + defer c.deinit(gpa); + try expectAnyCode(c.diagnostics.items, .rule_arena_value_escapes); + + // SECOND CASE, AND IT IS A DIFFERENT ARM. A bare enum shorthand in a + // `return` has no expected type to resolve against and comes back + // `.unknown`, which the shared predicate refuses on the arbitrated ground + // that safety cannot be ESTABLISHED for an unresolved type. So this one is + // not the string over-refusal repeated — it is the `.unknown` arm, reached + // here by a value that happens to be a POD discriminant at runtime. Found + // by the green twin rejecting it, not by reading the predicate. + const en = try raceReturnProgram(gpa, "enum K { a, b }\n", ".b"); + defer gpa.free(en); + var e2 = try parseAndCheck(gpa, en); + defer e2.deinit(gpa); + try expectAnyCode(e2.diagnostics.items, .rule_arena_value_escapes); +} + +test "a scalar event filter is still accepted, and only a string can reach one" { + const gpa = std.testing.allocator; + + // THE SECOND RETENTION POINT NEEDS NO REFUSAL, and this pins why rather + // than asserting it. `wake.filter` retains the captured filter values + // across ticks, and `captureEventFilter` stabilises STRINGS only — which is + // complete, because a string is the only rule-arena form that can reach an + // event field at all. Measured on the four other candidates: an `int?` + // field and an `int[]` field are refused as FIELD TYPES, a struct-valued + // filter is refused by the filter checker, and an enum filter carries a + // discriminant. If any of those four ever becomes legal, this test is where + // the assumption is written down. + const scalar = + \\component C { out: int = 0 } + \\enum K { a, b } + \\event E { n: int = 0, k: K = .a } + \\async rule r(entity: Entity) + \\ when entity has C + \\{ + \\ await global_event(E { n: 1, k: .b }) + \\} + ; + var ok = try parseAndCheck(gpa, scalar); + defer ok.deinit(gpa); + try expectNoCode(ok.diagnostics.items, .rule_arena_value_escapes); + + // An OPTIONAL event field is refused at the field type, so `some(…)` never + // reaches a captured filter. + var opt = try parseAndCheck(gpa, + \\event E { n: int? } + ); + defer opt.deinit(gpa); + try expectAnyCode(opt.diagnostics.items, .undefined_symbol); + + // A COLLECTION event field likewise — collections are resource-only. + var arr = try parseAndCheck(gpa, + \\event E { xs: int[] } + ); + defer arr.deinit(gpa); + try expectAnyCode(arr.diagnostics.items, .undefined_symbol); +} + +test "a component or resource may not take a name the engine registers" { + const gpa = std.testing.allocator; + + // THE COLLISION WAS ACCEPTED AND THEN PANICKED, not diagnosed. `interp.zig` + // injects the builtin time resources after the user-declaration loop and + // skips any name already in the registry — so a user's `resource GameTime` + // won the name, the builtin arm took its `continue`, and the offset + // resolution below it unwrapped `findField(gid, "dt").?` on a type with no + // such field. The comment there asserted the lookups "cannot miss", which + // the `continue` arm is exactly the case that makes false. + // + // Every name is exercised, and both declaration kinds for each, because the + // registry is ONE namespace: a `component GameTime` collides as surely as a + // `resource` one. + const forms = [_][]const u8{ + "resource GameTime { dt: float = 0.0 }", + "resource GameTime { zz: int = 0 }", + "resource UnscaledTime { zz: int = 0 }", + "resource RealTime { zz: int = 0 }", + "component GameTime { zz: int = 0 }", + "component UnscaledTime { zz: int = 0 }", + "component RealTime { zz: int = 0 }", + "component TagSet { zz: int = 0 }", + "resource TagSet { zz: int = 0 }", + }; + for (forms) |src| { + const z = try std.fmt.allocPrintSentinel(gpa, "{s}", .{src}, 0); + defer gpa.free(z); + var c = try parseAndCheck(gpa, z); + defer c.deinit(gpa); + expectAnyCode(c.diagnostics.items, .duplicate_symbol) catch |e| { + std.debug.print("reserved name not refused: {s}\n", .{src}); + return e; + }; + } +} + +test "an ordinary component or resource name is untouched — the green twin" { + const gpa = std.testing.allocator; + + // NAMED BEFORE RUNNING. Without it the refusal above is satisfied by a rule + // that refuses every declaration, and the reservation would be indistinguishable + // from a blanket. Includes two names that merely RESEMBLE the reserved ones — + // the predicate compares whole bytes, not a prefix. + const forms = [_][]const u8{ + "resource Ordinary { zz: int = 0 }", + "component Ordinary { zz: int = 0 }", + "resource GameTimer { zz: int = 0 }", + "component TagSetup { zz: int = 0 }", + "resource Time { zz: int = 0 }", + }; + for (forms) |src| { + const z = try std.fmt.allocPrintSentinel(gpa, "{s}", .{src}, 0); + defer gpa.free(z); + var c = try parseAndCheck(gpa, z); + defer c.deinit(gpa); + expectNoCode(c.diagnostics.items, .duplicate_symbol) catch |e| { + std.debug.print("green twin wrongly refused: {s}\n", .{src}); + return e; + }; + } +} + +test "the reservation is DERIVED from the builtin table, not a second list" { + // If a builtin resource is added to `builtin_resources`, the reservation + // must extend by itself — a hand-kept list is how the two come to disagree, + // and the disagreement would be silent until a user picked the new name. + for (&builtin_resources) |*br| { + try std.testing.expect(isReservedEngineTypeName(br.name)); + } + try std.testing.expect(isReservedEngineTypeName(tagset_component_name)); + try std.testing.expect(!isReservedEngineTypeName("GameTimer")); + try std.testing.expect(!isReservedEngineTypeName("")); +} diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index c93f9139..cc506073 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -234,8 +234,8 @@ pub const uncollected = [_]Uncollected{ /// same table and not a second measurement, which is why the CI layer matters. pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // RE-DERIVED FROM THE SUITE, never from the closure. `zig build test --summary all` - // reported `2366/2385 tests passed (19 skipped)` on macOS when these values were - // last set, and the closure arrives at 2385 independently from the table above. + // reported `2373/2392 tests passed (19 skipped)` on macOS when these values were + // last set, and the closure arrives at 2392 independently from the table above. // Bumping either to match the other is the repair the failure message forbids: it // turns two computations of one quantity into arithmetic on itself, and the drift // it was built to catch becomes invisible. @@ -247,8 +247,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // // Windows is two lower by the `only_on = .windows` entries above. return switch (os) { - .windows => 2383, - else => 2385, + .windows => 2390, + else => 2392, }; } From 043a44e1d48aed98993160f197bc85fb02752ca2 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 22 Sep 2026 01:22:00 +0200 Subject: [PATCH 005/141] fix(build): drop the second epsilon, narrow bindgen-verify (S5/G5) M1.D.36 - fast_paths.zig defined its own contactMargin with its own conv_k = 16 while gjk.zig exports both. The entry says five call sites; there are four plus the definition - five OCCURRENCES. The local doc said the margin "must stay gjk.zig's", an intent the code did not enforce. Dependency direction measured before importing: the narrowphase graph is acyclic and epa.zig and shapecast.zig already depend on gjk the same way. The neighbour above it is NOT in the class: boxExtent mirrors coreExtent's .box arm at a site holding raw half-extents and contains NO constant, so it has nothing to drift. Swept out deliberately rather than folded in. Verified by the determinism instrument rather than by argument: forge-determinism exits 0 with the eight witnesses byte-identical and all four discrete invariants OK; forge suite green at f32, f64 and ReleaseSafe. M1.D.37 - the sharper fault is the PATH SET, not the comparison base. The gate named bindings/generated/ and src/core/platform/ wholesale while the adapters write exactly four files. The first holds two hand-maintained .api.zig sidecars no adapter writes; the second holds nine entries of which one is generated. So a comment edit to threading.zig turned a bindgen gate red - S4/G5 of this milestone did exactly that. Narrowed to the four, and based on HEAD because "matches the COMMITTED output" is the criterion the gate's own comment states. Then the second declarant was removed: the four paths were spelled once in the two zig fmt passes and again in the diff. generated_binding_files is now the one list all three read, so a new generated file cannot be formatted and then left ungated. Two-sided witness, re-run after that refactor: with threading.zig dirty the old predicate exits 1 and the full gate now exits 0; with a generated file dirty the predicate exits 1. Both directions, or the narrowing would be indistinguishable from switching the gate off. M1.D.41 - predicate derived, no rename performed. My first arm B gave 90 subjects against the entry's 16 because it dropped the single-entry constraint: a directory entered from outside by two or more of its files is not a module. Corrected, arm B reproduces exactly under six independent graph variants. My arm A was short by exactly three, which is the whole 8-against-11 gap: build.zig:1138 is root_source_file = b.path(spec.path) inside for (test_specs), and three of the table's entries are under src/. A literal extraction cannot see a path arriving through a loop variable - and tools/weld_lint/dead_tests.zig:920 names that exact trap in the repository's own words. The entry's count is right and mine was wrong. A ninth subject, plugin_loader/desc.zig, came from applying the arm-B re-export discriminant to an arm-A declared root. The deliverable is NOT executable: src/ holds two executables, simd/bench two, and simd/tests three files with no root.zig - each group would collapse onto one reserved name. Seven subjects cannot be renamed in place; closing them is a restructure, the same verdict forge/ carries. No lint rule written either, on the M1.D.11 precedent that a completeness rule over a non-conforming set produces filling. Classification put through an adversarial audit first: five lenses, 35 verdicts, 28 survived and 7 refuted. It found two errors and both were mine. Every decisive claim re-checked by me at the source before being written. Floor unchanged at 2392 - this gate adds no test, stated because it was measured. Green: test, lint, fmt, bindgen-verify, forge-determinism. Co-Authored-By: Claude Opus 5 --- briefs/m1.d-phase-1-debt.md | 167 ++++++++++++++++++ build.zig | 55 ++++-- .../pipeline/narrowphase/fast_paths.zig | 20 +-- 3 files changed, 211 insertions(+), 31 deletions(-) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index 3c9226f8..717e732b 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -4040,6 +4040,173 @@ removal VERIFIED on the diff: **284 insertions, ZERO deletions, one file**. A pure-addition diff is the evidence that no existing line was touched, which is precisely what the global replace of the previous gate could not show. +### S5/G5 — bloc 3: the second epsilon, the bindgen gate, and a predicate re-derived twice + +#### `M1.D.36` — the second epsilon removed, and the neighbour that is NOT in the class + +Re-established at the code: `gjk.zig:585` exports `contact_margin_conv_k = 16` and +`gjk.zig:591` exports `contactMargin`; `fast_paths.zig:154` defined its own with +its own `const conv_k: T = 16`. **The entry says five call sites; there are four**, +plus the definition — five OCCURRENCES. Reported because it is this milestone's own +class in miniature, and because nothing depends on which number is right. + +The local doc said the margin *« must stay `gjk.zig`'s »* — an intent the code did +not enforce, which is the costliest shape this repository names. + +**The dependency direction was measured before the import was added**, not assumed: +the narrowphase graph is acyclic, `gjk.zig` imports only `support.zig`, and +`epa.zig` and `shapecast.zig` already depend on `gjk` the same way. So the import +closes no cycle. + +**And the neighbour directly above it is NOT in the class.** `boxExtent` carries a +comment saying it *« mirrors `gjk.coreExtent` »*, which reads like the same defect. +It is not: `coreExtent` takes a `SupportShape(T)` and `boxExtent` takes raw +half-extents, so it is that function's `.box` arm at a site holding a vector — +and, decisively, **it contains no constant**. `M1.D.36` is about a THRESHOLD that +can drift; `he.length()` has nothing to drift. Swept deliberately out rather than +folded in, which would have changed a signature to buy nothing. + +**Verification is the determinism instrument, and it is exact.** The two +expressions are `T(16) · floatEps(T) · coord_scale` in the same operation order, +so the substitution must be bit-neutral — and that is checkable rather than +arguable here: `zig build forge-determinism` exits 0 with the eight witnesses +byte-identical and all four discrete invariants OK. Forge suite green at f32, +f64 and ReleaseSafe. + +#### `M1.D.37` — the defect is the PATH SET, and it is wider than the entry states + +The entry describes the comparison base — worktree against index. Measured, the +sharper fault is the paths: the gate named `bindings/generated/` and +`src/core/platform/` **wholesale**, while the adapters write exactly **four** files +(`vk.zig` and the three `wayland_protocols/*.zig`). + +| gated path | what is actually in it | +|---|---| +| `bindings/generated/` | **two** `.api.zig` sidecars, maintained BY HAND, written by no adapter | +| `src/core/platform/` | nine entries — `threading.zig`, `time.zig`, `fs.zig`, `window/`, `input/`, … of which ONE is generated | + +So an ordinary comment edit to `threading.zig` turned a *bindgen* gate red — which +is not a hypothesis: S4/G5 of this milestone did exactly that and recorded it. The +gate answered *is the working tree dirty under these paths* and reported it as a +verdict on generator drift. + +**Narrowed to the four files, and based on `HEAD` rather than the index** — because +*« matches the COMMITTED output »* is the criterion the gate's own comment states, +and the index is not the commit. The two agree on a clean tree and part company on +a staged edit, where only the `HEAD` form still compares against what was +committed. + +**Then the second declarant was removed, which is the part that keeps it true.** +The four paths were spelled once in the two `zig fmt` passes and again in the diff. +`generated_binding_files` is now the one list and all three read it, so a new +generated file cannot be formatted and then silently left ungated. + +**Two-sided witness, re-run after that refactor:** with `threading.zig` dirty the +OLD predicate exits **1** (the false alarm, reproduced) and the full gate now exits +**0**; with a GENERATED file dirty the predicate exits **1**, so the gate still +sees what it exists to see. Both directions, or the narrowing would be +indistinguishable from switching the gate off. + +One property is stated rather than tested, because the gate's shape makes it so: +the full gate REGENERATES before diffing, so a hand edit to a generated file in the +worktree is overwritten and cannot be witnessed end to end. That is correct — the +generator is the authority — and it means the gate detects DRIFT between the +committed file and the generator's output, never worktree tampering of a generated +file. The predicate witness above is what shows those four paths are still in +scope. + +#### `M1.D.41` — the predicate derived, MY count refuted, and the renames shown unsatisfiable + +**The plan's own predicate reaches none of the subjects**, as the entry says: it is +written on `b.addModule`, and the subjects are `createModule` roots or files not in +`build.zig` at all. So the predicate had to be derived. Two arms, per § *Fichier +racine*: **A**, a declared module root; **B**, the file by which a directory is +entered from OUTSIDE its own subtree. + +**Arm B needed one constraint my first derivation dropped, and dropping it gave 90 +subjects against the entry's 16.** A directory entered from outside by TWO OR MORE +of its files is not a module — its files are imported one by one — so only a +directory with EXACTLY ONE outside entry has a root to name. That is the discriminant +S4/G1 already recorded, and the first count was a set larger than the one the rule +names: the milestone's own class, committed again. + +**Arm B then reproduced exactly.** Independently recomputed over a 597-file graph +(`src/ tests/ bench/ tools/ examples/`, module-name imports resolved through +`build.zig`'s 27 `addImport` wirings, `//` comments stripped so a commented-out +import cannot forge an edge), it yields the SAME six non-conforming files, and is +invariant across six graph variants including a direct-children-only form. + +**And MY ARM A WAS WRONG BY EXACTLY THREE — which is the whole 8-against-11 gap.** +`build.zig:1138` reads `.root_source_file = b.path(spec.path)` inside +`for (test_specs) |spec|` (`:1136`), a `b.createModule` consumed by `b.addTest` at +`:1200`. Of the table's 116 entries, three are under `src/` — +`src/foundation/simd/tests/{adler32_test, correctness, paeth_test}.zig` at `:1116`, +`:1117`, `:1119`. A literal `b.path("…")` extraction cannot see a path that arrives +through a loop variable. **8 + 3 = 11: the entry's count is right and mine was +short.** + +**The repository had already written down the trap I fell into.** +`tools/weld_lint/dead_tests.zig:920` names it verbatim — *« when a module's +`root_source_file` is NOT a literal — `b.path(spec.path)` in the `test_specs` loop, +the ordinary shape »* — and its own root discovery resolves that shape. So this was +not an unknown: it was a documented trap, and a selector written by resemblance +walked into it. Tenth instance of the register's family, and the first where the +counter-measure was already in the tree. + +**A NINTH module-root subject, from a category error of mine.** +`src/core/plugin_loader/desc.zig` is a declared root at `build.zig:209-213` — arm A. +I excluded it with the re-export discriminant, which belongs to arm B: a file +re-exported by its directory's `root.zig` is not an *entry point*, but that says +nothing about a root `build.zig` declares outright. An arm-B test applied to an +arm-A subject. + +**THE DELIVERABLE — « les seize renommages » — IS NOT EXECUTABLE, and this is +measured rather than argued.** Three directories hold SEVERAL subjects each, and the +reserved name is one per directory: + +| directory | files | all would be named | +|---|---|---| +| `src/` | `demo_etch_codegen.zig`, `demo_etch_interp.zig` — both column-0 `pub fn main` | `src/main.zig` | +| `src/foundation/simd/bench/` | `adler32_bench.zig`, `paeth_bench.zig` — both executable roots | `main.zig` | +| `src/foundation/simd/tests/` | `adler32_test.zig`, `correctness.zig`, `paeth_test.zig` — no `root.zig` present | `root.zig` | + +Seven of the subjects therefore cannot be renamed in place at all: each group would +collapse two or three files onto one name. Closing them is a RESTRUCTURE — new +directories, and build-graph edits to match — which is the same verdict `forge/` +already carries as `M1.D.42`, reached here by a different route. + +**So no rename is performed and the lint rule is not written**, on the precedent +`M1.D.11` sets in this same table: a completeness rule over a set that cannot yet +conform produces filling, and conformity is the precondition. What this gate +delivers is the predicate itself, derived, verified in both arms, and now carrying +the three things the entry's own reading did not have — the single-entry constraint, +the table-variable arm-A blind spot, and the unsatisfiability. + +**One finding kept although it is not a subject**: `src/modules/render/gal/vulkan/sampler.zig` +has ZERO importers anywhere in the tree, `build.zig` included — a dead file, not an +entry point. It does not change its directory's entry count and is reported rather +than acted on. + +#### Method + +The classification went through an adversarial audit before being believed: five +independent lenses over the tree, every finding handed to a verifier charged with +REFUTING it — 35 verdicts, **28 survived, 7 refuted**. It found the two errors that +mattered and both were mine: the arm-A blind spot, and the arm-B discriminant +applied to an arm-A subject. Each decisive claim was then re-checked by me at the +source before being written here, which is where the `dead_tests.zig:920` citation +came from. + +#### Gates + +`zig build test` **exit 0**, 316/316, `2373/2392 tests passed (19 skipped)` · +`zig build lint` **exit 0**, *conservation OK … at 2392* · `zig fmt --check` +**exit 0** · `zig build bindgen-verify` **exit 0** · `zig build forge-determinism` +**exit 0**, eight witnesses byte-identical. Floor unchanged at **2392**: this gate +adds no test, which is stated because it was measured — `M1.D.36`'s witness is the +determinism instrument and `M1.D.37`'s is a two-sided predicate measurement, neither +of which is a suite test. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree diff --git a/build.zig b/build.zig index 89d864d0..9633e64f 100644 --- a/build.zig +++ b/build.zig @@ -2338,7 +2338,21 @@ pub fn build(b: *std.Build) void { // only after `zig fmt` normalises identifier escapes (e.g. `@"undefined"` → // `undefined`) and trims trailing blank lines. Pipe through fmt in the same // step so the command is self-sufficient regardless of pre-commit hooks. - const vk_gen_fmt = b.addSystemCommand(&.{ b.graph.zig_exe, "fmt", "src/core/platform/vk.zig" }); + // THE ONE LIST. Every file a bindgen adapter writes, named here and nowhere + // else: the two `zig fmt` passes below take slices of it, and so does the + // `bindgen-verify` diff. Spelled twice, the gate and the generators drift — + // a new generated file would be formatted and then not gated, silently. + const generated_binding_files = [_][]const u8{ + "src/core/platform/vk.zig", + "src/core/platform/window/wayland_protocols/core.zig", + "src/core/platform/window/wayland_protocols/xdg_shell.zig", + "src/core/platform/window/wayland_protocols/xdg_decoration.zig", + }; + const vk_generated = generated_binding_files[0..1]; + const wayland_generated = generated_binding_files[1..]; + + const vk_gen_fmt = b.addSystemCommand(&.{ b.graph.zig_exe, "fmt" }); + vk_gen_fmt.addArgs(vk_generated); vk_gen_fmt.step.dependOn(&vk_gen_run.step); const vk_gen_step = b.step("bindgen-vk", "Regenerate src/core/platform/vk.zig from vk.xml"); vk_gen_step.dependOn(&vk_gen_fmt.step); @@ -2357,13 +2371,8 @@ pub fn build(b: *std.Build) void { const wayland_gen_run = b.addRunArtifact(wayland_gen_exe); wayland_gen_run.has_side_effects = true; // Same fmt pass as vk_gen — see comment there. - const wayland_gen_fmt = b.addSystemCommand(&.{ - b.graph.zig_exe, - "fmt", - "src/core/platform/window/wayland_protocols/core.zig", - "src/core/platform/window/wayland_protocols/xdg_shell.zig", - "src/core/platform/window/wayland_protocols/xdg_decoration.zig", - }); + const wayland_gen_fmt = b.addSystemCommand(&.{ b.graph.zig_exe, "fmt" }); + wayland_gen_fmt.addArgs(wayland_generated); wayland_gen_fmt.step.dependOn(&wayland_gen_run.step); const wayland_gen_step = b.step( "bindgen-wayland", @@ -2429,29 +2438,37 @@ pub fn build(b: *std.Build) void { // -------------------------------------------- bindgen-verify gate -- // - // The non-negotiable mechanical criterion: regenerate then - // `git diff --quiet bindings/generated/ src/core/platform/`. Exit + // The non-negotiable mechanical criterion: regenerate then diff. Exit // 0 if the regen matches the committed output bit-for-bit; non-zero // (visible diff) signals a divergence and blocks the merge. // // KNOWN-GOOD CONTROL FIRST: `git diff --exit-code` answers 1 for a real diff // and a different non-zero when git cannot run at all, so the control must // establish that git answers before the diff's code is read as a verdict. + // + // THE PATHS ARE THE FOUR FILES THE GENERATORS WRITE, and nothing else. It + // used to name `bindings/generated/` and `src/core/platform/` wholesale, + // which is a superset of the generated set by a wide margin: the first holds + // two `.api.zig` sidecars that are maintained BY HAND and that no adapter + // writes, and the second holds `threading.zig`, `time.zig`, `fs.zig`, + // `window/`, `input/` and more. So an ordinary comment edit to + // `threading.zig` turned this gate red — observed, not imagined — and the + // gate answered a question about the working tree's cleanliness while + // reporting it as a verdict on generator drift. + // + // Against HEAD rather than the index, because "matches the COMMITTED output" + // is the criterion this comment states and the index is not the commit. The + // two agree on a clean tree and part company on a staged edit, where only + // this form still compares against what was committed. const bindgen_verify_control = b.addSystemCommand(&.{ "git", "--version" }); - const bindgen_verify_diff = b.addSystemCommand(&.{ - "git", - "diff", - "--quiet", - "--exit-code", - "bindings/generated/", - "src/core/platform/", - }); + const bindgen_verify_diff = b.addSystemCommand(&.{ "git", "diff", "--quiet", "--exit-code", "HEAD", "--" }); + bindgen_verify_diff.addArgs(&generated_binding_files); bindgen_verify_diff.step.dependOn(&bindgen_verify_control.step); bindgen_verify_diff.step.dependOn(&vk_gen_fmt.step); bindgen_verify_diff.step.dependOn(&wayland_gen_fmt.step); const bindgen_verify_step = b.step( "bindgen-verify", - "Regenerate bindings + assert `git diff --quiet` on bindings/generated + src/core/platform", + "Regenerate bindings + assert `git diff --quiet HEAD` on the four generated files", ); bindgen_verify_step.dependOn(&bindgen_verify_diff.step); diff --git a/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig b/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig index 70eeeb5c..750d2cd0 100644 --- a/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig +++ b/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig @@ -44,6 +44,10 @@ const std = @import("std"); const math = @import("foundation").math; const support = @import("support.zig"); +// Imported for the ONE margin, and the direction is safe: `gjk.zig` imports only +// `support.zig`, so this closes no cycle — `epa.zig` and `shapecast.zig` already +// depend on `gjk` the same way. +const gjk = @import("gjk.zig"); /// The contact seed a fast path hands to `manifold.generateManifold` — exactly /// the quantities the generic path's GJK/EPA block produces, so the generated @@ -148,14 +152,6 @@ fn boxExtent(comptime T: type, he: math.Vec(3, T)) T { return he.length(); } -/// The `separated` contact margin, which must stay `gjk.zig`'s: a fast pair and -/// its generic oracle have to classify the touch/separated boundary the same -/// way, up to the flip band. -fn contactMargin(comptime T: type, coord_scale: T) T { - const conv_k: T = 16; - return conv_k * std.math.floatEps(T) * coord_scale; -} - /// The sphere/sphere seed: cores are the two centres (radius excluded). Shallow /// for any non-zero centre distance (points are 0-D, never "deep" unless /// coincident); `.separated` past the inflated margin; a deterministic +X @@ -165,7 +161,7 @@ fn sphereSphere(comptime T: type, ca: math.Vec(3, T), ra: T, cb: math.Vec(3, T), const dist_sq = d.dot(d); const dist = @sqrt(dist_sq); const r_sum = ra + rb; - if (dist - r_sum > contactMargin(T, dist)) return .separated; + if (dist - r_sum > gjk.contactMargin(T, dist)) return .separated; // `normalize(d)` is scale-EQUIVARIANT, so the only thing to guard is 0/0. The // fallback fires ONLY at true coincidence (`dist² ≤ floatMin` — the type's // underflow floor, NOT a geometric scale): translation- and scale-invariant by @@ -225,7 +221,7 @@ fn sphereBox( const dist_sq = delta.dot(delta); const dist = @sqrt(dist_sq); const coord_scale = sphere_c.sub(box_c).length() + boxExtent(T, box_he); - if (dist - r_sum > contactMargin(T, coord_scale)) return .separated; + if (dist - r_sum > gjk.contactMargin(T, coord_scale)) return .separated; // Normal from the box surface toward the sphere centre; on the surface // (dist ≈ 0, the shallow↔deep seam) fall back to the least-penetration // face axis. `normalize(delta)` is scale-equivariant, so the fallback fires @@ -326,7 +322,7 @@ fn boxBox( const heb = he_b.toArray(); const dc = cb.sub(ca); const scale = dc.length() + he_a.length() + he_b.length(); - const margin = contactMargin(T, scale); + const margin = gjk.contactMargin(T, scale); // Track the least-overlap FACE axis and least-overlap EDGE axis separately so // a small bias can prefer a face on a near-tie (edge cross products carry more @@ -531,7 +527,7 @@ fn capsuleCapsule( const dist = @sqrt(dist_sq); const r_sum = r_a + r_b; const coord_scale = cb.sub(ca).length() + ha + hb; - if (dist - r_sum > contactMargin(T, coord_scale)) return .separated; + if (dist - r_sum > gjk.contactMargin(T, coord_scale)) return .separated; // `normalize(d)` is scale-equivariant ⇒ guard only 0/0: the fallback (radial / // mutual-perpendicular) fires ONLY at true coincidence (`dist² ≤ floatMin`, // e.g. collinear cores whose closest points coincide exactly). From e9f2b974eba5ad0cc82e838d18364c99659e1ec1 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 22 Sep 2026 01:35:40 +0200 Subject: [PATCH 006/141] =?UTF-8?q?docs(brief):=20measure=20M1.D.35=20?= =?UTF-8?q?=E2=80=94=20the=20remedy=20moves=20nothing=20(S5/G6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measurement only; no line written, and the reason is not the one the entry worries about. Who holds the locations: World.entity_locations, the single EntityId -> Location map (world.zig:167, declared "the single map" at :20). The archetype holds none, which is exactly why releaseChunkIfEmpty ANSWERS the renumbered index instead of repairing and World.reclaimChunk does the repair - the split M1.D.18 delivered. A reuse policy needs no new seam. What a move invalidates: NOTHING, because the named remedy moves no entity. removeSwap swaps the trailing entity into the freed slot, so every chunk holds a dense prefix and free space is always at the tail - never an interior hole. Partial-chunk REUSE appends at chunk.entity_count of a non-trailing chunk, which is the operation allocateSlot already performs on the trailing one. No entity moves, no Location changes, no pointer is invalidated. That is separate from COMPACTION, which the entry's title suggests and its deliverable does not name. Were it taken it would invalidate the moved entity's Location - world-owned, and reclaimChunk already repairs that class - and any live chunk pointer, comptime_query holding cur_chunk with a chunk_idx/slot cursor, already covered by the contract that structural change never runs mid-iterateArchetype. ComponentRef supports it WITHOUT CHANGE, and M1.D.18's recorded blocker is stale: it says moving an entity invalidates the chunk_ptr of every live ComponentRef and therefore touches a just-frozen contract. M1.D.21 removed chunk_ptr in S2/G1 of this same milestone, one session before that was written. Same shape as M1.D.12's deferral condition, met by work that landed elsewhere. Cost sized: only finding a partial chunk is new. A per-spawn scan is O(chunks), worst precisely in the regime the entry names where M1.D.18 measured 8200. A maintained first_partial u32 on the archetype is O(1) amortised - one u32 and maintenance at two sites. One observable changes and it is already not an invariant: under reuse a late spawn can iterate before earlier entities. The declared contract at query.zig:16 is archetype-creation order then chunk then slot, which describes the walk and survives. Spawn order equals iteration order is not that contract and is already broken today, removeSwap moving the trailing entity into the freed slot on every non-trailing removal. What is NOT measured is the benefit. M1.D.18's 6-against-2 is the full-drain case that reclamation already closes; the half-empty regime has no measurement, and the instrument is absent - ecs_hybrid_crossover.zig:149-150 records that DynamicQuery offers neither chunkAt nor chunkCount. So the obstruction is gone, the remedy is cheaper than it reads, and what is missing is the figure nobody doubted. Recommendation: build the probe first. Delivering a Tier 0 allocation policy on an unmeasured benefit is this milestone's dominant defect with the sign reversed. Floor unchanged at 2392; test and lint green. Co-Authored-By: Claude Opus 5 --- briefs/m1.d-phase-1-debt.md | 94 +++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index 717e732b..90724d29 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -4207,6 +4207,100 @@ adds no test, which is stated because it was measured — `M1.D.36`'s witness is determinism instrument and `M1.D.37`'s is a two-sided predicate measurement, neither of which is a suite test. +### S5/G6 — `M1.D.35` measured: the remedy moves nothing, and its recorded blocker was dissolved by this milestone + +Measurement only. No line written, because one of the two things a delivery needs +is missing and it is not the one the entry worries about. + +#### Q1 — who holds the locations + +`World.entity_locations`, an `AutoHashMapUnmanaged(EntityId, Location)` at +`world.zig:167`, and `world.zig:20` calls it *« the single map from +`EntityId → Location` »*. **The archetype holds none.** That asymmetry is not +incidental: it is exactly why `releaseChunkIfEmpty` ANSWERS which index was +renumbered instead of repairing it, and why `World.reclaimChunk` performs the +repair — the split `M1.D.18` delivered in S2/G2 of this milestone. A reuse policy +would need no new seam; it would use that one. + +#### Q2 — what an entity move invalidates: NOTHING, because the named remedy moves no entity + +`removeSwap` swaps the TRAILING entity into the freed slot and decrements the +count, so **every chunk holds a dense prefix `[0, entity_count)` and its free space +is always at the tail** — a chunk never carries an interior hole. + +Partial-chunk REUSE therefore appends at `chunk.entity_count` of a non-trailing, +non-full chunk, which is bit-for-bit the operation `allocateSlot` already performs +on the trailing one. No entity moves. No `Location` changes. No pointer is +invalidated. **The invalidation set is empty.** + +That is worth separating from the reading the entry's own TITLE suggests — +*« n'est jamais compacté »*. COMPACTION, moving live entities to close gaps, is a +different remedy and is not the one the entry's deliverable names. Were it taken it +would invalidate two things, both already handled elsewhere: the moved entity's +`Location`, which the world owns and which `reclaimChunk` already repairs in exactly +this class; and any live chunk pointer — `comptime_query` holds `cur_chunk: ?*Chunk` +with a `chunk_idx`/`slot` cursor — which the standing contract already covers, every +structural change being deferred to the tick boundary and never run +mid-`iterateArchetype`. + +#### Q3 — `ComponentRef` supports it WITHOUT CHANGE, and the recorded blocker is stale + +`ComponentRef` is `{ entity, component_id, mutable }`: no chunk pointer, no slot, +every access re-resolving through `World.componentBytes`. + +`M1.D.18`'s recorded reason for deferring says *« moving an entity between chunks +invalidates the `chunk_ptr` of every live `ComponentRef` — the type M1.B/G5 built, +whose table arm deliberately holds a chunk pointer — so the fix touches a contract +this milestone just froze »*. **That obstruction no longer exists. `M1.D.21` +removed `chunk_ptr` in S2/G1 of this same milestone**, one session before +`M1.D.18` was written. The reason was true when recorded and this milestone's own +first session dissolved it — the same shape as `M1.D.12`'s deferral condition, +met by work that landed elsewhere. + +#### The cost, sized + +Only one thing is new: finding a partial chunk. + +| form | cost | +|---|---| +| scan `self.chunks` per spawn | O(chunks) — worst precisely in the pathological regime the entry names, where `M1.D.18` measured **8200** chunks | +| a maintained `first_partial: u32` on the archetype | O(1) amortised — advanced when it fills at `allocateSlot`, lowered at `removeSwap` when a chunk drops below capacity at a smaller index. One `u32` per archetype, maintenance at two sites | + +#### One observable changes, and it is ALREADY not an invariant + +Under reuse a late-spawned entity can land in a low-index chunk and iterate before +earlier ones. The DECLARED contract is `query.zig:16` — *archetype-creation order → +chunk order → slot order* — which describes the WALK and survives untouched. + +*Spawn order → iteration order* is not that contract, and it is **already broken in +the shipped code**: `removeSwap` moves the trailing entity into the freed slot on +every non-trailing removal, so that entity's iteration position changes. Reuse does +not introduce the divergence; it widens one that ships. Stated because the natural +objection to reuse is an ordering one, and the ordering it would disturb is not +guaranteed today. + +#### What is NOT measured, and why nothing is written + +**The benefit.** `M1.D.18`'s 6-against-2 is the FULL-DRAIN case, which reclamation +already closes; the entry's own case — chunks stabilising at two or three entities +and never refilled — carries **no measurement at all**. And the instrument that +would give one does not exist: `bench/ecs_hybrid_crossover.zig:149-150` records that +`DynamicQuery` offers neither `chunkAt` nor `chunkCount`, so a dynamically +registered component has no chunk count to report. + +So the state is the inverse of what the entry implies. The OBSTRUCTION it records is +gone, the remedy is cheaper than it reads — one `u32` and two maintenance sites, +moving nothing — and what is missing is the thing no one doubted: a figure showing +the half-empty regime is reached by a real load. + +**Recommendation, and it follows this milestone's own doctrine rather than my +preference**: build the probe first, then decide. *A named cause that was never +measured* is what this milestone records as its dominant defect, and delivering a +Tier 0 allocation-policy change on an unmeasured benefit would be that defect with +the sign reversed. The alternative — deliver `first_partial` on the structural +argument alone — is defensible precisely because the cost is so small, and it is +Guy's call, not mine. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree From 0e253c85a3ba32ac6c3f049d81d121ce727e1ab7 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 22 Sep 2026 01:56:29 +0200 Subject: [PATCH 007/141] feat(ecs): refill a partially-filled chunk (M1.D/S5/G7) Archetype.first_partial is a LOWER BOUND on the index of any non-full chunk, not "the first partial chunk". A bound makes each maintenance site decidable alone where an equality would make every one owe a proof: lowering is always safe, advancing must be earned, and exactly one site earns it - allocateSlot, walking past chunks it has just observed full. Four sites: the walk advances on an observation, removeSwap lowers unconditionally by @min, and the two release arms clamp. A latent defect went with it: allocateSlot returned chunks.items.len - 1 unconditionally, correct only while the trailing chunk was the sole reachable destination and a WRONG LOCATION the moment an earlier chunk can be chosen. It now returns the chosen index. No entity moves. removeSwap swaps the trailing entity into the freed slot, so a chunk's occupants are a dense prefix and its free space is always at the tail; reuse appends there, which is what the trailing path already did. Hence no location repair, no ComponentRef change, no new seam. Three witnesses in the regime reclamation does not reach - chunks stabilising above zero - each pairing the chunk count with chunks_released pinned at ZERO so the other mechanism cannot take the credit: a quarter of chunk 0 drained then refilled against a FULL trailing chunk, count stays 3; the reused entity recorded at chunk 0 with its bytes read back through that location and every displaced survivor still readable; and the bound asserted as a bound across three rounds of drain-and-refill. Counter-factual built to isolate the DECISION from the MECHANISM - the bound stays maintained and only the choice reverts to trailing-only. Predicted and measured: tests 1 and 2 redden, test 3 stays GREEN, so the first two measure the reuse and the third measures the bound. The benefit is UNMEASURED and no figure is offered. M1.D.18's 6-against-2 is the full-drain case reclamation already closes; the half-empty regime has no load measurement and the instrument is absent - DynamicQuery offers neither chunkAt nor chunkCount. What justifies the change is structural: the invalidation set is empty and the cost is one u32 and four sites. Floor 2392 -> 2395, windows 2393. Green at Debug/f32, ReleaseSafe and -Dphysics_f64=true; lint, fmt, and forge-determinism with witnesses unchanged - which matters because this is a Tier 0 allocation-policy change. S5 closes: of the fifteen entries M1.D created, NINE are closed and six leave the milestone as architecture decisions or a restructure. Co-Authored-By: Claude Opus 5 --- briefs/m1.d-phase-1-debt.md | 166 +++++++++++++++++++++++++++++++++ src/core/ecs/archetype.zig | 60 ++++++++++-- src/core/ecs/world.zig | 117 +++++++++++++++++++++++ tools/weld_lint/dead_tests.zig | 8 +- 4 files changed, 339 insertions(+), 12 deletions(-) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index 90724d29..a96d022a 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -4301,6 +4301,172 @@ the sign reversed. The alternative — deliver `first_partial` on the structural argument alone — is defensible precisely because the cost is so small, and it is Guy's call, not mine. +### S5/G7 — `first_partial` delivered, and S5 closes + +#### The delivery, and what it deliberately does not claim + +`Archetype.first_partial: u32` — **a LOWER BOUND on the index of any non-full +chunk, not "the first partial chunk"**. That distinction is the design and not a +nuance: an equality nobody can cheaply maintain would make every maintenance site +owe a proof, where a bound makes each one decidable alone. **Lowering is always +safe; advancing is what must be earned**, and exactly one site earns it — +`allocateSlot`, walking past chunks it has just observed full. + +Four maintenance sites, each with its own reason written at it: the walk advances +the bound on an OBSERVATION; `removeSwap` lowers it unconditionally by `@min`, +because lowering an already-low bound costs a comparison and can never be wrong +where a conditional would have to reason about the pre-removal count; and the two +`releaseChunkIfEmpty` arms clamp it, one because the trailing chunk moved down into +a freed index and may be partial, the other because the list shrank. + +**A latent defect went with it.** `allocateSlot` returned +`.chunk_idx = chunks.items.len - 1` unconditionally — correct only while the +trailing chunk was the sole reachable destination, and a WRONG LOCATION the moment +an earlier chunk can be chosen. It now returns the chosen index. The second test +below is the one that would have caught it. + +**No entity moves.** `removeSwap` swaps the trailing entity into the freed slot, so +a chunk's occupants are a dense prefix and its free space is always at the tail — +reuse appends there, which is bit-for-bit what the trailing-chunk path already did. +That is why this needed no location repair, no `ComponentRef` change and no new +seam. + +#### Three witnesses, and the counter-factual that separates two questions + +The regime is the one reclamation does NOT reach — chunks stabilising ABOVE zero, +never emptied, so nothing is released. **Every assertion pairs the chunk COUNT with +`chunks_released` pinned at ZERO**, because a count that held while something was +reclaimed would be the other mechanism taking the credit. + +1. A quarter of chunk 0 drained, then the same number spawned back, with the + trailing chunk FULL so that without reuse every one of them allocates a fourth + chunk. Count stays 3, releases stay 0. +2. The LOCATION and not merely the count: the reused entity is recorded at chunk + **0**, its bytes read back through that recorded location, and every survivor + `removeSwap` displaced still readable at its new home. +3. The invariant as the BOUND it is declared to be — every chunk below + `first_partial` is full — asserted across three rounds of drain-and-refill, the + sequence that plays lowering at `removeSwap` against advancing at + `allocateSlot`. + +**Counter-factual, subset named before running, and built to isolate the DECISION +from the MECHANISM**: the bound stays fully maintained and only the choice reverts +to trailing-only. Measured — tests 1 and 2 redden, **test 3 stays GREEN**. So the +first two measure the reuse and the third measures the bound, and neither is +standing in for the other. + +#### What this does NOT establish, stated because no one took the figure + +**The benefit is unmeasured and no number is offered.** `M1.D.18`'s 6-against-2 is +the full-drain case that reclamation already closes; the half-empty regime has no +load measurement, and the instrument is absent — `bench/ecs_hybrid_crossover.zig` +records that `DynamicQuery` offers neither `chunkAt` nor `chunkCount`, so a +dynamically registered component has no chunk count to report. What justifies the +change is structural and stated as such: the invalidation set is empty and the cost +is one `u32` and four maintenance sites. **The entry closes saying the gain was +never measured rather than carrying a figure nobody took.** + +Two facts from G6 go to the entry rather than staying here. The entry's TITLE says +*« n'est jamais compacté »* while its deliverable names REUSE, and the two have +different invalidation sets — reuse moves nothing, compaction moves entities. And +its recorded blocker is stale: *« moving an entity invalidates the `chunk_ptr` of +every live `ComponentRef` »* was dissolved by `M1.D.21` in S2/G1 of this same +milestone, one session before the sentence was written. Second deferral condition +satisfied by work from elsewhere, after `M1.D.12`. + +#### Gates + +`zig build test` **exit 0**, 316/316, `2376/2395 tests passed (19 skipped)` · +ReleaseSafe **exit 0** · `-Dphysics_f64=true` **exit 0** · `zig build lint` +**exit 0**, *conservation OK … at 2395* · `zig fmt --check` **exit 0** · +`zig build forge-determinism` **exit 0**, witnesses unchanged — which matters here +because this is a Tier 0 allocation-policy change and the instrument is the only +thing that would show it perturbing a downstream order. Floor **2392 → 2395**, +windows 2393. + +--- + +## S5 — close + +**Fifteen entries M1.D created and did not treat. Nine closed, six leave the +milestone.** + +| closed in S5 | | +|---|---| +| `M1.D.44` | tag identity enters the schema digest | +| `M1.D.45` | `storage` and `requires` both ruled OUT, one by arbitration and one by measurement | +| `M1.D.38` | the unreachable `E0217` emission removed rather than relocated | +| `M1.D.39` | the trailing comma, on FOUR `arg_list` users and not the three the entry named | +| `M1.D.46` | builtin names reserved, the predicate DERIVED from the table | +| `M1.D.48` | arena values refused at the async retention point | +| `M1.D.36` | the second contact epsilon removed | +| `M1.D.37` | `bindgen-verify` narrowed to the four generated files, one declarant | +| `M1.D.35` | `first_partial` | + +| leaves the milestone | why | +|---|---| +| `M1.D.34` | restricting the f64 leg changes what the matrix attests | +| `M1.D.41` | a restructure: seven subjects would collapse onto one reserved name | +| `M1.D.42` | whether a directory may declare several modules | +| `M1.D.43` | a removal primitive in a registry whose ids are positional | +| `M1.D.47` | carry the zone beside the type, or split `ResolvedType` | + +Five decisions and a restructure, none of them accumulated debt. + +#### What S5 establishes, beyond the nine + +**A COUNTER-MEASURE WRITTEN DOWN STOPS NOTHING UNTIL AN INSTRUMENT EXECUTES IT.** +`tools/weld_lint/dead_tests.zig:920` names the `b.path(spec.path)` trap in the +repository's own words, and its root discovery resolves that shape. My extraction +fell into it anyway and under-counted `M1.D.41`'s module roots by exactly three. +Tenth instance of the register's selector family and the FIRST where the parade +already existed before the error — which is the same finding this session produced +on the other side, where a rule held in memory did not fire until a symptom +triggered it. + +**MY PROMPT SAID TWO RETENTION POINTS; THERE IS ONE.** `wake.filter` needed no +refusal: an `int?` or `int[]` event field is refused as a FIELD TYPE, a struct +filter by the filter checker, an enum carries a discriminant — a string is the only +arena form that can reach one, and `captureEventFilter` already copies exactly it. +*« Ne couvre que les chaînes »* was COMPLETE. Measured instead of implemented, and +the assumption pinned in a test that falls if a collection field ever becomes legal. + +**COVERAGE BY ACCIDENT OF SPELLING, MEASURED DIRECTLY.** `let a = "x"; return a + "y"` +was refused and `return "x" + "y"` was not, though both park the same `.string_run` +— the first only because `let a` made it a local the sibling rule walks. The +sharpest instance of the class four external reviews exploited four times, and it +came from a probe rather than a review. + +**THE GREEN TWIN'S OTHER HALF PRODUCED THE COST TWICE.** At G4 both false refusals +— a bare string literal, and a bare enum shorthand arriving `.unknown` — were found +by the twin REJECTING them, not by reading the predicate. At G7 the twin's role +inverted again: the surviving test is what shows the bound and the reuse are two +questions and not one. + +**FOUR OF MY OWN ERRORS, EACH CAUGHT BY AN INSTRUMENT AND NONE BY RE-READING.** An +arm B that counted 90 subjects where the rule names 16, because it dropped the +single-entry constraint. An arm A short by three, on the documented trap above. The +re-export discriminant — an arm-B test — applied to an arm-A declared root. And a +global `s.replace` over 13 000 production lines, whose innocence I refused to assert +without an instrument and which a later `git diff` proved harmless: **refusing to +claim the negative was right, and the negative was true.** + +**THREE BLIND PROBES IN ONE GATE, AND THE FIRST CAUSE WAS ONE CHARACTER.** +`wait(1s)` is not a valid duration literal, so every async case was silently +unparsed and BOTH negative controls read zero — a full page of plausible zeros. +What separated the third attempt from the first two was a control I had not written +until the second. The control shape — both negatives firing and both INT controls +silent in the same execution — is now the form every probe in this session used. + +**AND THE MILESTONE'S OWN PREMISE WAS REFUTED ON ITS OWN SUBJECT.** `M1.D.35`'s +recorded blocker was dissolved by `M1.D.21`, one session earlier in the same +milestone; `M1.D.47`'s deliverable said the zone would close three defects *« sans +exemption par provenance »* while the measurement says it must be SUPPLIED by +provenance at the four sites where the ambiguity is born; and `M1.D.41`'s +sixteen renames are unsatisfiable. Three entries whose own text the tree +contradicted — which is what this milestone exists to find, applied to the entries +it created. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree diff --git a/src/core/ecs/archetype.zig b/src/core/ecs/archetype.zig index beccab62..4faf4cb9 100644 --- a/src/core/ecs/archetype.zig +++ b/src/core/ecs/archetype.zig @@ -142,6 +142,24 @@ pub const Archetype = struct { registry: *const Registry, layout: ChunkLayout, chunks: std.ArrayListUnmanaged(*Chunk) = .empty, + /// A LOWER BOUND on the index of any non-full chunk: every chunk below it is + /// full. Not "the first partial chunk" — that would be an equality nobody + /// could cheaply maintain, and stating it as a bound is what makes each + /// maintenance site decidable on its own. Lowering is ALWAYS safe (it can + /// only make the scan start earlier); advancing is what must be earned, and + /// `allocateSlot` earns it by walking past chunks it has just observed full. + /// + /// Without it `allocateSlot` filled only the TRAILING chunk, so a chunk left + /// half-empty by churn was never refilled and the count followed cumulative + /// appends rather than live population. + /// + /// **No entity moves to make this work.** `removeSwap` swaps the trailing + /// entity into the freed slot, so a chunk's occupants are always a dense + /// prefix and its free space is always at the tail — reuse appends there, + /// exactly as the trailing-chunk path already did. That is why this needs no + /// location repair and no `ComponentRef` change: nothing is invalidated + /// because nothing is displaced. + first_partial: u32 = 0, transitions: TransitionCache = .{}, /// `true` iff this archetype hosts a singleton-entity /// resource. Set by `resources.setResource` after spawning the @@ -248,13 +266,22 @@ pub const Archetype = struct { /// sidecars are initialised to `tick`, and the slot's dirty bit is /// set — the entity is "fresh" for the current frame. pub fn allocateSlot(self: *Archetype, gpa: std.mem.Allocator, tick: Tick) ArchetypeError!SpawnResult { - const chunk = blk: { - if (self.chunks.items.len > 0) { - const last = self.chunks.items[self.chunks.items.len - 1]; - if (last.header().entity_count < self.layout.capacity) break :blk last; - } - break :blk try self.allocChunk(gpa); - }; + // Walk forward from the bound, past chunks observed FULL, and record how + // far we got — that is the only place the bound advances, and it + // advances on an observation rather than on an assumption. The walk is + // amortised O(1): each step it takes is paid once per chunk until + // something lowers the bound again. + var idx = self.first_partial; + while (idx < self.chunks.items.len and + self.chunks.items[idx].header().entity_count >= self.layout.capacity) : (idx += 1) + {} + self.first_partial = idx; + + const chunk = if (idx < self.chunks.items.len) + self.chunks.items[idx] + else + try self.allocChunk(gpa); + const chunk_idx: u32 = @intCast(idx); const hdr = chunk.header(); const slot = hdr.entity_count; hdr.entity_count = slot + 1; @@ -267,8 +294,11 @@ pub const Archetype = struct { } change_detection.setDirty(chunk.dirtyBitset(&self.layout), slot); + // The CHOSEN index, not the trailing one. The old form was correct only + // because the only reachable destination was the last chunk; it becomes + // a wrong location the moment an earlier chunk can be the destination. return .{ - .chunk_idx = @intCast(self.chunks.items.len - 1), + .chunk_idx = chunk_idx, .slot = slot, }; } @@ -330,6 +360,12 @@ pub const Archetype = struct { const chunk = self.chunks.items[chunk_idx]; const hdr = chunk.header(); std.debug.assert(slot < hdr.entity_count); + // This chunk is about to have room, so the bound cannot stay above it. + // Unconditional `@min` rather than a test on capacity: the bound is a + // LOWER bound, so lowering it when it was already low costs a comparison + // and can never be wrong, where a conditional would have to reason about + // the pre-removal count. + self.first_partial = @min(self.first_partial, chunk_idx); const last = hdr.entity_count - 1; if (slot == last) { hdr.entity_count = last; @@ -387,10 +423,18 @@ pub const Archetype = struct { self.chunks_released += 1; if (chunk_idx == last_idx) { _ = self.chunks.pop(); + // The list shrank; a bound past its end would make the walk skip the + // allocation branch's precondition. Clamp rather than reset, so what + // was earned about the chunks below is not thrown away. + self.first_partial = @min(self.first_partial, @as(u32, @intCast(self.chunks.items.len))); return null; } self.chunks.items[chunk_idx] = self.chunks.items[last_idx]; _ = self.chunks.pop(); + // The trailing chunk moved DOWN into `chunk_idx` and may be partial, so + // the bound must not sit above its new home. Clamping to the new length + // in the same expression keeps the bound inside the list after a pop. + self.first_partial = @min(self.first_partial, chunk_idx); return chunk_idx; } diff --git a/src/core/ecs/world.zig b/src/core/ecs/world.zig index d360bea7..72dc064a 100644 --- a/src/core/ecs/world.zig +++ b/src/core/ecs/world.zig @@ -2939,3 +2939,120 @@ test "sustained churn keeps the chunk count on the live population" { try std.testing.expectEqual(@as(usize, 2), arch.chunks.items.len); try std.testing.expect(arch.chunks_released >= 4); } + +// ---------------------------------------------------------- partial reuse -- +// +// The regime the reclamation-at-zero path does NOT reach: chunks that stabilise ABOVE +// zero. They are never emptied, so nothing is released, and before `first_partial` +// they were never refilled either — `allocateSlot` looked only at the trailing +// chunk, so every spawn landed there and a half-empty chunk stayed half-empty. +// +// Every assertion below is on the chunk COUNT with `chunks_released` pinned at +// ZERO beside it. The pairing is the point: a count that stays put while something +// was reclaimed would be the other mechanism, and this one must be shown to work +// with the other mechanism silent. + +test "a chunk left partial by churn is refilled, and nothing is reclaimed" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + const cid = try world.registerComponentRaw(gpa, reclaimProbe("PA")); + const first = try world.spawnDynamic(gpa, &.{cid}); + const arch = world.archetypes.items[world.entity_locations.get(first).?.archetype_idx]; + const cap = arch.layout.capacity; + + var ids: std.ArrayListUnmanaged(EntityId) = .empty; + defer ids.deinit(gpa); + try ids.append(gpa, first); + while (ids.items.len < 3 * cap) try ids.append(gpa, try world.spawnDynamic(gpa, &.{cid})); + try std.testing.expectEqual(@as(usize, 3), arch.chunks.items.len); + + // Drain a QUARTER of chunk 0 — enough to leave room, never enough to empty + // it, so the reclamation path cannot fire and take the credit. + const holes = cap / 4; + try std.testing.expect(holes > 0); + for (0..holes) |i| try world.despawn(gpa, ids.items[i]); + try std.testing.expectEqual(@as(u64, 0), arch.chunks_released); + try std.testing.expectEqual(@as(usize, 3), arch.chunks.items.len); + + // Spawn exactly as many back. The trailing chunk is FULL, so without reuse + // every one of these allocates into a FOURTH chunk. + for (0..holes) |_| _ = try world.spawnDynamic(gpa, &.{cid}); + + try std.testing.expectEqual(@as(usize, 3), arch.chunks.items.len); + try std.testing.expectEqual(@as(u64, 0), arch.chunks_released); +} + +test "a reused slot is reported at the chunk it actually landed in" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + const cid = try world.registerComponentRaw(gpa, reclaimProbe("PB")); + const first = try world.spawnDynamicWithValues(gpa, &.{cid}, &.{&[_]u8{ 7, 0, 0, 0 }}); + const arch = world.archetypes.items[world.entity_locations.get(first).?.archetype_idx]; + const cap = arch.layout.capacity; + + var ids: std.ArrayListUnmanaged(EntityId) = .empty; + defer ids.deinit(gpa); + try ids.append(gpa, first); + while (ids.items.len < 2 * cap) { + try ids.append(gpa, try world.spawnDynamicWithValues(gpa, &.{cid}, &.{&[_]u8{ 7, 0, 0, 0 }})); + } + try std.testing.expectEqual(@as(usize, 2), arch.chunks.items.len); + + try world.despawn(gpa, ids.items[0]); + + // THE LOCATION, not merely the count. `allocateSlot` used to answer + // `chunks.items.len - 1` unconditionally, which was right only while the + // trailing chunk was the sole possible destination; the moment an earlier + // chunk can be chosen, that answer names the wrong chunk and the entity is + // recorded where it is not. So this asserts chunk 0 AND reads the bytes back + // through the recorded location. + const reused = try world.spawnDynamicWithValues(gpa, &.{cid}, &.{&[_]u8{ 9, 0, 0, 0 }}); + const loc = world.entity_locations.get(reused).?; + try std.testing.expectEqual(@as(u32, 0), loc.chunk_idx); + try std.testing.expectEqual(@as(usize, 2), arch.chunks.items.len); + + const bytes = world.componentBytes(reused, cid).?; + try std.testing.expectEqual(@as(u8, 9), bytes[0]); + + // And the entity that removeSwap displaced is still readable at its new home. + for (ids.items[1..]) |e| { + const b = world.componentBytes(e, cid) orelse return error.SurvivorLost; + try std.testing.expectEqual(@as(u8, 7), b[0]); + } +} + +test "first_partial is a lower bound: every chunk below it is full" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + const cid = try world.registerComponentRaw(gpa, reclaimProbe("PC")); + const first = try world.spawnDynamic(gpa, &.{cid}); + const arch = world.archetypes.items[world.entity_locations.get(first).?.archetype_idx]; + const cap = arch.layout.capacity; + + var ids: std.ArrayListUnmanaged(EntityId) = .empty; + defer ids.deinit(gpa); + try ids.append(gpa, first); + while (ids.items.len < 3 * cap) try ids.append(gpa, try world.spawnDynamic(gpa, &.{cid})); + + // Churn across three chunks, then re-fill, then churn again — the sequence + // that exercises lowering at `removeSwap` against advancing at `allocateSlot`. + var round: usize = 0; + while (round < 3) : (round += 1) { + for (0..cap / 8) |i| try world.despawn(gpa, ids.items[round * cap + i]); + for (0..cap / 8) |_| _ = try world.spawnDynamic(gpa, &.{cid}); + + // THE INVARIANT, asserted as the bound it is declared to be — not as an + // equality with "the first partial chunk", which the field deliberately + // does not claim. + for (arch.chunks.items[0..arch.first_partial]) |c| { + try std.testing.expectEqual(arch.layout.capacity, c.header().entity_count); + } + try std.testing.expect(arch.first_partial <= arch.chunks.items.len); + } +} diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index cc506073..085147fe 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -234,8 +234,8 @@ pub const uncollected = [_]Uncollected{ /// same table and not a second measurement, which is why the CI layer matters. pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // RE-DERIVED FROM THE SUITE, never from the closure. `zig build test --summary all` - // reported `2373/2392 tests passed (19 skipped)` on macOS when these values were - // last set, and the closure arrives at 2392 independently from the table above. + // reported `2376/2395 tests passed (19 skipped)` on macOS when these values were + // last set, and the closure arrives at 2395 independently from the table above. // Bumping either to match the other is the repair the failure message forbids: it // turns two computations of one quantity into arithmetic on itself, and the drift // it was built to catch becomes invisible. @@ -247,8 +247,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // // Windows is two lower by the `only_on = .windows` entries above. return switch (os) { - .windows => 2390, - else => 2392, + .windows => 2393, + else => 2395, }; } From f4a92da4b8cfc3241c783f8275313b76cbce343f Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 22 Sep 2026 03:24:31 +0200 Subject: [PATCH 008/141] chore(debt): close ten entries M1.D created (M1.D/S5) S5's subject is the fifteen entries M1.D itself created, under a rule the milestone did not have: a defect found while working closes in the same session, or it is a STOP and an arbitration, never a new number. Measured on the delivered lot (sha256 6da1d093, 648 lines): TEN of the fifteen are closed - M1.D.40 by S4, and 44, 45, 38, 39, 46, 48, 36, 37, 35 by S5 - and FIVE leave the milestone. S5 minted ZERO new entries, which is what it existed to demonstrate against a milestone that minted nine. The five that leave are not accumulated debt. M1.D.34: restricting the f64 leg changes what the matrix attests. M1.D.42: whether a directory may declare several modules. M1.D.43: a removal primitive in a registry whose ids are positional. M1.D.47: carry the zone beside the type, or split ResolvedType. Four architecture decisions. M1.D.41 is the fifth and is a restructure rather than a renaming: seven of its subjects would collapse onto one reserved name. THREE ENTRIES' OWN TEXT WAS CONTRADICTED BY THE TREE, which is this milestone's purpose applied to its own output. M1.D.47 does not overlap 44 and 45 at all - the digest input path holds zero ResolvedType references across its four functions, and the premise was a homonym, zone against @storage. M1.D.35's recorded blocker was dissolved by M1.D.21 one session earlier in the same milestone. M1.D.41's sixteen renames are unsatisfiable. The standing result: A COUNTER-MEASURE WRITTEN DOWN STOPS NOTHING UNTIL AN INSTRUMENT EXECUTES IT. dead_tests.zig:920 names the b.path(spec.path) trap in the repository's own words and the linter resolves that shape; my extraction walked into it anyway and under-counted M1.D.41 by exactly three. Tenth instance of the selector family and the first where the parade existed before the error. Beside it: one retention point measured instead of implemented, the prompt having said two - a string is the only arena form that can reach an event field, and captureEventFilter already copies it. Coverage by accident of spelling measured directly - return a + "y" refused where return "x" + "y" was not, both parking the same handle. Four errors of mine, every one caught by an instrument and none by re-reading, plus a fifth in the closing tally itself. Three blind probes in one gate whose first cause was ONE CHARACTER, wait(1s) against wait(1.0s), with both negative controls reading zero. And the latent defect in allocateSlot caught before it shipped: it returned chunks.items.len - 1 unconditionally, a wrong location the moment an earlier chunk can be chosen. Floor re-derived from the suite at every gate and never carried: 2379 -> 2395, windows 2393. Green at Debug/f32, ReleaseSafe and -Dphysics_f64=true; lint conservation OK, fmt, bindgen-verify, and forge-determinism with its eight witnesses byte-identical. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 12 +-- briefs/m1.d-phase-1-debt.md | 155 +++++++++++++++++++++++++++++++++++- 2 files changed, 158 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cc4d5277..8ea7ce0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,9 +10,9 @@ knowledge base — see § Quick links spec. | Field | Value | |---|---| | Phase | 1 (Etch ↔ ECS) | -| Current milestone | **M1.D — Phase 1 debt. CLOSED at S4/G13, PR #81 ready for review.** Opened as *a documentation and instrumentation milestone: no program line is delivered* — **FALSE at close and corrected here rather than left standing**: 100 commits, 245 `.zig` files, floor 2290/2288 → 2373/2371 (**+83 tests**), and two rewrites under frozen surfaces (`M1.D.21` Tier 0, `M1.D.31` Forge). True when written; the world moved under it. Measured on the delivered lot (`sha256 8fb85acb…`, 648 lines): **49 entries, 18 closed of which SIXTEEN by the milestone** (3 in S1, 6 in S2, 7 in S4), 31 open, and **9 of the 49 MINTED during it** — the last two `M1.D.47` (the storage zone absent from `ResolvedType`) and `M1.D.48` (a parked async result re-raised with no bounds check). **FOUR external NO-GOs reopened the closure** — the first on six defects at `b26556a`; the second on two P1 at `317ae11` of a class the first had not reached (*the type checker was treating an ABSENCE of information as a guarantee*); the third at `39bedd5` on ONE defect of a fourth kind — **the predicate was right and the SET it walked was wrong**, `refuseArenaLocalsAcrossAwait` iterating the named locals while the `ForFrame` retains a handle to a value that is nobody's local. That is the unit error this milestone had already documented four times (`M1.D.11`, `12`, `2`, `30`), the fifth instance landing inside the fix written to close the fourth. Closed by enumerating the seven frame variants FIELD BY FIELD rather than adding an arm: exactly two retain, one of them already covered. **And the FOURTH found that same unit error one level up, inside the fix written for the third**: both checks were armed on the `await` NODE rather than on the property that matters — *this point suspends the parent* — and `race`/`sync` suspend with no `await` in the parent's own statements. Enumerated again at the interpreter: every parent suspension originates at a `return .suspended`, which is `stepBodyStmt`'s three `await` arms plus `beginRaceSync`; `driveLoop`'s seven propagate and `branch`/`spawn` detach. One predicate now carries both checks, armed on that set. Between them an internal adversarial review refused a closure seven more times, every finding against code written the same day. **TWO standing results.** A green fix with its counter-factual establishes its own case and nothing more — the reviews found eight defects in fixes delivered green, three of them in entries marked closed; an adjacent case is shown safe, it is not declared safe. And **SEVEN unit errors** — counting a set different from the one named — of which TWO were committed inside the fix written to close the previous one, and the SEVENTH in the milestone's own squash title, which counted closure MARKS (19, three of them another milestone's, a pre-milestone one and a template line) instead of the sixteen closures M1.D made. A unit error is the one class a green counter-factual cannot expose, because the fix has real power over the members it does reach. What ended the series was ENUMERATING THE SET AT THE CODE (seven `AsyncFrame` variants field by field, then eleven `return .suspended` sites), never better reasoning about the reported instance. M1.A — ECS access enforcement — CLEARED before it and is tagged `v0.11.19-ecs-access-enforcement`. Opened as *a documentation and instrumentation milestone: no program line is delivered* — **that description is FALSE at close and is corrected here rather than left standing**: measured, 245 `.zig` files changed, floor 2290/2288 → 2369/2367 (**+79 tests**), and two rewrites under frozen surfaces (`M1.D.21` Tier 0, `M1.D.31` Forge). The sentence was true when written and the world moved under it. **Sixteen entries closed by the milestone** (3 in S1, 6 in S2, 7 in S4), two already closed before it, **29 of 47 still open — and 7 of those 47 were MINTED during it**, which is what measuring for real produces. S1 closed with `M1.D.27` open (RD-4); S2 closed at `b18f5c10` with fifteen entries worked, **five of them carrying a false cause under an exact symptom** (`M1.D.8`, `27`, `0`, `23`, and RD-3's premise); S4 closed at `b26556a` with nine, then **an external NO-GO reopened it on six defects**, and an internal adversarial review then refused that closure **seven more times, every finding against code written the same day**. Its standing result is the one `engine-zig-conventions.md` §12 now carries: the three criteria, and the withdrawal of the three-line cap. M1.A — ECS access enforcement — CLEARED before it and is tagged `v0.11.19-ecs-access-enforcement`. | +| Current milestone | **M1.D — Phase 1 debt. S5 CLOSED at G7, PR #82 open.** S5's subject is the fifteen entries M1.D ITSELF created, under a rule the milestone did not have: *a defect found while working closes in the same session, or it is a STOP and an arbitration, never a new number*. **Measured on the delivered lot (`sha256 6da1d093…`, 648 lines): of those fifteen, TEN are closed — `M1.D.40` by S4, and `44`, `45`, `38`, `39`, `46`, `48`, `36`, `37`, `35` by S5 — and FIVE leave the milestone**: `34`, `41`, `42`, `43`, `47`, four architecture decisions and one restructure, none of them accumulated debt. **S5 minted ZERO new entries**, which is what it existed to demonstrate against a milestone that minted nine. Floor 2379 → **2395** / 2393 over seven commits. **THREE ENTRIES' OWN TEXT WAS CONTRADICTED BY THE TREE**, which is this milestone's purpose turned on its own output: `M1.D.47` does not overlap `44`/`45` at all — the digest input path holds ZERO `ResolvedType` references across its four functions, and the premise was a HOMONYM, *zone* (a value's memory lifetime) against `@storage(.sparse)` (the ECS storage mode, which `registry.zig` declares out of the hash by `ARCH-005`); `M1.D.35`'s recorded blocker was dissolved by `M1.D.21` one session earlier in the same milestone; and `M1.D.41`'s sixteen renames are UNSATISFIABLE, seven subjects collapsing onto one reserved name. **THE STANDING RESULT: a counter-measure written down stops nothing until an instrument executes it.** `tools/weld_lint/dead_tests.zig:920` names the `b.path(spec.path)` trap in the repository's own words and the linter resolves that shape; my own extraction walked into it anyway and under-counted `M1.D.41` by exactly three — tenth instance of the selector family and the FIRST where the parade existed before the error. Four errors of mine in S5, every one caught by an instrument and none by re-reading, plus a fifth in the closing tally itself (a count of what the SESSION closed, published under a heading naming what the MILESTONE created). And three blind probes in one gate whose first cause was ONE CHARACTER — `wait(1s)` is not a valid duration literal, so every async case was silently unparsed and BOTH negative controls read zero. The control shape that ended it — both negatives firing and both INT controls silent in the same execution — is now the form of every probe. Sessions S1–S4 are PR #81, unmerged, and their record stands in `briefs/m1.d-phase-1-debt.md`. M1.A — ECS access enforcement — CLEARED before all of it and is tagged `v0.11.19-ecs-access-enforcement`. | | Last released tag | `v0.11.19-ecs-access-enforcement` (M1.A). M1.D carries no tag: a debt milestone ships none, on the hotfix precedent. | -| Active branch | `phase-1/debt/phase-1-debt`, head = PR #81's tip (PR #81, **ready for review**, not merged — the merge and the tag are Guy's). *This row had been stale twice in a row — it described M1.E after its merge and M1.A after its. It is written here at the close of the milestone it names, which is the only moment it can be true.* | +| Active branch | `phase-1/debt/m1d-created`, head = PR #82's tip (PR #82, **open**, not merged — the merge and the tag are Guy's). PR #81 (`phase-1/debt/phase-1-debt`, M1.D sessions S1–S4) precedes it and is also unmerged, so **two debt PRs are open at once and #82 branches from `main`, not from #81** — it therefore carries none of #81's work and the two do not conflict by construction. *Written at the close of the session it names, which is the only moment it can be true.* | | Next planned milestone | M1.2.0 — Kinesis core: the skeleton system and the `BoneRef` addressing `ARCH-033` requires before `AnimationModule` freezes. **Unchanged by M1.D** — which does move plan rows, sixteen of them closed and seven minted, but delivers no feature and therefore no plan ROW of its own. The former wording here (*« delivers no program line »*) is the same false premise corrected in the milestone row above. | | CI matrix | `{ubuntu-24.04, windows-2025, ubuntu-24.04-arm} × {Debug, ReleaseSafe} × {f32, f64}` **plus one `ubuntu-24.04 / ReleaseFast / f32` cell** — **13 cells**, every one pinned `-Dcpu=baseline` (`ARCH-031` rule 6, third axis). `zig build lint` and `zig build forge-determinism` both run on the cell path; before M1.1.14 the first ran in NO workflow and the second in none either. **The thirteenth cell is M1.1.15.1/H1's and is deliberately NOT an axis**: `std.debug.assert` is compiled to nothing in ReleaseFast, so with {Debug, ReleaseSafe} alone every assert in the tree was verified in exactly the two modes where its breach costs nothing. A release-stripped assert is MODE-dependent and neither platform- nor precision-dependent, so one cell detects the whole class where completing the axis would cost 50 % of the matrix for the same detection — the reason is written in `ci.yml` at the cell so nobody completes it by symmetry. Cache restored to every cell, keyed by os · mode · precision · cpu · zig version · zon hash · sha, with an all-or-nothing size guard on BOTH save steps. Since M1.1.15.2, `zig build bindgen-check` runs on the `ubuntu-24.04 / Debug` cell AND again under `-Dphysics_f64=true` — one STEP and not a fourteenth cell, the answer being a property of the source; the f64 step exists because the single-cell arbitration rested on "no emitted type follows `Real`", which became a claim about the physics service the moment it entered the manifest. | | Determinism instrument | `zig build forge-determinism` — canonical scenario, 1000 frames, one worker, no RNG, **NINE elements** since the review: the eighth and ninth are a kinematic character on a riser and three mesh ramps forming a closed bowl, plus a lone box that sleeps inside the compared window, whose surface cosines bracket `cos(max_slope)` on both sides so a wrong cosine costs METRES of trajectory. **Eight witnesses committed** under `src/modules/forge/forge_3d/tests/determinism/witnesses/` with `SHA256SUMS.txt` and a `PROVENANCE.txt` carrying run URL, cell, CPU pinning, PR-head sha, cross-mode result, the REPORTED `zig version`, and a per-file generator mode. Regeneration is gated on a `Witness-regen:` trailer in the PR head commit. **Replayed by M1.1.21.1 at N workers and by M1.A on a rebuilt DAG** — it is an instrument, not a test. | @@ -115,11 +115,11 @@ commit, so a milestone still in review has no row here. - **M1.A residuals, all named and none deferred debt — the milestone opened no `M1.D` entry of its own.** (a) **`FrameContext.user` is an untyped escape the view cannot close, and it is how every migrated system gets its query.** Measured: no system body in the tree constructs a query — all 52 test and bench sites stash a pre-built one in `ctx.frame.user: ?*anyopaque` and the worker body then reaches columns by raw chunk offset. `bench_integrate` writes two columns that way. The view therefore closes the class on the paths that GO THROUGH the view — per-entity `get`/`getMut`, change ticks, resources — and says nothing about an opaque pointer the caller stuffed. Pre-existing and structural: no type system closes a `?*anyopaque`. **It is a residual of the TEST corpus and not of the production path**: measured, `frame.user` has zero readers under `src/`, and the eight files that read it are all under `tests/` and `bench/`. (b) **The view exposes no `query`, deliberately**, for the reason above — an entry with no caller is an unexercised entry, not symmetry (`world.zig`'s own words about the missing `addedTickOf`). It lands with its first consumer. (c) **`services/physics.zig` keeps a `*World` on an Etch-callable path** and writes `RigidBody` and `Transform` with no declared access anywhere. It is NOT a system entry point, so `ARCH-030` as scoped does not reach it; recorded because a `*World` in a gameplay-reachable path survives this milestone and a reader will assume otherwise. (d) **`M1.D.23` was neither fixed nor promoted.** The brief required a stop-and-report if the singleton gap were carried into the new type; the view wraps no query path, so the gap stays exactly where it was. (e) **The Tier 3 guarantee is a compilation witness and nothing more**, as the brief scopes it: the ECS surface there is still inert stubs, and no execution-level conformance is claimed. (f) The `SystemContext` a hand-written raw `SystemFn` receives carries an erased world; recovering a `*World` from it is one explicit cast. (g) **The brief's empty-set rule is SATISFIED without being implemented, ruled at the closing review.** `ARCH-030`'s object is the IMPLICIT empty set — an omission — and `spec` became a mandatory comptime parameter at the P1-1 correction, so omitting it is a COMPILE error, stronger than the registration error the invariant asks for; a hand-written `&.{}` is a declaration, not an omission. The decisive half is that the same correction made an empty declaration SELF-VERIFYING: the body receives `View(&.{})`, whose `get` and `getMut` refuse at comptime for every `T`, pinned by `view.zig`'s « an empty declaration grants nothing ». A system that declares nothing cannot reach a column and has no edge to place. Before the pairing was closed an empty set could lie; after it, it cannot — so a refusal would forbid twelve systems the type system now guarantees honest. (h) **A THIRD REVIEW PASS FOUND THAT P1-2 HAD OPENED THE NEXT BREACH, and it is closed**: `weld_no_job_body` sat on `View` and not on `ErasedFor`, so `job_bound` refused a view in a dispatched body's arguments and admitted `ctx.view.world_erased` — a `*ErasedFor(spec)`, which `fromErased` takes directly, so a worker holding one rebuilds the view with NO cast and reaches any entity by handle. Measured before the fix: `carriesMarked(*ErasedFor)` FALSE bare and wrapped, against `View` true. **`ErasedFor` was born without its twin's guarantee because that guarantee is implemented in another file**, and nothing at the point of creation recalled it — the second time in this milestone a remedy opened the next breach. The site therefore carries the RULE and not just the constant: *any type through which a `*World` can be recovered must declare this marker, whatever else it is for.* Census measured before and after and unchanged (6 / 4 / 4 / 5); counter-proofs bare AND wrapped, the wrapped one's mark deliberately avoiding the `no reason declared` tail so it survives `M1.D.24`'s repair rather than reading it as a regression. (i) **`SystemScheduler.phases` is a public field**, so a holder of the scheduler can rewrite a stored descriptor after registration. Named and deliberately not closed: `ARCH-030`'s threat model is the SYSTEM, which receives a context and nothing else, not the orchestrator that owns the scheduler. Zig cannot seal a struct field, so what the invariant gets is that the entry point RECEIVES no world — which is what it asks for — and not that no one can manufacture one. - **M1.1.15.2 residuals, all named and none deferred debt.** (a) **No aggregate value crosses the Phase 1 tree-walker** — `Value` carries twelve variants and not one is a `vec3`, with zero `.vec3` handling in `interp.zig` — so `engine-physics-forge.md` §13's `physics_raycast(origin, direction, …)` shape is unreachable from a rule and the service's signatures are componentwise (RD-2). The aggregate form returns everywhere at once the day the tree-walker carries an aggregate value. (b) §13's FREE-FUNCTION spelling as opposed to the service-call form: the tree-walker dispatches by RECEIVER and a bodyless top-level `fn` has no implementation; additive, and no exit criterion depends on it. (c) Emitting a `.d.etch`-declared event FROM Etch — the same cross-arena treatment applied to the emit path, purely additive, touching no frozen surface. (d) The repo's builtin `ErrorCode` set and `Error` shape diverge from `etch-abi-zig.md` §11.5 in name, membership, form AND encoding; a Zig error crossing into Etch therefore carries `@errorName` in `message`, which is lossless, and a coarse bucket in `code`. PRE-EXISTING, not created here, and no gate of this milestone owns the builtin surface. (e) `Entity.null`, the corpus's own spelling for the absent entity, is REFUSED by the type-checker as a field default (`E1101`), so an emitted `Entity` field carries no default at all. (f) `bindgen-lint`'s §9.2 rule "no hand-written `.d.etch`" — the `AUTO-GENERATED` header is emitted and its constant exported for it; the rule belongs to whoever opens `bindgen-lint`. - **The plugin entry is CALLED BEFORE the version check, and the table it receives is inert only by accident of delivery (opened at M1.A, raised not fixed, needs a number)**. `loader.zig` calls `entry_fn(@ptrCast(&api_mod.stub_api))` and only then compares `api_version_min` against the runtime major in both directions — so a plugin built against a superseded major receives the current table and can call through it before returning the descriptor that would have refused it. **Bounded by measurement**: `stub_api.ecs` is `WeldEcsAPI{}`, every entry its default stub, `stub_query_create` carries the NEW seven-parameter signature while discarding all of them, and FOUR sites hand a plugin that table (`loader.zig:158`, `:202`, `:268`, `:287`), of which exactly one — the entry at `:202` — does so BEFORE the version check at `:208`/`:215`; the other three are post-check lifecycle callbacks. So the consequence is latent while the table is inert and becomes live the day a real table is handed over, which is what delivering the plugin system means. The ordering predates M1.A. The remedy is a version negotiation that hands over no operational table — a change to the loading protocol, which is PluginLoader design; closing it hastily at a milestone's end is what produced a type born without its twin's guarantee two findings earlier. Owner: whoever opens the loading protocol. -- **`M1.D.37` — `bindgen-verify` cannot tell a hand edit from generator drift (opened at M1.D/S2)**. The gate ends on `git diff --quiet --exit-code bindings/generated/ src/core/platform/`, which compares the WORKTREE to the INDEX — so any uncommitted change under either tree reddens `zig build test`, whatever produced it. Measured on a comment-only edit to `bindings/generated/*.api.zig`: `2293/2313` with it unstaged, `2294/2313` stashed, `2294/2313` once committed. The case is reachable by ordinary work, those two `.api.zig` files being hand-maintained placeholders no adapter writes. Owner: whoever next opens the bindgen gate. -- **`M1.D.36` — a second contact-margin epsilon is live in `fast_paths.zig` (opened at M1.D/S2, measured, NOT fixed)**. `gjk.zig` exports `contact_margin_conv_k` and `contactMargin`, hoisted out of its locals so no second epsilon exists. `fast_paths.zig` nevertheless still defines its OWN `contactMargin` with its own `conv_k: T = 16`, consumed at five call sites. The `v0.11.11-mesh-shape` record states that this local duplicate went in the same pass as the hoist; it did not, and that record is a tagged milestone's narrative and is not edited — the current-state fact lives here. The two constants agree today, which is exactly what makes the drift silent when one moves. M1.D delivers no program line, so it is reported and not repaired. Owner: whoever next opens the narrowphase. +- **`M1.D.37` — CLOSED at M1.D/S5/G5, and the defect was WIDER than the entry.** The entry named the comparison base; measured, the sharper fault was the PATH SET. The gate diffed `bindings/generated/` and `src/core/platform/` wholesale while the adapters write exactly FOUR files — the first holds two hand-maintained `.api.zig` sidecars no adapter writes, the second nine entries of which one is generated. So a comment edit to `threading.zig` turned a *bindgen* gate red, which S4/G5 of the same milestone did. Narrowed to the four and based on `HEAD`, because *« matches the COMMITTED output »* is the criterion the gate's own comment states. Then the SECOND DECLARANT went: `generated_binding_files` is the one list the diff and both `zig fmt` passes read, so a new generated file cannot be formatted and then silently left ungated. Two-sided witness: with `threading.zig` dirty the old predicate exits 1 and the gate now exits 0; with a generated file dirty the predicate exits 1. +- **`M1.D.36` — CLOSED at M1.D/S5/G5.** `fast_paths.zig`'s private `contactMargin` is gone and its four call sites read `gjk.contactMargin`; the entry says five call sites, which counts five OCCURRENCES — four calls plus the definition. The dependency direction was measured before the import was added: the narrowphase graph is acyclic, and `epa.zig` and `shapecast.zig` already depend on `gjk` the same way. **The neighbour was deliberately NOT swept in**: `boxExtent` carries a comment saying it mirrors `gjk.coreExtent`, and it is that function's `.box` arm at a site holding raw half-extents — it contains NO constant, so there is nothing to drift, which is what `M1.D.36` is about. Verified by instrument and not by argument: `zig build forge-determinism` exits 0 with the eight witnesses byte-identical. - **THE NUMBER `M1.D.17` DENOTES TWO DIFFERENT DEBTS, and this is the SECOND such collision (measured at M1.D close).** `engine-phase-1-plan.md` numbers `M1.D.17` the Etch hot-reload debt — a `component` reloaded with a changed layout reusing its id — CLOSED at M1.D/S4/G6. The entry immediately below, carried only here, is a DIFFERENT debt under the same number, and the plan carries it under none at all: measured on the delivered lot (`sha256 761adec9…`, 646 lines), zero occurrences of `buildSchemaRemap` or `.sav`. M1.E already corrected a first `M1.D.17`/`M1.D.18` collision on chunk fragmentation, so this is not that one. **Deliberately NOT renumbered here**: the debt table belongs to `engine-phase-1-plan.md`, and a repo file renaming a corpus entry is how a third reading is born. Owner: Guy, with the plan. - **M1.D.17 (this file's sense) — the `.sav` schema check is blind to a size-preserving field addition (opened at M1.1.15.2).** `loader.buildSchemaRemap` compares SIZE and ALIGNMENT, and `RigidBody.authority` landed in existing trailing padding — 32 bytes before and after. It bites nothing today because no image carries a `RigidBody` (measured: zero `.etch` name the type, nothing registers it, no cook reaches it) and `.solver` is the zero value, pinned by a test. It will bite at the first image that does, and nothing will announce it. Owner: `engine-scene-serialization.md`, not a physics milestone. -- **M1.D.18 — a table component under sustained churn grows its chunk count until the DISPATCH FAILS (opened at M1.B/G11, measured, mechanism named)**. `Archetype.removeSwap` compacts INSIDE one chunk only — `chunk_idx` is fixed and the last slot OF THAT CHUNK fills the hole — and `archetype.zig` has NO release path: no `chunks.pop`, no `swapRemove`, no `entity_count == 0` test, no shrink. So the count follows the CUMULATIVE number of adds and never the live population. Measured by `bench/ecs_hybrid_crossover.zig` at `payload=64B`, fraction 1.0, churn 60/carrier/s: **128 chunks at the first tick, 8200 within the window**, for 20 000 entities over 8 archetypes — 2.4 entities per chunk where the payload allows ~156 — after which `jobs.Scheduler.dispatchBatch` returns `error.TooManyChunks` at its `workers × 8192` capacity. **The consequence is a HARD dispatch failure, not slowness**, and the population that reaches it is any durable churning load — precisely the load `@storage(.sparse)` exists to serve, whose range count is CONSTANT in the same report row. **Invisible to C0.1, which never churns.** NOT fix-as-you-go and the reason is structural, not convenience: the remedy is inter-chunk compaction or a partial-chunk free list, and **moving an entity between chunks invalidates the `chunk_ptr` of every live `ComponentRef`** — the type M1.B/G5 built, whose table arm deliberately holds a chunk pointer — so the fix touches a contract this milestone just froze, and it interacts with `chunkAt(i)`'s stability during a dispatch. That is a milestone, not a commit. Owner: the chunk-lifecycle owner; Guy carries the corpus side. +- **M1.D.18 — a table component under sustained churn grows its chunk count until the DISPATCH FAILS (opened at M1.B/G11, measured, mechanism named)**. `Archetype.removeSwap` compacts INSIDE one chunk only — `chunk_idx` is fixed and the last slot OF THAT CHUNK fills the hole — and `archetype.zig` has NO release path: no `chunks.pop`, no `swapRemove`, no `entity_count == 0` test, no shrink. So the count follows the CUMULATIVE number of adds and never the live population. Measured by `bench/ecs_hybrid_crossover.zig` at `payload=64B`, fraction 1.0, churn 60/carrier/s: **128 chunks at the first tick, 8200 within the window**, for 20 000 entities over 8 archetypes — 2.4 entities per chunk where the payload allows ~156 — after which `jobs.Scheduler.dispatchBatch` returns `error.TooManyChunks` at its `workers × 8192` capacity. **The consequence is a HARD dispatch failure, not slowness**, and the population that reaches it is any durable churning load — precisely the load `@storage(.sparse)` exists to serve, whose range count is CONSTANT in the same report row. **Invisible to C0.1, which never churns.** NOT fix-as-you-go and the reason is structural, not convenience: the remedy is inter-chunk compaction or a partial-chunk free list, and **moving an entity between chunks invalidates the `chunk_ptr` of every live `ComponentRef`** — the type M1.B/G5 built, whose table arm deliberately holds a chunk pointer — so the fix touches a contract this milestone just froze, and it interacts with `chunkAt(i)`'s stability during a dispatch. That is a milestone, not a commit. Owner: the chunk-lifecycle owner; Guy carries the corpus side. **THE BLOCKER THIS ENTRY RECORDS NO LONGER EXISTS, measured at M1.D/S5/G6**: `M1.D.21` removed `chunk_ptr` in S2/G1 of that same milestone, one session BEFORE this sentence was written, so `ComponentRef` is `{entity, component_id, mutable}` and re-resolves at every access. Second deferral condition satisfied by work from elsewhere, after `M1.D.12`. And the half of the remedy that is REUSE — not compaction — landed at S5/G7 as `Archetype.first_partial`: it moves NO entity, because `removeSwap` leaves a dense prefix and free space at the tail, so its invalidation set is empty. Its benefit is deliberately UNMEASURED and no figure is offered, the instrument being absent (`DynamicQuery` exposes neither `chunkAt` nor `chunkCount`). - **`M1.D.14` gets its SEVENTH and EIGHTH measurements, and its first treatment (M1.B/G11)**. That plan row already carries this debt by name — *"le job `bench-ecs-smoke (windows-2025)` a une queue de durée qui franchit son budget `timeout-minutes: 10`"* — with six measurements at near-constant code (5m08, 6m52, 8m19, 9m21, 9m28, 9m38), factor 1.9, two cancellations and two green re-runs at the SAME SHA, and it names raising the budget as one of two options while taking neither. **M1.B adds 9m37 (passed, 23 seconds of headroom) and 11m28 (cancelled) and TAKES that option**: 10 → 20 minutes, with the measurement written at the site. M1.B also made the job heavier, measured rather than assumed — the step runs `zig build bench-ecs`, whose run step depends on the install step, so it compiles EVERY installed artifact, verified by deleting `zig-out/bin/` and watching `ecs-hybrid-crossover-bench` reappear beside `ecs-benchmark`. What the raise does NOT remove is the runner's intrinsic variance, which `M1.D.14`'s own analysis already establishes as the cause, nor the standing question of whether the Windows bench belongs in the PR matrix. *An earlier draft of this entry opened a parallel record under a new number; a debt treated under any name but its own stays open in the document that carries it.* - **A CELL OF THE CI MATRIX HANGS, TEN TIMES MEASURED, AND THE CLASS HAS NO HOME IN THE CORPUS (opened at M1.B/G11, needs a number)**. Distinct from `M1.D.14`, which carries the DURATION of the bench job: this is the PENDING of a matrix cell that gates merges. **Cell:** `build-and-test (windows-2025, ReleaseSafe)`, both precisions. **Signature:** `error: test runner failed to respond for ~1m`, with **zero** occurrences of the sibling class `failed without output` — the two have never been co-present. **Count: TEN**, all on that cell, every one exonerated by a green re-run at the SAME SHA (three recorded before M1.B, two at M1.B/G10-G11, three at M1.B/P3-P4, one at M1.E/G12 on `6a2bb8e`, one at M1.E on `387ab97`). **The ninth, at M1.E/G12 on `6a2bb8e`, is the most informative the class has produced and it is the LARGEST BY AN ORDER**: `2164/2196 tests passed (32 skipped)` against a declared windows floor of 2250 gives **54 TESTS LOST**, where the previous counts were 1, 5, 6, 8 and 14. It ran 38.1 min against a 55-minute budget, so it is NOT the sibling timeout recorded below it — that one has every step green and no lost test — and the log carries ZERO `error: '…' failed:` lines, so no assertion fired. Fifty-four tests is a whole step of substance rather than a straggler, which narrows what can be hanging: the class is not a slow tail on a small step. **THE THIRTEENTH, at M1.A on `565012c`, hangs TWICE IN ONE RUN and the new discriminant reads BOTH**: signature present twice, sibling absent, zero assertions, `2253/2285 tests passed (32 skipped)` against a declared floor of 2288 — **THREE tests lost across two hung steps** — 52.6 minutes against 55 with conclusion `failure`. The two `failed command:` lines name `fecde19354ea9ccbd9ecb5d20cdb00ac` and `2fe9c3b586059afa4881744abc5715ef`: **two different executables in ONE build**, which is the within-run twin of the within-SHA comparison the twelfth produced. Four distinct identities are now recorded across three attempts and no two agree. The series of losses is 1, 5, 6, 8, 14, 54, 2, 1, 3. **THE TWELFTH, at M1.A on `e054b26`, ties the smallest loss and adds a collateral the class did not carry**: `2255/2287 tests passed (32 skipped)` against a declared floor of 2288 gives **ONE test lost**, signature present once, sibling class absent, zero `error: '…' failed:` lines, and **53.0 minutes against a 55-minute budget with conclusion `failure` and not `cancelled`** — which excludes `M1.D.27` on both of its discriminants at once rather than on duration alone. The series of losses is now 1, 5, 6, 8, 14, 54, 2, 1. **The collateral is that two debts met on one job**: the run carries `Zig cache is 11222302720 bytes, over the 10737418240 cap — SKIPPING the final save`, which is `M1.D.10`'s mechanism, and a skipped final save leaves the NEXT run on that cell colder, which is `M1.D.27`'s trigger. Neither debt is new; their interaction is recorded nowhere else. **IT FAILED ITS FIRST SAME-SHA RE-RUN, and that re-run produced a THIRD DISCRIMINANT this entry records as impossible.** Attempt 2: signature once, sibling absent, zero assertions, `2256/2287 (31 skipped)` against 2288 — **one test lost again**, where every previous pair of failures at one SHA had lost DIFFERENT counts, which is the shape an implicated test would produce. Refuted by measurement: this entry states that *"naming the step from the log does not work"*, which is true of the step's SOURCE name and FALSE of its identity — **the `failed command:` line immediately following the `failed to respond` message carries the hung step's build-cache hash**, and the two attempts differ (`01a58047…` against `c810df3f…`). Two DIFFERENT executables hung at one SHA, so no test is implicated, and the equal loss explains itself: both hung steps sit in the contiguous family whose neighbours all report `0 pass, 1 skip (1 total)`, where a hang costs exactly one test whichever member it hits. *The count was never the discriminant; the identity is, and it is one grep away in every log this class has already produced.* **THE ELEVENTH, at M1.A on `3f22cf6`, is the smallest loss the class has produced and the cleanest measurement of it**: `2245/2277 tests passed (32 skipped)` against a declared floor of 2279 gives **2 TESTS LOST**, with the signature present once, the sibling class absent, zero `error: '…' failed:` lines, and 52.7 minutes against a 55-minute budget — so `M1.D.27` is excluded by duration and by conclusion (`fail`, not `cancelled`). The series of losses is now 1, 5, 6, 8, 14, 54 and 2, which continues to refute any single implicated test. Cleared by a re-run at the SAME SHA. **THE TENTH, at M1.E on `387ab97`, is the first to hang TWICE IN ONE RUN**: two `failed to respond` twelve minutes apart (19:49:14 and 20:01:15 UTC) on two DIFFERENT test executables, and `301/304 steps succeeded (2 failed)` accounts for exactly those two. **And the loss stopped following the step count**: `2217/2249 tests passed (32 skipped)` against the same 2250 floor gives **ONE test lost for TWO hung steps**, so at least one of the two cost no test at all — where the counts so far had been 1, 5, 6, 8, 14 and 54, always for a single step. That asymmetry is NOT explained here: the natural reading, a runner that blocks after its last result is already reported, is plausible and unmeasured, and this class has already paid for two hypotheses issued on a plausible reading. What it does strengthen is the `0664f28` discriminant — two different steps, in one run, on a commit whose diff is comments and two Markdown files. It ran 40.2 min, SHORTER than the sibling timeout because it aborted on the hang rather than finishing, carries ZERO `error: '…' failed:`, and exonerated on the FIRST re-run. **AT ONE SHA (`0664f28`) THE CELL FAILED THREE TIMES AND LOST THREE DIFFERENT COUNTS — 14, 1 and 5 tests — hence three different step sets.** That is a discriminant the class did not previously have, and it is the strongest evidence yet that no single test is implicated: a test that hangs deterministically blocks the same step every time and loses the same number. **And the frequency moved**: the five occurrences preceding that SHA each exonerated on the FIRST same-SHA re-run, where this one took two — f64 green on re-run 1, f32 failing again with a third lost count and green only on re-run 2. Two collateral facts from the same investigation: `pre-push` runs `zig build test -Doptimize=ReleaseSafe` (`lefthook.yml:31`), so the failing cell's MODE is green on the dev machine at every push; and pushing over an in-flight run marks that run `failure` with no evidence of its own (`aa7416c`), which is the recorded status-predicate hazard seen from the other side. **Two discriminants, and only one of them works today.** (1) `declared − collected` from the `Build Summary` line `--summary all` already produces: at the M1.B occurrence, `302/304 steps succeeded (1 failed); 2103/2135 tests passed` against a declared windows floor of 2143 gives **8 tests lost**, so the hung step holds eight — a SIZE, computed and not inferred. (2) Naming the step from the log **does not work**: a hung step emits no output at all, so the per-step summary that would name it is exactly what is missing, and `--log-failed` returns the aggregate. Naming it needs either a per-step timeout that identifies its target or a step-by-step run. What DOES survive is a negative discriminant used at G11: a step that printed its own report AND its `failed command:` artifact has COMPLETED, which is how the M1.B/G10 scheduler-dispatch tests were exonerated without a re-run. **The corpus carries no foyer for this**: the signature returns zero occurrences across the corpus (measured), so ten occurrences on a merge-gating cell live only in a succession of briefs. - **`win32_thread_safety_test` TIMES OUT on `windows-2025 / Debug`, and it is a DISTINCT class from the hang above (second occurrence at M1.A, needs a number)**. Not `M1.D.19`: the hang signature is ABSENT, no test is lost (collected equals the floor exactly), and ONE assertion genuinely fires — `error.Win32ThreadSafetyTimeout` at `tests/platform/win32_thread_safety_test.zig:99`, a wall-clock bound on three threads doing 100 `createWindow`/`destroyWindow` pairs each against a 30 s budget. The job takes ~10 minutes, not the ~50 of a hang. @@ -436,4 +436,4 @@ line, and never on a `tail`. --- -Last updated: 2026-09-21 +Last updated: 2026-09-22 diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index a96d022a..52632e57 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -4388,8 +4388,15 @@ windows 2393. ## S5 — close -**Fifteen entries M1.D created and did not treat. Nine closed, six leave the -milestone.** +**Fifteen entries M1.D created. TEN closed, five leave the milestone** — +`M1.D.34`, `41`, `42`, `43`, `47`. + +*Corrected here rather than left standing: this section first read « nine closed, +six leave », which counted S5's own nine and forgot `M1.D.40`, closed by M1.D/S4 +before this session opened. A count of what the SESSION did, published under a +heading naming what the MILESTONE created — the unit error this milestone +documents, committed in its own closing tally for the second time, after the +squash title that counted marks instead of closures.* | closed in S5 | | |---|---| @@ -4403,6 +4410,8 @@ milestone.** | `M1.D.37` | `bindgen-verify` narrowed to the four generated files, one declarant | | `M1.D.35` | `first_partial` | +`M1.D.40` was closed by M1.D/S4; the nine below are S5's. + | leaves the milestone | why | |---|---| | `M1.D.34` | restricting the f64 leg changes what the matrix attests | @@ -4411,7 +4420,7 @@ milestone.** | `M1.D.43` | a removal primitive in a registry whose ids are positional | | `M1.D.47` | carry the zone beside the type, or split `ResolvedType` | -Five decisions and a restructure, none of them accumulated debt. +Four architecture decisions and one restructure, none of them accumulated debt. #### What S5 establishes, beyond the nine @@ -4467,6 +4476,146 @@ sixteen renames are unsatisfiable. Three entries whose own text the tree contradicted — which is what this milestone exists to find, applied to the entries it created. + +## Closing notes — S5 + +- **What worked:** + + **THE LATENT DEFECT CAUGHT INSIDE THE FIX, BEFORE IT SHIPPED.** `allocateSlot` + returned `.chunk_idx = chunks.items.len - 1` unconditionally — exact while the + trailing chunk was the only reachable destination, and **a wrong location the + moment an earlier chunk can be chosen**. Delivering partial reuse without + reworking that return would have written a silent corruption into the gesture + meant to close a debt: the entity recorded where it is not. That is the shape + four external reviews exploited four times on this milestone — a repair opening + the next breach — and this is the first time it was taken before leaving. + + **A BOUND INSTEAD OF AN INDEX, AND THE MOTIVE IS THE DESIGN.** `first_partial` + is declared a LOWER bound on the index of any non-full chunk, not "the first + partial chunk". An equality nobody can cheaply maintain makes every maintenance + site owe a proof; a bound makes each one decidable alone — lowering is always + safe, advancing must be earned, and exactly one site earns it, on an + OBSERVATION. Four sites, each carrying its own reason at it. + + **A COUNTER-FACTUAL THAT SEPARATES TWO QUESTIONS RATHER THAN CONFIRMING ONE.** + The bound stays fully maintained while only the CHOICE reverts to trailing-only: + the two reuse tests redden and the invariant test stays GREEN. Without that + split, one mutation reddening all three would have shown the code load-bearing + and identified nothing. The finest form of the shape this milestone has + produced. + + **MEASURING A RETENTION POINT INSTEAD OF IMPLEMENTING IT.** The session was + instructed to fix two; there is one. An `int?` or `int[]` event field is refused + as a FIELD TYPE, a struct filter by the filter checker, an enum carries a + discriminant — a string is the only arena form that can reach a captured filter, + and `captureEventFilter` already copies exactly it. *« Ne couvre que les + chaînes »* was COMPLETE, and the assumption is pinned in a test that falls the + day a collection field becomes legal. + + **REFUSING TO CLAIM A NEGATIVE WITHOUT AN INSTRUMENT — AND THE NEGATIVE WAS + TRUE.** A global `s.replace` over 13 000 production lines could not be shown + harmless while `Bash` was down, so it was reported as an unverified risk rather + than waved through. A later `git diff` settled it: **284 insertions, zero + deletions**. The conduct was right independently of the outcome; asserting it + then would have produced the same sentence with no information in it. + + **A CONTROL SHAPE THAT BECAME THE FORM OF EVERY PROBE.** Both negative controls + firing and both INT controls silent, in the same execution. Instituted after + three blind probes in one gate, and used unchanged at every measurement after. + +- **What deviated from the original spec:** **S5 opened no deviation.** Its rule — + a defect found while working closes in the same session, or it is a STOP and an + arbitration — was honoured by stopping five times rather than by writing + entries: the `M1.D.47` overlap, the `@requires` residual, `M1.D.41`'s + unsatisfiable renames, `M1.D.35`'s unmeasured benefit, and the four architecture + decisions. **Zero new numbers were minted**, which is what the session existed + to demonstrate against a milestone that minted nine. + +- **What to flag explicitly in review:** + + **A COUNTER-MEASURE WRITTEN DOWN STOPS NOTHING UNTIL AN INSTRUMENT EXECUTES + IT.** `tools/weld_lint/dead_tests.zig:920` names the `b.path(spec.path)` trap in + the repository's own words, and the linter's root discovery resolves that shape. + My extraction walked into it anyway and under-counted `M1.D.41`'s module roots + by exactly three. Tenth instance of the selector family and **the first where + the parade existed before the error** — the same finding this session produced + on the other side, where a rule held in memory did not fire until an observable + symptom triggered it. + + **FOUR ERRORS OF MINE, EVERY ONE CAUGHT BY AN INSTRUMENT AND NONE BY + RE-READING.** An arm B counting 90 subjects where the rule names 16, for + dropping the single-entry constraint. An arm A short by three, on the documented + trap above. The re-export discriminant — an arm-B test — applied to an arm-A + declared root. And the global replace. To which this closure adds a fifth: a + count of what the SESSION closed, published under a heading naming what the + MILESTONE created. + + **THREE BLIND PROBES IN ONE GATE, AND THE FIRST CAUSE WAS ONE CHARACTER.** + `wait(1s)` is not a valid duration literal, so every async case was silently + unparsed and BOTH negative controls read zero — a full page of plausible zeros. + Then `branch { }` inside `race` is a detached task, and a `{ }` race branch is a + value block. What separated the third attempt from the first two was a control + not written until the second. + + **COVERAGE BY ACCIDENT OF SPELLING, MEASURED DIRECTLY.** `let a = "x"; return a + + "y"` was refused and `return "x" + "y"` was not, though both park the same + `.string_run` — the first only because `let a` made it a local the sibling rule + walks. The sharpest instance of the class four external reviews exploited four + times, and it came from a probe rather than a review. + + **THREE ENTRIES WHOSE OWN TEXT THE TREE CONTRADICTED.** `M1.D.47` does not + overlap `44`/`45` — the premise was a homonym, `zone` against `@storage`. + `M1.D.35`'s recorded blocker was dissolved by `M1.D.21`, one session earlier in + the same milestone. `M1.D.41`'s sixteen renames are unsatisfiable. The milestone + found, in the entries it created, exactly what it exists to find elsewhere. + + **ONE FIGURE UNRECONCILED, stated rather than adopted.** Counting table rows + carrying a closure mark gives **27**; the lot's delivery message says 28. The + two figures certainly count different sets — a template line and a `[CLOSED]` + token live outside the table rows — and nothing here depends on it. The numbers + that do are measured and agree: **10 of the fifteen M1.D-created entries closed, + 5 open** (`34`, `41`, `42`, `43`, `47`). + +- **Final measurements (S5 close):** re-derived FROM THE SUITE at every gate and + never carried: **2379 → 2395** on macOS (`2376/2395 tests passed, 19 skipped`, + 316/316 steps), windows **2393** by the guard's own `only_on` table, and the + `dead-tests` closure agreeing independently at 2395 each time. Green at Debug + f32, ReleaseSafe and `-Dphysics_f64=true`; `zig build lint` *conservation OK*, + `zig fmt --check`, `zig build bindgen-verify`, and `zig build forge-determinism` + with its eight witnesses byte-identical — the last one load-bearing at G5 and G7, + which touched a contact epsilon and a Tier 0 allocation policy. Seven commits. + +- **Residual risks / tech debt left intentionally:** + + **FIVE ENTRIES LEAVE THE MILESTONE AND NONE IS ACCUMULATED DEBT.** `M1.D.34` + (restricting the f64 leg changes what the matrix attests), `M1.D.42` (whether a + directory may declare several modules), `M1.D.43` (a removal primitive in a + registry whose ids are positional), `M1.D.47` (carry the zone beside the type, + or split `ResolvedType`) — four architecture decisions — and `M1.D.41`, a + restructure, since seven of its subjects would collapse onto one reserved name. + + **`M1.D.35` CLOSES WITH ITS BENEFIT UNMEASURED, and says so.** The half-empty + regime has no load measurement and the instrument is absent — + `bench/ecs_hybrid_crossover.zig` records that `DynamicQuery` offers neither + `chunkAt` nor `chunkCount`. What justifies the change is structural: the + invalidation set is EMPTY and the cost is one `u32` and four maintenance sites. + No gain figure is offered, deliberately. + + **THE `@requires` RESIDUAL, raised and not filed.** A newly added requisite is an + invariant already-live entities may violate, and nothing re-checks them. Not a + layout problem, so it does not belong to the schema digest; a missing capacity + rather than accumulated debt. + + **`M1.D.15` (a) AND THE OTHER STANDING RESIDUALS ARE UNTOUCHED BY S5** and keep + their own entries. And `E0217` now joins the declared-with-no-producer set that + `M1.D.6` characterises, with its reason at the declaration: the orphan rule needs + a trait or type that RESOLVES while being foreign, which no module can name + today. + + **The `:6256` cast target stays UNMEASURED**, reported at S5/G2 because the + probe's own control was silent. Neither claim about it is established, and it is + written that way rather than resolved on a blind instrument. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree From 2cb8e5a41e7498fdd524cb5774f09354f45ce260 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 23 Sep 2026 05:50:57 +0200 Subject: [PATCH 009/141] docs(brief): measure M1.D.43, ownership does not reserve (S6/G8) Measurement only; nothing written, because the entry's own condition was met: one part does not reserve. The world mutations of Interpreter.compile are exactly four sites - Pass A's fresh arm (registerComponentRaw, and for a resource addResource then initResourceCollections), finalizeRequires, the TagSet fresh arm and the builtin time resources' fresh arm. Passes B to E are world-read-only. Enumerated at the code, then put through a read-only adversarial audit of 37 agents, which added two side effects: getMutResource sets dirty on a fresh resource with a collection field and on no other, and finalizeRequires frees every live closure before recomputing any. OOM sweep with FailingAllocator at every index and resize_fail_index = 0, both controls firing in all three scenarios. First compile: 92 fail points, 79 change the world, and after a retry 10 resources are never seeded, 2 collection slots are null and 45 string pointers dangle. Reload adding types: 3, 2 and 23 of 60. Plain reload: 2 of 44 destroy the live @requires closures. The retry does not repair: freshness is idOf != null, so seeding is skipped for good. Everything inside the registration window reserves - each fallible call is an allocation sized by the pre-mutation world, registry and store, or a refusal decidable from it; ids are positional, so new ids and the staged closure are predictable. What does not reserve is OWNERSHIP. String defaults are immortal blocks owned by the compile's errdefer, and the committed registration writes their address into the world. A failure after the commit - passes B to E, which read the committed registry and so cannot precede it - frees what the world points at: 12 of the 12 post-registration failures dangle, and not only on exhaustion. The same split on the path that succeeds, measured: tearing the previous interpreter down after a reload zeroes the live session's collection and string pointer, and the string it was reading belonged to the old interpreter. Found by the same instrument in the same function: nine leaks from one site (rule_descs.append after compileRule), six more leak sites from the audit, one double free verified at the source, one OutOfMemory swallowed by catch continue, and one u16 offset panic decidable from the AST. Co-Authored-By: Claude Opus 5.5 --- briefs/m1.d-phase-1-debt.md | 100 ++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index 52632e57..caf303b9 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -4616,6 +4616,106 @@ it created. probe's own control was silent. Neither claim about it is established, and it is written that way rather than resolved on a blind instrument. +### S6/G8 — `M1.D.43` measured: the registration reserves, the ownership does not + +Measurement only. No line written, because the entry's own condition was met: one +part does not reserve. + +#### The window is wider than Pass A, and it ends where the world stops being written + +Enumerated at the code and then put through an adversarial audit (five lenses, +read-only agents, 37 agents, every finding handed to a refuter). The WORLD +mutations of `Interpreter.compile` are exactly four sites: Pass A's fresh arm +(`registerComponentRaw`, and for a resource `World.addResource` then +`initResourceCollections`), `finalizeRequires`, the `TagSet` fresh arm, and the +builtin time resources' fresh arm. Passes B to E are world-read-only — +`hybrid_query.plan` and `World.queryDynamic` only copy ids, `descriptor.build` never +receives the world. Two side effects the first reading missed and the audit found: +`initResourceCollections` reaches the store through `getMutResource`, which sets +`dirty` on a fresh resource that has a collection field and on no other; and +`finalizeRequires` FREES every live closure before recomputing any of them. + +#### The sweep, under a control that fires both ways + +`std.testing.FailingAllocator` at every allocation index, with +`resize_fail_index = 0` so that every growth goes through a counted allocation +instead of escaping by `remap`. After each failure: the world against a snapshot, +then a retry on the SAME world and a health check. Both controls fire in all three +scenarios in the same execution — a clean compile reads `healthy`, a store entry +removed by hand reads `missing_store_entry`. + +| scenario | fail points | world changed by the FAILED compile | after a retry | +|---|---|---|---| +| first compile | 92 | 79 | 35 healthy · 10 resource registered and never seeded · 2 collection slot null · **45 string pointer dangling** | +| reload adding types | 60 | 39 | 32 healthy · 3 never seeded · 2 null collection · **23 dangling** | +| reload, same program | 44 | 2 — live `@requires` closures destroyed mid-walk | 44 healthy | + +The retry does not repair, and the reason is one line: `compileResource` decides +freshness by `idOf != null`, so a resource registered by a failed compile is +"already there" and its seeding is skipped for good. + +#### What reserves — everything inside the registration window + +Confirmed by the audit's C2 lens over every `try` in the window, callees included: +each fallible call is either an allocation sized by the AST and the PRE-mutation +world — registry AND resource store, the base the first reading stated too narrowly +— or a refusal decidable from the same data. Ids are positional, so the ids of new +entries are predictable in declaration order, and the closure `finalizeRequires` +would compute can be computed into staging over the predicted graph. Nothing inside +the window resists a reservation. + +#### What does not — and it is ownership, not reservation + +Resource `string` defaults are `persistent.allocImmortal` blocks appended to the +compile-local `persistent_literals`, which `compile()` destroys in an `errdefer` on +ANY error return. The committed registration writes the pointer into the WORLD — +the registry entry's default bytes and the resource store. So any failure AFTER the +commit frees blocks the world still points at, and the next `releaseResourcePayloads` +calls `decref` on a freed header. + +**The failures after the commit are precisely the ones no reservation can precede**: +passes B to E read the committed registry. Measured, and decisive: in the first +compile, the **12** failures that occur after the registration has completed leave +**12** dangling pointers. And they need not be exhaustion — Pass E returns +`UnsupportedDescriptorExpr` and `compileRule` returns `InvalidProgram`. + +**The same split on the path that SUCCEEDS**, measured rather than inferred: +compile B on a world where A runs, then tear A down. `Interpreter.deinit` releases +every resource payload in the world, whichever interpreter declared it, and the +live session B loses its collection (`items.ptr` `0x10e9a0090` → `0`) and its string +(`label.ptr` → `0`) — and the string B was reading belonged to A. The walk zeroes +only `StringSlot.ptr` and leaves `len`, so B's next read is `@ptrFromInt(0)`: a +panic in Debug and ReleaseSafe, undefined behaviour in ReleaseFast. No test covers +"compile B, tear A down, run B"; the runtime's hot-reload handler is a stub, so the +path is reachable today through the API only. + +Both halves are one question — who owns a block the world references — and a +reservation cannot answer it. + +#### Found by the same instrument, in the same function + +The sweep reported **9 leaks from one site**: `const desc = try compileRule(...)` +then `try rule_descs.append(gpa, desc)` — a failed append leaves `desc` in no list an +`errdefer` walks; three allocations per occurrence, one occurrence per scenario. The +audit found the same shape at six more sites (`slice` after `toOwnedSlice`; +`rule_tasks` and `entity_rule_tasks` with no `errdefer`; the `toOwnedSlice`s of +`compileRule`'s return literal; `lowerWhen`'s `bits`; `dnfFromPool`'s cloned term), +**one double free** — `fields` handed to `expr_filters` with its local `errdefer` +still armed, freed twice if `ctx.pool.append` then fails, verified at the source — +**one swallowed exhaustion** — `constCollectionValue(...) catch continue` in +`initArrayBlock` drops an element of a collection default on `OutOfMemory` with no +error — and **one panic decidable from the AST**: `@intCast` of a field offset to +`u16`, reached by a declaration past 64 KiB. + +#### Where this leaves the entry + +The reservation closes the registration's own half: never seeded, null collection, +and the live closures `finalizeRequires` destroys. It does not close the dangling +half, which is the larger one and the only memory-safety one. Neither half closes +without the other, measured: ownership alone leaves 10 + 2 unseeded or null results +in the first compile and destroys live closures on the plain reload; reservation +alone leaves every post-commit failure dangling, 12 of 12. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree From 960601a9e5157057fbfd7d3ed17aea03c1e321be Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 23 Sep 2026 06:28:13 +0200 Subject: [PATCH 010/141] docs(comments): reduce S5's comments to their contracts (S6/G8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit engine-zig-conventions.md §12 now has four flat prohibitions; the new one bars decision reasoning from a code file even when it passes the three criteria. CLAUDE.md carried the criteria and none of the prohibitions, so they never reached a session start; it now carries all four. The set was reproduced first: c3e6d316..f4a92da4 added 372 comment lines for 392 of code, 24 blocks of six or more totalling 285. Each of the 53 blocks was judged as one unit - kept whole, reduced to the shortest wording that keeps its contract, or removed whole - from read-only per-file proposals, each reviewed, applied by a script that refuses any unit holding a non-comment line. Result: 106 comment lines for the same 392 of code, and 2 blocks of six or more where there were 24; both are pre-S5 paragraphs touched only to correct a sentence S5 made false. Verified on the diff: comment -444/+106, blank -2, code 0. The adversarial pass was cut at 9 of 18 agents by a usage limit; the five unaudited files were audited by hand and the four unrefereed findings verified at the source. All four held and are fixed - two dropped wrong-fix guards (bindgen-verify against HEAD, why @requires stays out of the digest), one sentence that described the real digest as the wrong one, one grammar pointer - and the hand audit restored a fifth guard in dead_tests.zig. Comments S5's code had made false were corrected in the same pass and counted apart: the orphan rule still listed among validateTraitImpl's checks, "0 false refusals", allocateSlot filling only the trailing chunk, a drained chunk never refilled, content_digest missing from the digest's tuple, fast_paths importing support.zig only, among others. Found and not fixed, each being a code change: the tagset_component_name constant is read by nothing in interp.zig, so S5/G4's "cannot disagree" is false; vk-gen-check checks nothing (it looks up bindgen-verify before creating it; CI runs bindgen-verify directly); and etch-grammar.md contradicts itself on annotation trailing commas, §1.5 against §21.4. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 16 ++ briefs/m1.d-phase-1-debt.md | 140 +++++++++++++++ build.zig | 41 ++--- src/core/ecs/archetype.zig | 49 +----- src/core/ecs/registry.zig | 42 ++--- src/core/ecs/world.zig | 39 +---- src/etch/diagnostics.zig | 7 +- src/etch/interp.zig | 28 ++- src/etch/parser.zig | 34 +--- src/etch/tags.zig | 22 +-- src/etch/types.zig | 160 +++--------------- src/etch/zig_codegen/lower.zig | 17 +- .../pipeline/narrowphase/fast_paths.zig | 11 +- tests/etch/diagnostic_coverage_test.zig | 28 +-- tests/etch/hot_reload_test.zig | 58 ++----- tools/weld_lint/dead_tests.zig | 16 +- 16 files changed, 262 insertions(+), 446 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8ea7ce0a..17e595d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -370,6 +370,22 @@ Three criteria, in this order, at every comment added or modified. three lines below it, says nothing. - **Concision** — the load kept, stated short. +Four flat prohibitions, with no judgement to make. + +- **No section banner** (`--- … ---`) and no narrative file or test header. A + test's title says what it measures; if the title is not enough, it is a bad + title. +- **No narrative**: what the code did before a fix, what an earlier comment + claimed, how a defect was found, what a future implementer should do. +- **No decision reasoning** — why this choice over another, what was refused and + why, what was measured to decide — **even when it passes the three criteria**: + it justifies the code instead of guarding it. +- **No duplicate of a fact already written elsewhere.** The fact belongs at the + site where its absence would make the wrong fix possible. + +A code file is not a history. Narrative and decision reasoning have two homes: the +brief's journal and the commit body. + No numeric bound: no line ceiling, no density target. §12's former three-line cap is WITHDRAWN. diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index caf303b9..a3808492 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -4716,6 +4716,146 @@ without the other, measured: ownership alone leaves 10 + 2 unseeded or null resu in the first compile and destroys live closures on the plain reload; reservation alone leaves every post-commit failure dangling, 12 of 12. + +### S6/G8 addendum — §12's fourth prohibition, applied to what S5 wrote + +`engine-zig-conventions.md` §12 now carries FOUR flat prohibitions, the new one +being *no decision reasoning — why this choice over another, what was refused and +why, what was measured to decide — even when it passes the three criteria*. +`CLAUDE.md`'s copy carried the three criteria and none of the prohibitions, so they +never travelled into a session; it now carries all four under the criteria. + +#### The set, reproduced before anything was touched + +`c3e6d316..f4a92da4`, added lines of `.zig` files: **372** comment lines for **392** +of code, **24** blocks of six lines or more totalling **285** — the figures of the +directive, to the line. 53 blocks in 14 files. + +#### Method + +Each block judged as ONE unit — kept whole, replaced by the shortest wording that +keeps the complete contract, or removed whole. Proposals came from a read-only pass, +one agent per file; every proposal was reviewed against what the block actually +guards; the edits were applied by a script that REFUSES any unit whose current text +holds a non-comment line, or whose replacement does. `build.zig` by hand, because +it also moves a comment: the `zig fmt` rationale had been separated from the +`vk_gen_fmt` it documents when `generated_binding_files` was inserted between them, +and it goes back above its command. + +**The adversarial pass was incomplete, and that is stated rather than smoothed.** +The subagent weekly limit cut it at 9 of 18 agents: five files went un-audited and +all four refuters failed, so the four findings arrived with no verdict. The five +files were audited by hand and the four findings verified at the source. All four +held and are fixed: the `HEAD`-not-index guard of `bindgen-verify`, dropped as +reasoning although it guards a wrong fix CI would not see; the `@requires` bullet of +`schemaDigestOf`, which named the residual and dropped why hashing `requires` is +the wrong fix; a tag-reload sentence whose "names alone" describes the REAL digest, +which hashes names only, in order; and the parser's annotation comment, which must +name the production it follows (below). The hand audit found a fifth: `dead_tests.zig` +had lost the line saying the Windows floor is two lower by the two +`only_on = .windows` entries, which guards the second literal. + +#### Result + +| | before | after | +|---|---|---| +| comment lines S5 added | 372 | **106** | +| code lines S5 added | 392 | 392 | +| blocks of six lines or more | 24 (285 lines) | **2 (12 lines)** | + +The two remaining blocks are pre-S5 paragraphs of `interp.zig` (the `Layout` and +`schemaDigestFor` docs), touched only to correct a sentence S5's code made false. + +**The diff is comment-only, verified on the diff and not asserted**: against `HEAD`, +comment lines −444 / +106, blank lines −2 (two double blank lines left where removed +blocks sat between blanks), code lines **0**. `zig fmt --check`, `zig build lint` +and `zig build test` green, `2376/2395 tests passed (19 skipped)`. + +Whole-file comment lines, per file: + +| file | before | after | delta | +|---|---|---|---| +| `src/etch/types.zig` | 2855 | 2749 | −106 | +| `tests/etch/hot_reload_test.zig` | 88 | 50 | −38 | +| `src/core/ecs/archetype.zig` | 195 | 158 | −37 | +| `src/core/ecs/world.zig` | 1136 | 1101 | −35 | +| `src/etch/parser.zig` | 1564 | 1538 | −26 | +| `src/core/ecs/registry.zig` | 238 | 216 | −22 | +| `build.zig` | 829 | 814 | −15 | +| `src/etch/tags.zig` | 107 | 93 | −14 | +| `tests/etch/diagnostic_coverage_test.zig` | 79 | 65 | −14 | +| `src/etch/zig_codegen/lower.zig` | 1382 | 1371 | −11 | +| `tools/weld_lint/dead_tests.zig` | 394 | 384 | −10 | +| `src/etch/diagnostics.zig` | 193 | 188 | −5 | +| `src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig` | 213 | 210 | −3 | +| `src/etch/interp.zig` | 3431 | 3429 | −2 | +| **total** | 12704 | 12366 | −338 | + +#### Comments S5's CODE had made false, corrected in the same pass + +Outside the 372 lines and counted apart, because a comment the code went stale +around is the same delivery's defect: `validateImpls` and `validateTraitImpl` still +listed the orphan rule among their checks; `isRuleArenaType` claimed *0 false +refusals over the whole suite* while S5 pins two; `allocateSlot`'s doc said it fills +the trailing chunk; `releaseChunkIfEmpty`'s doc and the reclamation tests' header +said a drained chunk is never refilled; the `Layout` and `schemaDigestFor` docs and +`schemaDigestOf`'s tuple omitted `content_digest`; `fast_paths.zig`'s header said it +imports `support.zig` ONLY; `diagnostic_coverage_test.zig`'s header gave `E0217` an +emit site and named an `expectNoCode` helper that no longer exists; `lower.zig` +pointed at a `compileProgram` that does not exist; `build.zig` described an +`.api.zig → Zig` pipeline the gate no longer touches; and `builtinResourceByName`'s +consumer list missed the reservation predicate. + +#### Moved here from the files + +Substance the files carried and neither this brief nor the commits did: + +- **Tag reloads.** The false refusal of an appended tag is accepted on an asymmetry: + a refused reload costs a restart, a missed rename silently redefines live data. + Removing that refusal would need a prefix check, and a prefix check needs the OLD + tag table stored, not only its digest. +- **`first_partial`.** Both `@min` clamps in `releaseChunkIfEmpty` never bind under + the invariant — the released chunk is empty, hence not full, so the bound is + already at or below its index — and are defensive. +- **`content_digest`** is runtime-only like `schemaDigestOf`: no cooked artifact + carries it, a `SchemaEntry` holding name, size and alignment. That is what made + widening the hash input safe — no re-cook, and a confrontation is always between + two values of the same computation. +- **`TagTable.contentDigest`** asserts that the dense array has no hole rather than + skipping one: a hole means a duplicate or out-of-range `bit_index`, and a digest + over a partly filled array identifies nothing. +- **Codegen** emits the digest as a literal because it is a pure function of the tag + table the lowering already holds; nothing in a generated binary hot-reloads, so it + changes no behaviour there and exists for parity with `tagSetDesc`. + +#### Found, not fixed — each is a code change, outside a comment-only pass + +1. **A claim of S5/G4 is false.** `tagset_component_name` is read by the predicate + and its test only; `interp.zig` spells `"TagSet"` as a literal at six sites. So + *« `TagSet` gains a named constant so the reservation and the injection cannot + disagree »* above does not hold: they can. The file now states the precondition + instead of the guarantee. +2. **`vk-gen-check` checks nothing.** `zig build vk-gen-check` reports + `1/1 steps succeeded` — itself. It looks up `bindgen-verify` at `build.zig:420`, + before that step is created at `:2470` in the same `build` function, so the + dependency is never added. Pre-existing. CI runs `bindgen-verify` directly and is + unaffected; a developer running `vk-gen-check` gets a green that means nothing. + The comment above it stays as written: it becomes true when the block moves. +3. `registerComponentRaw` does not copy `content_digest` into the stored descriptor. + The stored schema digest includes it, being computed from the input; nothing + reads the stored copy today. +4. **The grammar contradicts itself on annotations.** `etch-grammar.md` §1.5 — the + section that owns annotations — gives `annotation_args` with NO trailing comma; + §21.4, in the `asset` construct, restates `annotation` over `arg_list`, which + has one. The parser has followed §21.4 since S5/G3. The corpus is Guy's. + +#### Residual + +Pre-S5 comment units touched only to correct their false sentence keep their +pre-S5 decision reasoning — the `Layout` and `schemaDigestFor` docs, the +`schemaDigestOf` bullets. The fourth prohibition is newer than their sweep, and this +pass was scoped to what S5 wrote. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree diff --git a/build.zig b/build.zig index 9633e64f..5418d42c 100644 --- a/build.zig +++ b/build.zig @@ -2334,14 +2334,9 @@ pub fn build(b: *std.Build) void { }); const vk_gen_run = b.addRunArtifact(vk_gen_exe); vk_gen_run.has_side_effects = true; - // Generator output is unformatted, so `bindgen-vk` produces an empty diff - // only after `zig fmt` normalises identifier escapes (e.g. `@"undefined"` → - // `undefined`) and trims trailing blank lines. Pipe through fmt in the same - // step so the command is self-sufficient regardless of pre-commit hooks. - // THE ONE LIST. Every file a bindgen adapter writes, named here and nowhere - // else: the two `zig fmt` passes below take slices of it, and so does the - // `bindgen-verify` diff. Spelled twice, the gate and the generators drift — - // a new generated file would be formatted and then not gated, silently. + // Every file a bindgen adapter writes, and nothing else: no adapter writes + // `bindings/generated/*.api.zig`. Both `zig fmt` passes and the + // `bindgen-verify` diff read it, so a generated path is named here only. const generated_binding_files = [_][]const u8{ "src/core/platform/vk.zig", "src/core/platform/window/wayland_protocols/core.zig", @@ -2351,6 +2346,10 @@ pub fn build(b: *std.Build) void { const vk_generated = generated_binding_files[0..1]; const wayland_generated = generated_binding_files[1..]; + // Generator output is unformatted, so `bindgen-vk` produces an empty diff + // only after `zig fmt` normalises identifier escapes (e.g. `@"undefined"` → + // `undefined`) and trims trailing blank lines. Pipe through fmt in the same + // step so the command is self-sufficient regardless of pre-commit hooks. const vk_gen_fmt = b.addSystemCommand(&.{ b.graph.zig_exe, "fmt" }); vk_gen_fmt.addArgs(vk_generated); vk_gen_fmt.step.dependOn(&vk_gen_run.step); @@ -2397,8 +2396,8 @@ pub fn build(b: *std.Build) void { // - `zig build bindgen-detch` — regenerate in place (manual, §8.4.4) // - `zig build bindgen-check` — compare and fail with a line-by-line diff // - // Distinct from `bindgen-verify` / `vk-gen-check`, which gate the Vulkan and - // Wayland `.api.zig` → Zig pipeline and have nothing to do with services. + // Distinct from `bindgen-verify`, which gates the Vulkan and Wayland XML → Zig + // generators and has nothing to do with services. const detch_module = b.createModule(.{ .root_source_file = b.path("tools/bindgen/detch_main.zig"), .target = b.graph.host, @@ -2438,28 +2437,14 @@ pub fn build(b: *std.Build) void { // -------------------------------------------- bindgen-verify gate -- // - // The non-negotiable mechanical criterion: regenerate then diff. Exit - // 0 if the regen matches the committed output bit-for-bit; non-zero - // (visible diff) signals a divergence and blocks the merge. + // Regenerate, then diff: exit 0 only if the regen matches the committed + // output bit-for-bit; non-zero signals a divergence and blocks the merge. + // Against `HEAD`, not the index: on a staged edit only this form still + // compares against what was committed. // // KNOWN-GOOD CONTROL FIRST: `git diff --exit-code` answers 1 for a real diff // and a different non-zero when git cannot run at all, so the control must // establish that git answers before the diff's code is read as a verdict. - // - // THE PATHS ARE THE FOUR FILES THE GENERATORS WRITE, and nothing else. It - // used to name `bindings/generated/` and `src/core/platform/` wholesale, - // which is a superset of the generated set by a wide margin: the first holds - // two `.api.zig` sidecars that are maintained BY HAND and that no adapter - // writes, and the second holds `threading.zig`, `time.zig`, `fs.zig`, - // `window/`, `input/` and more. So an ordinary comment edit to - // `threading.zig` turned this gate red — observed, not imagined — and the - // gate answered a question about the working tree's cleanliness while - // reporting it as a verdict on generator drift. - // - // Against HEAD rather than the index, because "matches the COMMITTED output" - // is the criterion this comment states and the index is not the commit. The - // two agree on a clean tree and part company on a staged edit, where only - // this form still compares against what was committed. const bindgen_verify_control = b.addSystemCommand(&.{ "git", "--version" }); const bindgen_verify_diff = b.addSystemCommand(&.{ "git", "diff", "--quiet", "--exit-code", "HEAD", "--" }); bindgen_verify_diff.addArgs(&generated_binding_files); diff --git a/src/core/ecs/archetype.zig b/src/core/ecs/archetype.zig index 4faf4cb9..d34c2813 100644 --- a/src/core/ecs/archetype.zig +++ b/src/core/ecs/archetype.zig @@ -142,23 +142,9 @@ pub const Archetype = struct { registry: *const Registry, layout: ChunkLayout, chunks: std.ArrayListUnmanaged(*Chunk) = .empty, - /// A LOWER BOUND on the index of any non-full chunk: every chunk below it is - /// full. Not "the first partial chunk" — that would be an equality nobody - /// could cheaply maintain, and stating it as a bound is what makes each - /// maintenance site decidable on its own. Lowering is ALWAYS safe (it can - /// only make the scan start earlier); advancing is what must be earned, and - /// `allocateSlot` earns it by walking past chunks it has just observed full. - /// - /// Without it `allocateSlot` filled only the TRAILING chunk, so a chunk left - /// half-empty by churn was never refilled and the count followed cumulative - /// appends rather than live population. - /// - /// **No entity moves to make this work.** `removeSwap` swaps the trailing - /// entity into the freed slot, so a chunk's occupants are always a dense - /// prefix and its free space is always at the tail — reuse appends there, - /// exactly as the trailing-chunk path already did. That is why this needs no - /// location repair and no `ComponentRef` change: nothing is invalidated - /// because nothing is displaced. + /// Every chunk below this index is full, and it never exceeds + /// `chunks.items.len`: a lower bound, not necessarily the first non-full + /// chunk. Lowering it is always safe; advance it only past chunks seen full. first_partial: u32 = 0, transitions: TransitionCache = .{}, /// `true` iff this archetype hosts a singleton-entity @@ -258,19 +244,15 @@ pub const Archetype = struct { return self.componentIndex(component_id) != null; } - /// Reserve a slot in the trailing chunk (allocating a new chunk when - /// the current one is full) without writing any component data. The + /// Reserve a slot at the tail of the first chunk with room, which need not + /// be the trailing chunk (allocating a new chunk when every chunk is full), + /// without writing any component data. The /// caller is responsible for filling the slot's component columns /// and the entity-id slot before any iteration touches them. The /// per-component `added_tick[col][slot]` and `changed_tick[col][slot]` /// sidecars are initialised to `tick`, and the slot's dirty bit is /// set — the entity is "fresh" for the current frame. pub fn allocateSlot(self: *Archetype, gpa: std.mem.Allocator, tick: Tick) ArchetypeError!SpawnResult { - // Walk forward from the bound, past chunks observed FULL, and record how - // far we got — that is the only place the bound advances, and it - // advances on an observation rather than on an assumption. The walk is - // amortised O(1): each step it takes is paid once per chunk until - // something lowers the bound again. var idx = self.first_partial; while (idx < self.chunks.items.len and self.chunks.items[idx].header().entity_count >= self.layout.capacity) : (idx += 1) @@ -294,9 +276,6 @@ pub const Archetype = struct { } change_detection.setDirty(chunk.dirtyBitset(&self.layout), slot); - // The CHOSEN index, not the trailing one. The old form was correct only - // because the only reachable destination was the last chunk; it becomes - // a wrong location the moment an earlier chunk can be the destination. return .{ .chunk_idx = chunk_idx, .slot = slot, @@ -360,11 +339,6 @@ pub const Archetype = struct { const chunk = self.chunks.items[chunk_idx]; const hdr = chunk.header(); std.debug.assert(slot < hdr.entity_count); - // This chunk is about to have room, so the bound cannot stay above it. - // Unconditional `@min` rather than a test on capacity: the bound is a - // LOWER bound, so lowering it when it was already low costs a comparison - // and can never be wrong, where a conditional would have to reason about - // the pre-removal count. self.first_partial = @min(self.first_partial, chunk_idx); const last = hdr.entity_count - 1; if (slot == last) { @@ -409,11 +383,6 @@ pub const Archetype = struct { /// `null` covers two cases alike to the caller — "not empty" and "the /// trailing chunk was freed" — since neither renumbers anything. Only /// `chunks_released` tells them apart. - /// - /// Reclaiming at all matters because `allocateSlot` fills only the TRAILING - /// chunk: a chunk drained by churn is never refilled, so the count follows - /// cumulative appends rather than live population until `dispatchBatch` - /// refuses the archetype at its chunk ceiling. pub fn releaseChunkIfEmpty(self: *Archetype, gpa: std.mem.Allocator, chunk_idx: u32) ?u32 { const chunk = self.chunks.items[chunk_idx]; if (chunk.header().entity_count != 0) return null; @@ -423,17 +392,11 @@ pub const Archetype = struct { self.chunks_released += 1; if (chunk_idx == last_idx) { _ = self.chunks.pop(); - // The list shrank; a bound past its end would make the walk skip the - // allocation branch's precondition. Clamp rather than reset, so what - // was earned about the chunks below is not thrown away. self.first_partial = @min(self.first_partial, @as(u32, @intCast(self.chunks.items.len))); return null; } self.chunks.items[chunk_idx] = self.chunks.items[last_idx]; _ = self.chunks.pop(); - // The trailing chunk moved DOWN into `chunk_idx` and may be partial, so - // the bound must not sit above its new home. Clamping to the new length - // in the same expression keeps the bound inside the list after a pop. self.first_partial = @min(self.first_partial, chunk_idx); return chunk_idx; } diff --git a/src/core/ecs/registry.zig b/src/core/ecs/registry.zig index 80c81752..09d03a11 100644 --- a/src/core/ecs/registry.zig +++ b/src/core/ecs/registry.zig @@ -185,49 +185,27 @@ pub const ComponentDesc = struct { /// computed once by `finalizeRequires` and read per add, never re-walked per /// add (`engine-ecs-internals.md` §3). requires: []const []const u8 = &.{}, - /// Digest of identity the four layout members CANNOT express, folded into - /// `schemaDigestOf`. Zero for every component whose identity is fully - /// described by its name, size, alignment and fields — which is all of them - /// but `TagSet`. - /// - /// `TagSet` is a bitfield and not a struct, so it carries `fields = &.{}` - /// and its size alone says how many WORDS of tags exist, never WHICH tag - /// owns which bit. Renaming or reordering tags inside one word therefore - /// left the digest identical while every live entity's bits changed meaning. - /// The producer is `TagTable.contentDigest`; nothing else sets this today. - /// - /// **Runtime-only, like the digest it feeds.** No cooked artifact carries - /// it: a `SchemaEntry` holds name, size and alignment, so widening this - /// tuple invalidates no scene and demands no re-cook. That is what makes it - /// safe to change the hash input at all — a confrontation is always between - /// two values of the SAME computation, never against a stored one. + /// Identity the other inputs of `schemaDigestOf` cannot express: + /// `TagTable.contentDigest` on the builtin `TagSet`, `0` everywhere else. content_digest: u64 = 0, }; /// The 64-bit schema identity of `desc` (`engine-ecs-internals.md` §13), over -/// `(name, size, alignment, [(field name, kind, offset) in declaration order])`. +/// `(name, size, alignment, [(field name, kind, offset) in declaration order], +/// content_digest)`. /// /// - **Derived at REGISTRATION, not at `comptime`.** A component declared in /// Etch has no Zig type when the engine is compiled. /// - **Size and alignment are IN the tuple**, not only the fields: a component -/// with no named field — the builtin `TagSet`, an opaque block sized by the -/// program's tag table — is discriminated by nothing else. +/// with no named field and a zero `content_digest` is discriminated by +/// nothing else. /// - **Storage mode is OUT of it.** `table` or `sparse` is a property of this /// registry and not of the layout (`ARCH-005`), so changing it provokes /// neither refusal nor migration. -/// - **`requires` is OUT of it too, and the reason is measured rather than -/// inherited from the line above.** It never appears beside a size, an -/// alignment or an offset anywhere in this file: it changes NOTHING about -/// layout, so a reload that only edits `@requires` leaves every live entity's -/// bytes valid and meaning exactly what they meant. Every consumer of the -/// closure — `World.addComponent*`, the removal guards, the observer arm — is -/// FORWARD-looking and gates the next operation; nothing re-validates entities -/// already spawned. So a newly added requisite is an invariant already-live -/// entities may violate unchecked, which is a real residual and is NOT a -/// layout problem: folding it in here would make this refusal fire under a -/// message that names a layout that did not change. -/// - **`content_digest` IS in it**, because it exists precisely to carry the -/// identity the four members above cannot. +/// - **`requires` is OUT of it too**: it changes no layout, so hashing it would +/// refuse a reload as a layout change that did not happen. A reload editing +/// only `@requires` passes, and nothing re-checks live entities against a newly +/// added requisite. /// /// Sensitive to a field added in EXISTING padding, since offsets enter the hash. /// diff --git a/src/core/ecs/world.zig b/src/core/ecs/world.zig index 72dc064a..5d400ded 100644 --- a/src/core/ecs/world.zig +++ b/src/core/ecs/world.zig @@ -2837,16 +2837,6 @@ test "grouped ops reject duplicate / absent components (R11c) without panicking" try std.testing.expect(world.componentBytes(e, c) != null); } -// -// `allocateSlot` fills only the TRAILING chunk, so a chunk drained by churn is -// never refilled, and without reclamation the chunk count follows the cumulative -// number of appends rather than the live population — until `dispatchBatch` -// refuses the archetype at its chunk ceiling. -// -// Every assertion below is on `chunks_released` or on a survivor's bytes, never -// on the absence of a crash: an implementation that reclaims nothing passes -// every OTHER test in this file. - /// A four-byte probe, so one chunk holds many entities and the capacity is the /// test's own parameter rather than a literal that a layout change would rot. fn reclaimProbe(name: []const u8) ComponentDesc { @@ -2940,18 +2930,6 @@ test "sustained churn keeps the chunk count on the live population" { try std.testing.expect(arch.chunks_released >= 4); } -// ---------------------------------------------------------- partial reuse -- -// -// The regime the reclamation-at-zero path does NOT reach: chunks that stabilise ABOVE -// zero. They are never emptied, so nothing is released, and before `first_partial` -// they were never refilled either — `allocateSlot` looked only at the trailing -// chunk, so every spawn landed there and a half-empty chunk stayed half-empty. -// -// Every assertion below is on the chunk COUNT with `chunks_released` pinned at -// ZERO beside it. The pairing is the point: a count that stays put while something -// was reclaimed would be the other mechanism, and this one must be shown to work -// with the other mechanism silent. - test "a chunk left partial by churn is refilled, and nothing is reclaimed" { const gpa = std.testing.allocator; var world = World.init(); @@ -2968,16 +2946,13 @@ test "a chunk left partial by churn is refilled, and nothing is reclaimed" { while (ids.items.len < 3 * cap) try ids.append(gpa, try world.spawnDynamic(gpa, &.{cid})); try std.testing.expectEqual(@as(usize, 3), arch.chunks.items.len); - // Drain a QUARTER of chunk 0 — enough to leave room, never enough to empty - // it, so the reclamation path cannot fire and take the credit. const holes = cap / 4; try std.testing.expect(holes > 0); for (0..holes) |i| try world.despawn(gpa, ids.items[i]); try std.testing.expectEqual(@as(u64, 0), arch.chunks_released); try std.testing.expectEqual(@as(usize, 3), arch.chunks.items.len); - // Spawn exactly as many back. The trailing chunk is FULL, so without reuse - // every one of these allocates into a FOURTH chunk. + // The trailing chunk must be FULL here, or the count below passes without reuse. for (0..holes) |_| _ = try world.spawnDynamic(gpa, &.{cid}); try std.testing.expectEqual(@as(usize, 3), arch.chunks.items.len); @@ -3004,12 +2979,6 @@ test "a reused slot is reported at the chunk it actually landed in" { try world.despawn(gpa, ids.items[0]); - // THE LOCATION, not merely the count. `allocateSlot` used to answer - // `chunks.items.len - 1` unconditionally, which was right only while the - // trailing chunk was the sole possible destination; the moment an earlier - // chunk can be chosen, that answer names the wrong chunk and the entity is - // recorded where it is not. So this asserts chunk 0 AND reads the bytes back - // through the recorded location. const reused = try world.spawnDynamicWithValues(gpa, &.{cid}, &.{&[_]u8{ 9, 0, 0, 0 }}); const loc = world.entity_locations.get(reused).?; try std.testing.expectEqual(@as(u32, 0), loc.chunk_idx); @@ -3018,7 +2987,6 @@ test "a reused slot is reported at the chunk it actually landed in" { const bytes = world.componentBytes(reused, cid).?; try std.testing.expectEqual(@as(u8, 9), bytes[0]); - // And the entity that removeSwap displaced is still readable at its new home. for (ids.items[1..]) |e| { const b = world.componentBytes(e, cid) orelse return error.SurvivorLost; try std.testing.expectEqual(@as(u8, 7), b[0]); @@ -3040,16 +3008,11 @@ test "first_partial is a lower bound: every chunk below it is full" { try ids.append(gpa, first); while (ids.items.len < 3 * cap) try ids.append(gpa, try world.spawnDynamic(gpa, &.{cid})); - // Churn across three chunks, then re-fill, then churn again — the sequence - // that exercises lowering at `removeSwap` against advancing at `allocateSlot`. var round: usize = 0; while (round < 3) : (round += 1) { for (0..cap / 8) |i| try world.despawn(gpa, ids.items[round * cap + i]); for (0..cap / 8) |_| _ = try world.spawnDynamic(gpa, &.{cid}); - // THE INVARIANT, asserted as the bound it is declared to be — not as an - // equality with "the first partial chunk", which the field deliberately - // does not claim. for (arch.chunks.items[0..arch.first_partial]) |c| { try std.testing.expectEqual(arch.layout.capacity, c.header().entity_count); } diff --git a/src/etch/diagnostics.zig b/src/etch/diagnostics.zig index 8a7624a9..89302b4a 100644 --- a/src/etch/diagnostics.zig +++ b/src/etch/diagnostics.zig @@ -47,12 +47,7 @@ pub const DiagnosticCode = enum { ambiguous_trait_method, // E0211 AmbiguousTraitMethod incomplete_trait_impl, // E0214 IncompleteTraitImpl conditional_impl_condition_not_proven, // E0215 ConditionalImplConditionNotProven - /// E0217 OrphanImpl. DECLARED WITH NO PRODUCER, deliberately: the §7.4 - /// orphan rule needs a trait or type that RESOLVES while being foreign, and - /// with no cross-module trait resolution `not local` and `not declared` - /// coincide — so every program that could violate it is already answered by - /// `undefined_symbol`. The emission was removed from `validateTraitImpl` - /// rather than relocated; see the note there. + /// E0217 OrphanImpl. No producer while traits do not resolve across modules (`etch-resolver-types.md` §7.4). orphan_impl, immutable_receiver_for_mut_self, // E0220 ImmutableReceiverForMutSelfMethod closure_cannot_mutate_capture, // E0221 ClosureCannotMutateCapture diff --git a/src/etch/interp.zig b/src/etch/interp.zig index 6bc1e240..86c37b6c 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -7297,11 +7297,7 @@ pub const RegKind = enum { component, resource }; /// `default_bytes` is the caller's, because `registerComponentRaw` stores it; the /// digest does not read it (see `schemaDigestFor`). /// -/// `content_digest` is `TagTable.contentDigest` and carries WHICH TAG OWNS WHICH -/// BIT, which no other member can: `size` says how many words exist and -/// `fields` is empty because a bitfield is not a struct. Without it a reload -/// renaming or reordering tags inside one word kept the digest and silently -/// redefined every live entity's bits. +/// `content_digest` is `TagTable.contentDigest` of the table `size` came from. fn tagSetDesc(size: u16, default_bytes: []const u8, content_digest: u64) weld_core.ecs.registry.ComponentDesc { return .{ .name = "TagSet", @@ -7442,11 +7438,12 @@ fn verifySchemas( } /// The LAYOUT half of a type declaration: field descriptors, size, alignment. -/// Extracted because it is EXACTLY what a schema digest reads and nothing more — -/// `Registry.schemaDigestOf` hashes name, size, alignment and each field's -/// (name, kind, offset), and never `default_bytes`. Materialising the defaults is -/// the other half of `compileTypeDecl`, it allocates immortal persistent blocks, -/// and the digest never looks at them. +/// Extracted because it is EXACTLY what a declaration's schema digest reads and +/// nothing more — `Registry.schemaDigestOf` hashes name, size, alignment, each +/// field's (name, kind, offset) and `content_digest`, which no declaration sets, +/// and never `default_bytes`. Materialising the defaults is the other half of +/// `compileTypeDecl`, it allocates immortal persistent blocks, and the digest +/// never looks at them. /// /// That split is what makes the pre-validation pass in `Interpreter.compile` cheap /// and side-effect-free: it can confront every declared schema against the live @@ -7528,11 +7525,12 @@ fn computeLayout( /// worse than no pre-pass: it would refuse reloads the site accepts, or wave /// through the ones it refuses. /// -/// **It takes a name and a layout, and nothing else, because nothing else is -/// hashed.** `schemaDigestOf` reads the name, the size, the alignment and each -/// field's (name, kind, offset) — measured, and pinned by `registry.zig`'s « the -/// digest is blind to the default bytes », which names this function as its -/// dependent. `default_bytes`, `storage` and `requires` are all absent from it. +/// **It takes a name and a layout, and nothing else, because nothing else a +/// declaration carries is hashed.** `schemaDigestOf` reads the name, the size, the +/// alignment, each field's (name, kind, offset) and `content_digest`, which no +/// declaration sets — measured, and pinned by `registry.zig`'s « the digest is +/// blind to the default bytes », which names this function as its dependent. +/// `default_bytes`, `storage` and `requires` are all absent from it. /// /// Taking a `storage` and a `requires` this function cannot use would be a /// signature declaring an influence it does not have, and it cost the pre-pass an diff --git a/src/etch/parser.zig b/src/etch/parser.zig index 7ef40119..3bc85ba7 100644 --- a/src/etch/parser.zig +++ b/src/etch/parser.zig @@ -948,12 +948,8 @@ pub const Parser = struct { try self.arena.annot_args.append(self.gpa, arg); args_len += 1; if (!try self.match(.comma)) break; - // Same optional trailing comma: `annotation = "@" , - // IDENT , [ "(" , [ arg_list ] , ")" ]` reaches the SAME - // `arg_list` production. This loop is annotation-local - // and does not go through `parseCallArgList`, which is - // why enumerating the grammar's `arg_list` users rather - // than that function's callers is what finds it. + // `etch-grammar.md` §21.4 gives `annotation` an `arg_list`, whose + // trailing comma is legal. if (self.peek() == .rparen) break; } } @@ -6432,17 +6428,8 @@ pub const Parser = struct { try out.args.append(self.gpa, a.raw()); try out.names.append(self.gpa, name); if (!try self.match(.comma)) break; - // THE TRAILING COMMA IS PART OF THE GRAMMAR, not a tolerance: - // `arg_list = arg , { "," , arg } , [ "," ]`, and the same optional - // comma is already honoured in array, struct, map and match-arm - // literals. Without this break the loop went on to parse an argument - // that is not there, and the two diagnostics it produced both named - // the wrong cause — on a named list it reported the §3.3 ordering - // rule, which the program was obeying. - // - // Every argument shape passes through here — function call, method - // call, widget element — and all three expect `)` immediately after, - // so testing for it covers the set rather than a sample. + // `arg_list = arg , { "," , arg } , [ "," ]`: the trailing comma is grammar. + // Precondition: every caller closes the list with `)`. if (self.peek() == .rparen) break; } } @@ -9317,16 +9304,6 @@ test "parser rejects a positional argument after a named one (§3.3)" { test "parser accepts a trailing argument comma (grammar l.571)" { const gpa = std.testing.allocator; - // `arg_list = arg , { "," , arg } , [ "," ]` — the trailing comma is - // OPTIONAL, and the same optional comma is already honoured in array, - // struct, map and match-arm literals. - // - // WHAT THIS EXERCISES AND WHAT IT DOES NOT, stated rather than implied: the - // function-call and method-call shapes are driven here directly, plus an - // annotation, whose arguments are parsed by a SEPARATE loop. The widget - // element shape is the third caller of `parseCallArgList` and is covered - // STRUCTURALLY — one function, three callers, each verified at the code to - // expect `)` immediately after — and not by a case of its own. var result = try parse(gpa, \\@tag(.a,) \\rule r(entity: Entity) when entity has C { @@ -9341,9 +9318,6 @@ test "parser accepts a trailing argument comma (grammar l.571)" { test "parser still rejects a positional argument after a named one, trailing comma or not" { const gpa = std.testing.allocator; - // THE GREEN TWIN'S OTHER HALF. The trailing-comma fix works by breaking the - // loop when `)` follows the comma, so it must NOT weaken the §3.3 ordering - // rule — which is the rule whose message the defect was borrowing. var result = try parse(gpa, \\rule r(entity: Entity) when entity has C { \\ f(a: 1, 2,) diff --git a/src/etch/tags.zig b/src/etch/tags.zig index acc98475..86d310e5 100644 --- a/src/etch/tags.zig +++ b/src/etch/tags.zig @@ -92,24 +92,10 @@ pub const TagTable = struct { return (self.leaf_count + 63) / 64; } - /// Digest over WHICH TAG OWNS WHICH BIT — the identity `words()` cannot - /// express, since a size says how many words exist and never what they mean. - /// Feeds `ComponentDesc.content_digest`, so a reload that renames or - /// reorders tags inside one word is refused instead of silently redefining - /// every live entity's bits. - /// - /// **The map is never iterated to produce the output**, which is the - /// property this file's header makes load-bearing: a `bit_index` is a pure - /// function of declaration order, and hashing in hash-map order would make - /// the digest vary build to build. Leaves are PLACED at their own index in a - /// dense scratch array and read back in index order, so the iteration order - /// reaches nothing observable. That the array fills completely is asserted - /// rather than assumed — a hole would mean a duplicate or an out-of-range - /// index, and a digest over a partly-filled array is a digest over garbage. - /// - /// Hashes the leaf count first, then per index the path's length and bytes. - /// The length is explicit so that `a.bc` and `ab.c` cannot collide by - /// concatenation. + /// Digest of which leaf path owns which bit. Requires a table `build` + /// returned without diagnostics, else two leaves can share a `bit_index`. + /// Hashed in `bit_index` order, never in `map` iteration order; the length + /// prefix stops adjacent paths trading bytes (`n.ab`,`n.c` vs `n.a`,`bn.c`). pub fn contentDigest(self: *const TagTable, gpa: std.mem.Allocator) !u64 { var h = std.hash.Wyhash.init(0); h.update(std.mem.asBytes(&self.leaf_count)); diff --git a/src/etch/types.zig b/src/etch/types.zig index 778d7ce7..17028da0 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -250,33 +250,20 @@ pub const builtin_resources = [_]BuiltinResource{ } }, }; -/// The builtin COMPONENT the interpreter injects when a program declares any -/// tag. Named here rather than spelled at each site so the reservation below and -/// the injection itself cannot disagree about which name is taken. +/// The builtin component the interpreter injects when a program declares any +/// tag. `interp.zig` spells it as a literal, which this must equal. pub const tagset_component_name = "TagSet"; -/// True iff `name` is one the engine itself registers into the ECS registry, so -/// a program declaring a `component` or `resource` under it would collide. -/// -/// DERIVED from `builtin_resources` rather than listed, so adding a builtin -/// resource extends the reservation by itself — a second list is how the two -/// come to disagree. The bound is `component` and `resource` and nothing else: -/// those are the two declaration kinds that enter the registry by name, which is -/// where `idOf` collides. A `struct` is by-value and never registered, and an -/// `event` lives in its own namespace. -/// -/// What the collision cost before this: the builtin injection arm in `interp.zig` -/// finds the user's entry with `idOf`, takes its `continue`, and the offset -/// resolution below it then unwraps `findField(gid, "dt").?` on a type that has -/// no such field — a panic, with no diagnostic, on an ordinary program. +/// True iff the engine itself registers `name` in the ECS registry, so a user +/// `component` or `resource` of that name would collide. pub fn isReservedEngineTypeName(name: []const u8) bool { if (std.mem.eql(u8, name, tagset_component_name)) return true; return builtinResourceByName(name) != null; } /// Descriptor lookup by resource name bytes. Consulted by the -/// receiver-less `get(T)` resolution and by the builtin-resource field -/// lookup; `interp.zig` iterates `builtin_resources` directly. +/// receiver-less `get(T)` resolution, the builtin-resource field lookup and +/// `isReservedEngineTypeName`; `interp.zig` iterates `builtin_resources` directly. pub fn builtinResourceByName(name: []const u8) ?*const BuiltinResource { for (&builtin_resources) |*r| { if (std.mem.eql(u8, r.name, name)) return r; @@ -3343,10 +3330,6 @@ pub const TypeChecker = struct { // ─── Pass 1 ────────────────────────────────────────────────────────── - /// Refuse a `component`/`resource` declaration that takes a name the engine - /// itself registers. Reuses `E0101` with a contextual message rather than - /// minting a code, on the precedent set for duplicate `test` names: the fault - /// IS a duplicate symbol, the other declarant simply being the engine. fn refuseReservedEngineName(self: *TypeChecker, name: StringId, span: SourceSpan, kind_word: []const u8) !void { const slice = self.arena.strings.slice(name); if (!isReservedEngineTypeName(slice)) return; @@ -3709,8 +3692,7 @@ pub const TypeChecker = struct { } /// Validate every `impl`'s target type once all symbols are known: the target - /// must be a declared `struct` / `component` / `resource`. Coherence and orphan - /// rules (§7.4) apply to a trait impl, conditional impls included. + /// must be a declared `struct` / `component` / `resource`. fn validateImpls(self: *TypeChecker) !void { const kinds = self.arena.items.items(.kind); const datas = self.arena.items.items(.data); @@ -3737,10 +3719,9 @@ pub const TypeChecker = struct { } /// Validate one `impl Trait for Type [when …]` ( - /// `etch-resolver-types.md §7.2/§7.4`). Checks: the trait is declared; the orphan - /// rule (§7.4 — trait OR type local to this module); every abstract trait method is - /// provided (E0214, else the trait must supply a default); the target type is a - /// struct / component / resource / `Entity`. + /// `etch-resolver-types.md §7.2/§7.4`). Checks: the trait is declared; every + /// abstract trait method is provided (E0214, else the trait must supply a + /// default); the target type is a struct / component / resource / `Entity`. fn validateTraitImpl(self: *TypeChecker, impl: ast_mod.ImplDecl, span: SourceSpan) !void { const trait_slice = self.arena.strings.slice(impl.trait_name); const type_slice = self.arena.strings.slice(impl.type_name); @@ -3762,27 +3743,9 @@ pub const TypeChecker = struct { try self.emit(.undefined_symbol, .error_, span, "trait-impl target '{s}' is not a struct, component, resource, or Entity", .{type_slice}); } - // ORPHAN RULE (§7.4), AND IT HAS NO EXPRESSIBLE INSTANCE TODAY — stated - // here instead of standing as a condition that cannot hold. - // - // The rule is that an impl is legal only if the trait OR the type is - // local to this module, so its violation needs a trait or a type that - // resolves while being FOREIGN. Two things forbid that. First, the - // `!trait_local` arm above RETURNS, so any test placed here reads - // `trait_local == true` by construction and `!trait_local and …` is - // false whatever the type is. Second, and this survives moving the test - // above that return: with no cross-module trait resolution, `not local` - // and `not declared` are the same predicate, so the only programs that - // could reach it are ones naming two undeclared symbols — for which - // `undefined_symbol` is the true and more useful diagnostic, and - // `orphan impl` would be a worse one wearing the right name. - // - // So the emission is REMOVED rather than relocated: relocating it would - // buy reachability by giving the code a meaning §7.4 does not give it. - // `E0217` stays declared, with no producer, until a module can name a - // foreign trait — the same gap the resolver already records for - // imported-trait impls. What single-module mode answers is pinned by a test, - // so a later attempt to make this reachable by the wrong route reddens. + // Orphan rule (§7.4): not checked. A check above the `!trait_local` + // return would fire on undeclared names, which `undefined_symbol` + // already reports. // E0214: every abstract trait method (no default body) must be provided. const tdecl = self.arena.trait_decls.items[self.arena.itemData(trait_sym.?.item_id)]; @@ -5569,20 +5532,10 @@ pub const TypeChecker = struct { const value: NodeId = @bitCast(data); if (!value.isNone()) { const vt = self.synthHeadValue(value, ctx); - // A `race` BRANCH'S RETURN IS A RETENTION POINT. The winner's - // value is parked in `AsyncTask.result` and re-raised at the - // race site several ticks later, after the rule arena has been - // reset — and `result` is a bare `Value` that stabilises - // nothing, where the sibling retention point at least - // deep-copies a string. - // - // Keyed on what the point RETAINS and not on how the value was - // written, which is the whole correction: `return a + "y"` was - // already refused and `return "x" + "y"` was not, though both - // park the same `.string_run` — the first only because `let a` - // made it a local the sibling rule walks. The same predicate - // as that rule, so the two cannot drift apart on which forms - // count as arena-backed. + // The winner's value is parked in `AsyncTask.result` and + // re-raised at the race site several ticks later, after the + // rule arena has been reset — and `result` is a bare `Value` + // that stabilises nothing. if (self.conc_branch) |ck| { if (ck == .race and isRuleArenaType(vt)) { try self.emit(.rule_arena_value_escapes, .error_, self.arena.exprSpan(value), "this 'race' branch returns a value stored in the rule arena; the winner's value is parked and re-raised at the race site after the arena has been reset", .{}); @@ -5971,7 +5924,6 @@ pub const TypeChecker = struct { // // The direction is `E0223`'s own: a false refusal is a compile error // the author reads, a missed capture is an abort in production. - // Measured at 0 false refusals over the whole suite. .unknown, .generic => true, .builtin => |b| b == .string_, else => false, @@ -13210,9 +13162,8 @@ test "an optional whose payload is not a builtin is still refused" { try expectAnyCode(v.diagnostics.items, .rule_arena_value_escapes); } -/// Build a one-rule program whose body is `body`, with optional extra `decls`. -/// Shared by the race-return retention tests below so every case differs in -/// exactly the expression under test and in nothing else. +/// A one-rule program whose first `race` branch is `return `, with +/// `decls` inserted before the rule. fn raceReturnProgram(gpa: std.mem.Allocator, decls: []const u8, body: []const u8) ![:0]u8 { return std.fmt.allocPrintSentinel( gpa, @@ -13225,17 +13176,6 @@ fn raceReturnProgram(gpa: std.mem.Allocator, decls: []const u8, body: []const u8 test "a race branch returning a rule-arena value is refused, per form" { const gpa = std.testing.allocator; - // THE RETENTION POINT, NOT THE PRODUCTION SITE. A `race` branch's `return` - // value is parked in `AsyncTask.result` and re-raised at the race site - // several ticks later, after the rule arena has been reset. `result` is a - // bare `Value` and stabilises NOTHING — where `captureEventFilter` at least - // deep-copies a string. - // - // The refusal is keyed on what the point RETAINS, which is why every form - // is listed: before this, `return a + "y"` was refused and - // `return "x" + "y"` was not, though both park the same `.string_run` — - // the first only because `let a` made it a local that the sibling rule - // already walks. Coverage by accident of spelling. const forms = [_]struct { decls: []const u8, expr: []const u8 }{ .{ .decls = "", .expr = "[1, 2, 3]" }, // array literal .{ .decls = "", .expr = "[1: 2]" }, // map literal @@ -13260,11 +13200,6 @@ test "a race branch returning a rule-arena value is refused, per form" { test "a race branch returning a NON-arena value is still accepted — the green twin" { const gpa = std.testing.allocator; - // NAMED BEFORE RUNNING. Without this the refusal above is satisfied by a - // rule that refuses every `race` return whatsoever, which would key on the - // SUSPENSION and not on the STORAGE — the distinction the whole entry is - // about. A bare string literal is in the AST pool (`.string_id`), not the - // rule arena, so it belongs on this side. const forms = [_]struct { decls: []const u8, expr: []const u8 }{ .{ .decls = "", .expr = "1" }, .{ .decls = "", .expr = "2.5" }, @@ -13287,34 +13222,17 @@ test "a race branch returning a NON-arena value is still accepted — the green test "a race branch returning a bare string literal is refused — the MEASURED COST" { const gpa = std.testing.allocator; - // THE FALSE REFUSAL, SHOWN RATHER THAN DECLARED. A bare `"x"` is an - // AST-pool handle (`.string_id`), copy-stable and outliving the arena, so - // parking it is safe and it is refused anyway. - // - // It is NOT separable at this point: `isRuleArenaType` answers on the - // RESOLVED type, and a literal, a concatenation and a resource-owned string - // are all `builtin .string_` — the zone is a property of the value, not of - // the type. Splitting them here would need the same thing the sibling rule - // needs and does not have. - // - // Taken deliberately, and for a reason beyond consistency: both retention - // points now consult ONE predicate, so a form can never count as - // arena-backed at one and not at the other. Measured cost on the repository: - // ZERO — the corpus contains no `race` construct at all, so nothing existing - // is refused by this. The cost is exactly this constructed case. + // A bare `"x"` is an AST-pool handle, safe to park, and refused anyway: + // `isRuleArenaType` sees only the resolved type, the same `string` as an + // arena-backed concatenation. const src = try raceReturnProgram(gpa, "", "\"x\""); defer gpa.free(src); var c = try parseAndCheck(gpa, src); defer c.deinit(gpa); try expectAnyCode(c.diagnostics.items, .rule_arena_value_escapes); - // SECOND CASE, AND IT IS A DIFFERENT ARM. A bare enum shorthand in a - // `return` has no expected type to resolve against and comes back - // `.unknown`, which the shared predicate refuses on the arbitrated ground - // that safety cannot be ESTABLISHED for an unresolved type. So this one is - // not the string over-refusal repeated — it is the `.unknown` arm, reached - // here by a value that happens to be a POD discriminant at runtime. Found - // by the green twin rejecting it, not by reading the predicate. + // A bare enum shorthand has no expected type here and resolves `.unknown`, + // which `isRuleArenaType` refuses though the value is a discriminant. const en = try raceReturnProgram(gpa, "enum K { a, b }\n", ".b"); defer gpa.free(en); var e2 = try parseAndCheck(gpa, en); @@ -13325,15 +13243,9 @@ test "a race branch returning a bare string literal is refused — the MEASURED test "a scalar event filter is still accepted, and only a string can reach one" { const gpa = std.testing.allocator; - // THE SECOND RETENTION POINT NEEDS NO REFUSAL, and this pins why rather - // than asserting it. `wake.filter` retains the captured filter values - // across ticks, and `captureEventFilter` stabilises STRINGS only — which is - // complete, because a string is the only rule-arena form that can reach an - // event field at all. Measured on the four other candidates: an `int?` - // field and an `int[]` field are refused as FIELD TYPES, a struct-valued - // filter is refused by the filter checker, and an enum filter carries a - // discriminant. If any of those four ever becomes legal, this test is where - // the assumption is written down. + // `captureEventFilter` copies only strings, and captured filters are kept + // across ticks: sound only while a string is the only rule-arena form that + // can reach an event filter. const scalar = \\component C { out: int = 0 } \\enum K { a, b } @@ -13367,17 +13279,6 @@ test "a scalar event filter is still accepted, and only a string can reach one" test "a component or resource may not take a name the engine registers" { const gpa = std.testing.allocator; - // THE COLLISION WAS ACCEPTED AND THEN PANICKED, not diagnosed. `interp.zig` - // injects the builtin time resources after the user-declaration loop and - // skips any name already in the registry — so a user's `resource GameTime` - // won the name, the builtin arm took its `continue`, and the offset - // resolution below it unwrapped `findField(gid, "dt").?` on a type with no - // such field. The comment there asserted the lookups "cannot miss", which - // the `continue` arm is exactly the case that makes false. - // - // Every name is exercised, and both declaration kinds for each, because the - // registry is ONE namespace: a `component GameTime` collides as surely as a - // `resource` one. const forms = [_][]const u8{ "resource GameTime { dt: float = 0.0 }", "resource GameTime { zz: int = 0 }", @@ -13404,10 +13305,6 @@ test "a component or resource may not take a name the engine registers" { test "an ordinary component or resource name is untouched — the green twin" { const gpa = std.testing.allocator; - // NAMED BEFORE RUNNING. Without it the refusal above is satisfied by a rule - // that refuses every declaration, and the reservation would be indistinguishable - // from a blanket. Includes two names that merely RESEMBLE the reserved ones — - // the predicate compares whole bytes, not a prefix. const forms = [_][]const u8{ "resource Ordinary { zz: int = 0 }", "component Ordinary { zz: int = 0 }", @@ -13428,9 +13325,6 @@ test "an ordinary component or resource name is untouched — the green twin" { } test "the reservation is DERIVED from the builtin table, not a second list" { - // If a builtin resource is added to `builtin_resources`, the reservation - // must extend by itself — a hand-kept list is how the two come to disagree, - // and the disagreement would be silent until a user picked the new name. for (&builtin_resources) |*br| { try std.testing.expect(isReservedEngineTypeName(br.name)); } diff --git a/src/etch/zig_codegen/lower.zig b/src/etch/zig_codegen/lower.zig index 19451cfd..ebb88dc0 100644 --- a/src/etch/zig_codegen/lower.zig +++ b/src/etch/zig_codegen/lower.zig @@ -1211,20 +1211,9 @@ fn emitRegister(w: *Writer, ast: *const AstArena, tag_table: *const tags_mod.Tag } } - // Register the builtin `TagSet` component when the program - // declares any tag. The raw descriptor mirrors the interpreter's - // `compileProgram` registration exactly (name "TagSet", size `@sizeOf`, - // align `@alignOf`, zeroed default, no named fields, and the tag-table - // content digest) so the runtime component id and layout are byte-identical - // across backends. The id is discarded — the rules look it up by name via - // `idOf("TagSet")`. - // - // The digest is emitted as a LITERAL because it is a pure function of the - // tag table this pass already holds, and it is emitted at all so this - // enumeration stays exhaustive: a member the interpreter sets and this one - // omits is the "mirrors exactly" claim above going quietly false. Nothing in - // a generated binary reloads, so it changes no behaviour here — which is the - // argument for keeping the two descriptors identical rather than against it. + // The emitted descriptor sets exactly what the interpreter's `tagSetDesc` + // sets, so the component's layout and schema digest match across backends. + // The id is discarded — the rules look it up by name via `idOf("TagSet")`. if (tag_table.leaf_count > 0) { const content_digest = try tag_table.contentDigest(w.gpa); try w.line("{"); diff --git a/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig b/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig index 750d2cd0..2319cf22 100644 --- a/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig +++ b/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig @@ -36,17 +36,14 @@ //! radius-0-box only). //! //! **Dependency discipline.** Imports `foundation` (math) and the -//! sibling `support.zig` ONLY — never `manifold.zig` (that would be a cycle: -//! `manifold.zig` imports THIS file, never the reverse), never `weld_forge`, -//! `body*.zig`, `config.zig`, or `broadphase.zig`. The scalar is the comptime -//! `T`; `forge_3d` instantiates it at `config.Real`. +//! siblings `support.zig` and `gjk.zig` ONLY — never `manifold.zig` (that would +//! be a cycle: `manifold.zig` imports THIS file, never the reverse), never +//! `weld_forge`, `body*.zig`, `config.zig`, or `broadphase.zig`. The scalar is +//! the comptime `T`; `forge_3d` instantiates it at `config.Real`. const std = @import("std"); const math = @import("foundation").math; const support = @import("support.zig"); -// Imported for the ONE margin, and the direction is safe: `gjk.zig` imports only -// `support.zig`, so this closes no cycle — `epa.zig` and `shapecast.zig` already -// depend on `gjk` the same way. const gjk = @import("gjk.zig"); /// The contact seed a fast path hands to `manifold.generateManifold` — exactly diff --git a/tests/etch/diagnostic_coverage_test.zig b/tests/etch/diagnostic_coverage_test.zig index 98ebd0a8..8f7d5a1d 100644 --- a/tests/etch/diagnostic_coverage_test.zig +++ b/tests/etch/diagnostic_coverage_test.zig @@ -1,10 +1,9 @@ //! One test per diagnostic code that the type-checker emits and that nothing //! asserted. The deliverable is coverage, not a count. //! -//! Each test names ONE code and asserts it is PRESENT. That direction is the -//! whole point: such a test goes red the day the checker stops emitting that -//! code, which a test asserting absence cannot do. `expectNoCode` exists here -//! only for the few cases that need to tell two neighbouring codes apart. +//! Each test asserts its code is PRESENT. That direction is the whole point: +//! such a test goes red the day the checker stops emitting that code, which a +//! test asserting absence cannot do. //! //! WHAT THIS FILE DOES NOT COVER, and why none of it can be covered the same //! way. Of the 203 declared codes, 138 already carry an assertion elsewhere and @@ -19,13 +18,8 @@ //! else in the tree. `E1902` is the one with a reference, in the `.d.etch` //! drift tool, as a report LABEL rather than an emitted diagnostic. //! -//! `E0217` is the 32nd and is a different case: it HAS an emit site, and that -//! site is unreachable. `validateTraitImpl` returns on `!trait_local` fourteen -//! lines before testing `!trait_local and !type_local`, so the conjunction is -//! unsatisfiable in every configuration. The comment there attributes it to -//! single-file mode, which is what makes the dead branch read as deliberate; -//! an imported trait lands in `imported_symbols`, which that function never -//! reads, so it takes the same early return. +//! `E0217` is the 32nd, left off that list only because the last test here +//! names it, to assert its absence. //! //! AND WHAT THE COUNT ITSELF DOES NOT SEE. The 138 is a STATIC reading of which //! tests name which code, and it is not verified per code: a test may name a @@ -564,16 +558,8 @@ test "W1740 empty track" { test "E0217 has no producer: an impl naming two undeclared symbols answers undefined_symbol" { const gpa = std.testing.allocator; - // THE ARBITRATION PINNED. `E0217 OrphanImpl` is declared and deliberately - // emitted by nothing: §7.4's rule needs a trait or a type that RESOLVES - // while being foreign, and with no cross-module trait resolution `not - // local` and `not declared` are one predicate. So the nearest program to a - // violation — an impl whose trait AND type are both unknown — is answered - // by `undefined_symbol`, which is the true diagnostic. - // - // This test exists so that making `E0217` reachable by RELOCATING its - // emission above the `!trait_local` return reddens here: that route would - // buy reachability by reporting "orphan impl" for two typos. + // WRONG FIX when the loop below reddens: deleting it. An undeclared trait + // or type is not a foreign one, so `orphan_impl` never answers this program. var c = try check(gpa, \\impl Missing for Absent { fn f(self) { } } ); diff --git a/tests/etch/hot_reload_test.zig b/tests/etch/hot_reload_test.zig index 6c30ec0a..45874181 100644 --- a/tests/etch/hot_reload_test.zig +++ b/tests/etch/hot_reload_test.zig @@ -241,8 +241,8 @@ const src_tags_wide = \\} ; -/// Same width as `src_tags_narrow`, same NAMES, order SWAPPED. A reorder keeps -/// the leaf count and therefore the size, while permuting every `bit_index`. +/// Same width and NAMES as `src_tags_narrow`, order SWAPPED, which permutes +/// every `bit_index`. const src_tags_reordered = \\tags { \\ a { t01, t00 } @@ -255,10 +255,8 @@ const src_tags_reordered = \\} ; -/// One MORE tag than `src_tags_narrow`, still inside the first word. Every -/// pre-existing bit keeps its index and its meaning, so this reload is SAFE and -/// is nevertheless refused — the measured cost of a whole-table digest, pinned -/// below rather than left to be discovered. +/// One MORE tag than `src_tags_narrow`, still inside the first word; every +/// existing tag keeps its `bit_index`. const src_tags_appended = \\tags { \\ a { t00, t01, t02 } @@ -271,8 +269,7 @@ const src_tags_appended = \\} ; -/// Same width as `src_tags_narrow`, different tag NAMES. Feeds the refusal -/// pinned below. +/// Same width as `src_tags_narrow`, different tag NAMES. const src_tags_renamed = \\tags { \\ a { u00, u01 } @@ -310,17 +307,6 @@ test "a reload renaming tags within one word is REFUSED" { const cid = world.registry.idOf("TagSet").?; - // `schemaDigestOf` hashes name, size, alignment and each FIELD's - // (name, kind, offset); the `TagSet` descriptor carries `fields = &.{}` - // because a bitfield is not a struct. So the four layout members say how - // many WORDS of tags exist and never WHICH tag owns which bit, and a rename - // inside one word used to keep the size, hence the digest, hence the reload - // — while every live entity's bits silently changed meaning. - // - // `ComponentDesc.content_digest` carries that identity now. The size is - // asserted UNCHANGED beside the refusal, which is what makes this a content - // refusal and not the sibling test's layout one: nothing about the layout - // moved. try std.testing.expectError(error.SchemaChanged, reloadOn(gpa, &world, src_tags_renamed)); try std.testing.expectEqual(@as(usize, 8), world.registry.componentSize(cid)); } @@ -333,11 +319,8 @@ test "a reload reordering tags within one word is REFUSED" { const cid = world.registry.idOf("TagSet").?; - // The SECOND half of the defect, and not the same case as a rename: here - // every tag NAME survives and only the declaration order moves, which - // permutes `bit_index` and therefore what each live bit denotes. A digest - // over the names alone would pass this; the digest is over (index, path) in - // index order, so it does not. + // Catches a digest over the tag names that ignores their order, which the + // rename refusal misses. try std.testing.expectError(error.SchemaChanged, reloadOn(gpa, &world, src_tags_reordered)); try std.testing.expectEqual(@as(usize, 8), world.registry.componentSize(cid)); } @@ -348,9 +331,7 @@ test "an identical tag reload is still accepted — the green twin" { defer world.deinit(gpa); try reloadOn(gpa, &world, src_tags_narrow); - // Without this the two refusals above are satisfied by a digest that refuses - // EVERY tag reload, which would be a blanket and not a discrimination. The - // same source reloads clean and the component keeps its identity. + // Catches a digest that refuses every tag reload, which both refusals miss. const cid = world.registry.idOf("TagSet").?; try reloadOn(gpa, &world, src_tags_narrow); try std.testing.expectEqual(cid, world.registry.idOf("TagSet").?); @@ -363,27 +344,8 @@ test "appending a tag inside one word is refused — the MEASURED COST, not a de defer world.deinit(gpa); try reloadOn(gpa, &world, src_tags_narrow); - // THE ADJACENT CASE, SHOWN RATHER THAN DECLARED, and it is a FALSE REFUSAL. - // Appending `t02` leaves `t00` at bit 0 and `t01` at bit 1: every live - // entity's bits keep their meaning, so this reload is safe and is refused - // anyway, because a digest over the whole table cannot distinguish "the - // prefix survived" from "the table changed". - // - // Accepted deliberately, in the direction this repository already chose: a - // refused reload costs a restart, a missed rename silently redefines live - // data. It is also less of a change in kind than it looks — an append - // CROSSING a word boundary was already refused, so "adding a tag may refuse - // your reload" was already the behaviour, just 1 time in 64 rather than - // always. - // - // What would remove it is a PREFIX check — compare the stored table against - // the new table's first N entries — and that needs the old table stored, not - // just its digest. The one slot that already stores a list, `desc.fields`, - // was measured and REFUSED for it: `componentFields` has ten readers, - // several on the scene-serialization path, which walk fields by `kind` to - // materialise values. Turning `TagSet`'s empty field list into 256 synthetic - // tag-named fields to buy an append allowance would change what that - // accessor means for every one of them. + // A false refusal: the reload is safe, but a whole-table digest cannot tell + // a surviving prefix from a changed table. try std.testing.expectError(error.SchemaChanged, reloadOn(gpa, &world, src_tags_appended)); } diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 085147fe..4810fa65 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -233,19 +233,9 @@ pub const uncollected = [_]Uncollected{ /// above — `shm_posix.zig` and `transport_posix.zig`. That is arithmetic on this /// same table and not a second measurement, which is why the CI layer matters. pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { - // RE-DERIVED FROM THE SUITE, never from the closure. `zig build test --summary all` - // reported `2376/2395 tests passed (19 skipped)` on macOS when these values were - // last set, and the closure arrives at 2395 independently from the table above. - // Bumping either to match the other is the repair the failure message forbids: it - // turns two computations of one quantity into arithmetic on itself, and the drift - // it was built to catch becomes invisible. - // - // The figure was MEASURED THREE TIMES because an earlier reading of it was wrong: - // a review subagent had registered a probe of its own into `build.zig`, and the - // total climbed 2369 → 2373 → 2375 across successive runs with no test of mine - // added. A total that moves while the tree is meant to be still is not a total. - // - // Windows is two lower by the `only_on = .windows` entries above. + // Taken from the suite, never from the closure: `M` in the `N/M tests passed` + // line of `zig build test --summary all`, skipped tests included. Windows is + // two lower, by the two `only_on = .windows` entries above. return switch (os) { .windows => 2393, else => 2395, From a3b3ea6a9413d756ac8a2be2378c50259567628f Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 23 Sep 2026 07:58:15 +0200 Subject: [PATCH 011/141] fix(ecs): the registry owns its default blocks (M1.D/S5/G8) Closes M1.D.43. A registry entry owns the immortal persistent blocks its default bytes point into and destroys them with itself; the interpreter and the scene cook own none. compile() registers through one transaction: every declaration, TagSet and the builtin time resources are prepared without touching the world, every refusal and every @requires closure is computed over the predicted graph, registry and store capacity is reserved, then an infallible commit adopts entries, buffers and closures. World.deinit is the one place resource payloads are released. Same instrument, same function: seven leak sites and a double free in compile(), an OutOfMemory swallowed by initArrayBlock and initMapBlock, a u16 panic past 64 KiB (now error.LayoutTooLarge), a leak in planTableDriven, and descriptor building, which now allocates in an arena the Descriptors value owns. Tests: an allocation sweep of compile() in three scenarios with both controls, the two teardown orders, the 64 KiB refusal, and a sweep of the nineteen Level-B programs. Floor 2395 -> 2403, windows 2401. Co-Authored-By: Claude Opus 5.5 --- src/core/ecs/hybrid_query.zig | 4 +- src/core/ecs/registry.zig | 205 ++++- src/core/ecs/resources.zig | 27 +- src/core/ecs/sparse_storage.zig | 3 +- src/core/ecs/world.zig | 30 +- src/core/testing/alloc_counting.zig | 38 + src/etch/descriptor.zig | 311 +------ src/etch/ecs_bridge.zig | 16 +- src/etch/interp.zig | 973 ++++++++++------------ src/etch/scene_cook.zig | 14 +- tests/etch/hot_reload_test.zig | 250 ++++++ tests/etch_interp/levelb_ir_diff_test.zig | 44 + tools/weld_lint/dead_tests.zig | 4 +- 13 files changed, 1038 insertions(+), 881 deletions(-) diff --git a/src/core/ecs/hybrid_query.zig b/src/core/ecs/hybrid_query.zig index b330a72d..c041af4c 100644 --- a/src/core/ecs/hybrid_query.zig +++ b/src/core/ecs/hybrid_query.zig @@ -431,9 +431,11 @@ pub fn planTableDriven( var inner = try world.queryDynamic(gpa, t_with.items, t_without.items); errdefer inner.deinit(gpa); + const sparse_with = try s_with.toOwnedSlice(gpa); + errdefer gpa.free(sparse_with); return .{ .inner = inner, - .sparse_with = try s_with.toOwnedSlice(gpa), + .sparse_with = sparse_with, .sparse_without = try s_without.toOwnedSlice(gpa), }; } diff --git a/src/core/ecs/registry.zig b/src/core/ecs/registry.zig index 09d03a11..800f0afd 100644 --- a/src/core/ecs/registry.zig +++ b/src/core/ecs/registry.zig @@ -25,6 +25,7 @@ const std = @import("std"); /// Imported only for `FieldKind.fromZigType`; `entity.zig` imports /// nothing of `registry.zig`, so this is acyclic. const EntityId = @import("entity.zig").EntityId; +const persistent = @import("../memory/persistent.zig"); /// Stable identifier assigned at registration. The first registered /// component gets `ComponentId(0)`; subsequent registrations get the next @@ -247,6 +248,72 @@ const Entry = struct { /// Schema identity, derived at registration — beside the descriptor for the /// same reason `closure` is. schema_digest: u64 = 0, + /// Immortal `persistent` blocks that `desc.default_bytes` points into. The + /// entry owns them and destroys them with itself, so every copy of the + /// default bytes — a resource store slot included — stays valid for the + /// registry's lifetime. + owned_blocks: []const [*]u8 = &.{}, +}; + +/// Free every allocation an entry owns. +fn freeEntry(gpa: std.mem.Allocator, e: *Entry) void { + gpa.free(e.desc.name); + gpa.free(e.desc.default_bytes); + for (e.desc.fields) |f| gpa.free(f.name); + gpa.free(e.desc.fields); + for (e.desc.requires) |r| gpa.free(r); + gpa.free(e.desc.requires); + if (e.closure.len != 0) gpa.free(e.closure); + for (e.owned_blocks) |b| persistent.destroy(gpa, b); + if (e.owned_blocks.len != 0) gpa.free(e.owned_blocks); +} + +/// A registration built in full and not yet visible: `Registry.prepareEntry` +/// makes every allocation it needs, so `Registry.commitPrepared` cannot fail. +pub const PreparedEntry = struct { + entry: Entry, + + /// Release an entry that was never committed, its owned blocks included. + pub fn deinit(self: *PreparedEntry, gpa: std.mem.Allocator) void { + freeEntry(gpa, &self.entry); + self.* = undefined; + } + + pub fn name(self: *const PreparedEntry) []const u8 { + return self.entry.desc.name; + } + + pub fn schemaDigest(self: *const PreparedEntry) u64 { + return self.entry.schema_digest; + } + + pub fn fields(self: *const PreparedEntry) []const FieldDesc { + return self.entry.desc.fields; + } + + pub fn defaultBytes(self: *const PreparedEntry) []const u8 { + return self.entry.desc.default_bytes; + } + + pub fn size(self: *const PreparedEntry) u16 { + return self.entry.desc.size; + } + + pub fn alignment(self: *const PreparedEntry) u16 { + return self.entry.desc.alignment; + } +}; + +/// Transitive `@requires` closures computed apart from the registry, one per +/// id, by `Registry.stageClosures`; `Registry.commitClosures` adopts them. +pub const StagedClosures = struct { + closures: [][]const ComponentId, + + pub fn deinit(self: *StagedClosures, gpa: std.mem.Allocator) void { + for (self.closures) |c| if (c.len != 0) gpa.free(c); + gpa.free(self.closures); + self.* = undefined; + } }; /// Runtime registry of component (and resource) type descriptions. @@ -273,15 +340,7 @@ pub const Registry = struct { } pub fn deinit(self: *Registry, gpa: std.mem.Allocator) void { - for (self.entries.items) |*e| { - gpa.free(e.desc.name); - gpa.free(e.desc.default_bytes); - for (e.desc.fields) |f| gpa.free(f.name); - gpa.free(e.desc.fields); - for (e.desc.requires) |r| gpa.free(r); - gpa.free(e.desc.requires); - if (e.closure.len != 0) gpa.free(e.closure); - } + for (self.entries.items) |*e| freeEntry(gpa, e); self.entries.deinit(gpa); self.by_name.deinit(gpa); for (self.aliases.items) |a| gpa.free(a); @@ -292,8 +351,23 @@ pub const Registry = struct { /// Register a component described at runtime. The registry duplicates /// `desc.name`, `desc.default_bytes`, and each `FieldDesc.name`. pub fn registerComponentRaw(self: *Registry, gpa: std.mem.Allocator, desc: ComponentDesc) RegistryError!ComponentId { + var prepared = try self.prepareEntry(gpa, desc, &.{}); + errdefer prepared.deinit(gpa); + try self.reserve(gpa, 1); + return self.commitPrepared(prepared); + } + + /// Copy `desc` into an entry `commitPrepared` can adopt, without touching + /// the registry. On success the entry owns `owned_blocks`, which must be the + /// immortal blocks `desc.default_bytes` points into, allocated with `gpa`; + /// on error the caller keeps them. Refuses a name already registered. + pub fn prepareEntry( + self: *const Registry, + gpa: std.mem.Allocator, + desc: ComponentDesc, + owned_blocks: []const [*]u8, + ) RegistryError!PreparedEntry { if (self.by_name.contains(desc.name)) return RegistryError.DuplicateComponent; - const id: ComponentId = @intCast(self.entries.items.len); const name_owned = try gpa.dupe(u8, desc.name); errdefer gpa.free(name_owned); @@ -325,7 +399,7 @@ pub const Registry = struct { dup_req += 1; } - try self.entries.append(gpa, .{ + return .{ .entry = .{ .desc = .{ .name = name_owned, .size = desc.size, @@ -334,15 +408,36 @@ pub const Registry = struct { .fields = fields_owned, .storage = desc.storage, .requires = requires_owned, + .content_digest = desc.content_digest, }, .schema_digest = schemaDigestOf(desc), - }); - errdefer _ = self.entries.pop(); + .owned_blocks = owned_blocks, + } }; + } + + /// Make room for `n` more entries, so that many `commitPrepared` calls + /// cannot fail. Changes nothing a reader of the registry can observe. + pub fn reserve(self: *Registry, gpa: std.mem.Allocator, n: usize) error{OutOfMemory}!void { + try self.entries.ensureUnusedCapacity(gpa, n); + try self.by_name.ensureUnusedCapacity(gpa, @intCast(n)); + } - try self.by_name.put(gpa, name_owned, id); + /// Adopt `prepared` under the next id and return that id. Cannot fail: + /// requires room from `reserve` and a name registered neither before nor by + /// another entry committed since `prepareEntry`. + pub fn commitPrepared(self: *Registry, prepared: PreparedEntry) ComponentId { + const id: ComponentId = @intCast(self.entries.items.len); + self.entries.appendAssumeCapacity(prepared.entry); + self.by_name.putAssumeCapacityNoClobber(prepared.entry.desc.name, id); return id; } + /// The immortal blocks entry `id` owns, which its default bytes point into. + pub fn ownedBlocks(self: *const Registry, id: ComponentId) []const [*]u8 { + if (id >= self.entries.items.len) return &.{}; + return self.entries.items[id].owned_blocks; + } + /// The schema identity recorded for `id` at registration, or `null` when `id` /// names no entry. A reload compares it with `schemaDigestOf` of the /// CANDIDATE — two values of one computation, never against the RTTI digest. @@ -406,44 +501,94 @@ pub const Registry = struct { /// reduce it to a two-colour visited set: that cannot tell a cycle from a /// diamond (`A requires B, C`; `B requires D`; `C requires D`), and a /// diamond is legal. + /// + /// On error every closure keeps its previous value. pub fn finalizeRequires(self: *Registry, gpa: std.mem.Allocator) !void { - const n = self.entries.items.len; + const staged = try self.stageClosures(gpa, &.{}); + self.commitClosures(gpa, staged); + } + + /// Compute the closure of every committed entry and of every `pending` + /// entry, `pending[i]` standing at id `componentCount() + i` as if already + /// committed in order. Every allocation and every refusal happen here and + /// the registry is left untouched. + pub fn stageClosures( + self: *const Registry, + gpa: std.mem.Allocator, + pending: []const PreparedEntry, + ) error{ OutOfMemory, RequiresCycle, UnknownRequisite }!StagedClosures { + const graph: Graph = .{ .registry = self, .pending = pending }; + const n = graph.count(); + const out = try gpa.alloc([]const ComponentId, n); + for (out) |*c| c.* = &.{}; + var staged: StagedClosures = .{ .closures = out }; + errdefer staged.deinit(gpa); + const colour = try gpa.alloc(Colour, n); defer gpa.free(colour); @memset(colour, .white); - - for (self.entries.items, 0..) |*e, i| { - if (e.closure.len != 0) { - gpa.free(e.closure); - e.closure = &.{}; - } - _ = i; - } for (0..n) |i| { if (colour[i] == .black) continue; - try self.closeOne(gpa, @intCast(i), colour); + try closeOne(gpa, graph, @intCast(i), colour, out); } + return staged; } + /// Adopt `staged` as the closures of every entry, then free the closures it + /// replaces. Cannot fail: requires the pending entries it was staged over to + /// have been committed since, and nothing else. + pub fn commitClosures(self: *Registry, gpa: std.mem.Allocator, staged: StagedClosures) void { + std.debug.assert(staged.closures.len == self.entries.items.len); + for (self.entries.items, staged.closures) |*e, c| { + if (e.closure.len != 0) gpa.free(e.closure); + e.closure = c; + } + gpa.free(staged.closures); + } + + /// The committed entries followed by the pending ones, under one id space. + const Graph = struct { + registry: *const Registry, + pending: []const PreparedEntry, + + fn count(g: Graph) usize { + return g.registry.entries.items.len + g.pending.len; + } + + fn requiresOf(g: Graph, id: ComponentId) []const []const u8 { + const base = g.registry.entries.items.len; + if (id < base) return g.registry.entries.items[id].desc.requires; + return g.pending[id - base].entry.desc.requires; + } + + fn idOf(g: Graph, name: []const u8) ?ComponentId { + if (g.registry.by_name.get(name)) |id| return id; + for (g.pending, 0..) |p, i| { + if (std.mem.eql(u8, p.entry.desc.name, name)) return @intCast(g.registry.entries.items.len + i); + } + return null; + } + }; + /// Sorts the closure ASCENDING by id: the add path applies it in that order, so /// the order must be a pure function of the program and never of the walk. - fn closeOne(self: *Registry, gpa: std.mem.Allocator, id: ComponentId, colour: []Colour) !void { + fn closeOne(gpa: std.mem.Allocator, graph: Graph, id: ComponentId, colour: []Colour, out: [][]const ComponentId) !void { if (colour[id] == .black) return; if (colour[id] == .grey) return error.RequiresCycle; colour[id] = .grey; var acc: std.ArrayListUnmanaged(ComponentId) = .empty; errdefer acc.deinit(gpa); - for (self.entries.items[id].desc.requires) |req_name| { - const req = self.by_name.get(req_name) orelse return error.UnknownRequisite; + for (graph.requiresOf(id)) |req_name| { + const req = graph.idOf(req_name) orelse return error.UnknownRequisite; if (req == id) return error.RequiresCycle; - try self.closeOne(gpa, req, colour); + try closeOne(gpa, graph, req, colour, out); try appendUnique(gpa, &acc, req); - for (self.entries.items[req].closure) |t| try appendUnique(gpa, &acc, t); + for (out[req]) |t| try appendUnique(gpa, &acc, t); } const flat = try acc.toOwnedSlice(gpa); std.mem.sort(ComponentId, flat, {}, std.sort.asc(ComponentId)); - self.entries.items[id].closure = flat; + out[id] = flat; colour[id] = .black; } diff --git a/src/core/ecs/resources.zig b/src/core/ecs/resources.zig index 89b5cd32..365805f0 100644 --- a/src/core/ecs/resources.zig +++ b/src/core/ecs/resources.zig @@ -60,10 +60,33 @@ pub const ResourceStore = struct { /// is `false`; an already-present id returns `error.DuplicateResource`. pub fn addResource(self: *ResourceStore, gpa: std.mem.Allocator, id: ComponentId, init_bytes: []const u8) ResourceError!void { if (self.entries.contains(id)) return ResourceError.DuplicateResource; - const buf = try gpa.alignedAlloc(u8, comptime .fromByteUnits(BufferAlignment), init_bytes.len); + const buf = try allocBuffer(gpa, init_bytes); errdefer gpa.free(buf); + try self.reserve(gpa, 1); + self.adoptAssumeCapacity(id, buf); + } + + /// The buffer type the store holds a resource in. + pub const Buffer = []align(BufferAlignment) u8; + + /// A buffer `adoptAssumeCapacity` accepts, filled with `init_bytes`. The + /// caller owns it until adopted. + pub fn allocBuffer(gpa: std.mem.Allocator, init_bytes: []const u8) error{OutOfMemory}!Buffer { + const buf = try gpa.alignedAlloc(u8, comptime .fromByteUnits(BufferAlignment), init_bytes.len); @memcpy(buf, init_bytes); - try self.entries.put(gpa, id, .{ .bytes = buf, .dirty = false }); + return buf; + } + + /// Make room for `n` more resources, so that many `adoptAssumeCapacity` + /// calls cannot fail. Changes nothing a reader of the store can observe. + pub fn reserve(self: *ResourceStore, gpa: std.mem.Allocator, n: usize) error{OutOfMemory}!void { + try self.entries.ensureUnusedCapacity(gpa, @intCast(n)); + } + + /// Store `buf` as resource `id`, not dirty, taking ownership of it. Cannot + /// fail: requires room from `reserve` and an `id` the store does not hold. + pub fn adoptAssumeCapacity(self: *ResourceStore, id: ComponentId, buf: Buffer) void { + self.entries.putAssumeCapacityNoClobber(id, .{ .bytes = buf, .dirty = false }); } /// Immutable view of the resource bytes. Returns `null` if absent. diff --git a/src/core/ecs/sparse_storage.zig b/src/core/ecs/sparse_storage.zig index 27605bcb..5766c050 100644 --- a/src/core/ecs/sparse_storage.zig +++ b/src/core/ecs/sparse_storage.zig @@ -389,8 +389,7 @@ pub const SparseStores = struct { /// Declare `component_id` sparse. Idempotent: a second call for the same id /// keeps the existing storage, so a hot-reload re-compile does not discard - /// live rows (the treatment `compileResource` already gives a re-registered - /// resource). + /// live rows. pub fn ensure( self: *SparseStores, gpa: std.mem.Allocator, diff --git a/src/core/ecs/world.zig b/src/core/ecs/world.zig index 5d400ded..657780b8 100644 --- a/src/core/ecs/world.zig +++ b/src/core/ecs/world.zig @@ -274,9 +274,6 @@ pub const World = struct { self.archetype_by_signature.deinit(gpa); self.sparse_stores.deinit(gpa); self.entity_locations.deinit(gpa); - // Reclaim resource-owned persistent payloads (strings, collections) - // BEFORE freeing the byte buffers. Idempotent — a no-op - // when an interpreter already ran this in its own deinit. self.releaseResourcePayloads(gpa); self.resources.deinit(gpa); self.singleton_resources.deinit(gpa); @@ -2420,27 +2417,12 @@ pub const World = struct { } /// Decref and zero every resource's persistent-heap payload slot - /// (`.string_` / `.array_` / `.map_` / `.set_`) — the uniform teardown of - /// resource-owned heap blocks. Tier-0 `World` owns this - /// walk so a world with no interpreter (e.g. the scene loader over a bare - /// world) and any resource outside the interpreter's `bridge.resources` - /// still reclaim their blocks. `ResourceStore` stays string-agnostic in - /// write — `resources.deinit` only frees the byte buffers — but `World` - /// owns this decref pass over them. - /// - /// Idempotent: each slot is zeroed (`ptr = 0`) after its decref, so a - /// second call no-ops. This is load-bearing for the interpreter teardown - /// order — `Interpreter.deinit` calls this BEFORE destroying its immortal - /// `persistent_literals`, and the subsequent `World.deinit` call then sees - /// zeroed slots and never re-reads a slot pointing at a freed immortal - /// block (which would be a use-after-free). `persistent.decref` no-ops on a - /// sentinel-refcount immortal default and frees a refcounted user block. - /// - /// Allocation-free (decrefs + in-place slot zeroing only); never fails. - /// Reaches into `resources.entries` directly rather than through a store - /// method: the enumeration is a `World`-level teardown concern, and - /// `ResourceStore` (FROZEN) exposes no all-resources iterator. - pub fn releaseResourcePayloads(self: *World, gpa: std.mem.Allocator) void { + /// (`.string_` / `.array_` / `.map_` / `.set_`), whoever wrote it. Called by + /// `deinit` only, before the store frees the buffers holding the slots and + /// before the registry destroys the immortal blocks a string slot may point + /// at, which `decref` leaves alone. Idempotent: each slot is zeroed after its + /// decref. + fn releaseResourcePayloads(self: *World, gpa: std.mem.Allocator) void { var it = self.resources.entries.iterator(); while (it.next()) |kv| { const rid = kv.key_ptr.*; diff --git a/src/core/testing/alloc_counting.zig b/src/core/testing/alloc_counting.zig index dd1ae1a9..d81229e9 100644 --- a/src/core/testing/alloc_counting.zig +++ b/src/core/testing/alloc_counting.zig @@ -149,3 +149,41 @@ pub const CountingAllocator = struct { _ = self.bytes_freed.fetchAdd(memory.len, .acq_rel); } }; + +/// Fails allocation number `fail_at` and no other, and refuses every resize and +/// remap so that each growth goes through a counted allocation. `failed` records +/// whether the failure was reached: a caller that succeeds with it set swallowed +/// an `OutOfMemory`. +pub const OneShotFailing = struct { + backing: std.mem.Allocator, + fail_at: usize, + count: usize = 0, + failed: bool = false, + + pub fn allocator(self: *OneShotFailing) std.mem.Allocator { + return .{ .ptr = self, .vtable = &.{ .alloc = alloc, .resize = resize, .remap = remap, .free = free } }; + } + + fn alloc(ctx: *anyopaque, len: usize, alignment: std.mem.Alignment, ra: usize) ?[*]u8 { + const self: *OneShotFailing = @ptrCast(@alignCast(ctx)); + defer self.count += 1; + if (self.count == self.fail_at) { + self.failed = true; + return null; + } + return self.backing.rawAlloc(len, alignment, ra); + } + + fn resize(_: *anyopaque, _: []u8, _: std.mem.Alignment, _: usize, _: usize) bool { + return false; + } + + fn remap(_: *anyopaque, _: []u8, _: std.mem.Alignment, _: usize, _: usize) ?[*]u8 { + return null; + } + + fn free(ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, ra: usize) void { + const self: *OneShotFailing = @ptrCast(@alignCast(ctx)); + self.backing.rawFree(memory, alignment, ra); + } +}; diff --git a/src/etch/descriptor.zig b/src/etch/descriptor.zig index 6adb096f..d35ec1bf 100644 --- a/src/etch/descriptor.zig +++ b/src/etch/descriptor.zig @@ -43,14 +43,15 @@ pub const BuildError = error{ /// Owned ordered sequence of Level-B descriptors built from one AST, in /// top-level declaration order ACROSS construct kinds (the engraved -/// canonical-form rule). Every string is gpa-owned (no arena borrows — -/// the descriptors outlive nothing but the interpreter that holds them). +/// canonical-form rule). Every allocation lives in `arena`, which the value +/// owns and releases whole; nothing borrows from the AST. pub const Descriptors = struct { items: []types.Descriptor = &.{}, + arena: ?std.heap.ArenaAllocator = null, pub fn deinit(self: *Descriptors, gpa: std.mem.Allocator) void { - for (self.items) |d| freeDescriptor(gpa, d); - gpa.free(self.items); + _ = gpa; + if (self.arena) |*a| a.deinit(); self.* = .{}; } @@ -64,41 +65,6 @@ pub const Descriptors = struct { } }; -fn freeDescriptor(gpa: std.mem.Allocator, d: types.Descriptor) void { - switch (d) { - .data => |t| freeData(gpa, t), - .routine => |r| freeRoutine(gpa, r), - .behavior => |b| freeBehavior(gpa, b), - .quest => |q| freeQuest(gpa, q), - .dialogue => |dlg| freeDialogue(gpa, dlg), - .ability => |a| freeAbility(gpa, a), - .theme => |t| freeTheme(gpa, t), - .motion => |m| freeMotion(gpa, m), - .input_mapping => |im| freeInputMapping(gpa, im), - .widget => |w| freeWidget(gpa, w), - .locale => |l| freeLocale(gpa, l), - .effect => |e| freeEffect(gpa, e), - .audio_graph => |ag| freeAudioGraph(gpa, ag), - .audio_score => |asc| freeAudioScore(gpa, asc), - .sequence => |seq| freeSequence(gpa, seq), - .anim_graph => |ag| freeAnimGraph(gpa, ag), - .shader => |sh| freeShader(gpa, sh), - .scene => |sc| freeScene(gpa, sc), - .prefab => |pf| freePrefab(gpa, pf), - } -} - -fn freeInputMapping(gpa: std.mem.Allocator, m: types.InputMapping) void { - gpa.free(m.name); - gpa.free(m.context); - gpa.free(m.priority); - gpa.free(m.consume_input); - for (m.actions) |act| freeInputAction(gpa, act); - gpa.free(m.actions); - for (m.combos) |c| freeInputCombo(gpa, c); - gpa.free(m.combos); -} - fn freeInputAction(gpa: std.mem.Allocator, act: types.InputActionDesc) void { gpa.free(act.name); gpa.free(act.type_name); @@ -121,49 +87,6 @@ fn freeInputCombo(gpa: std.mem.Allocator, c: types.InputComboDesc) void { gpa.free(c.window); } -fn freeTheme(gpa: std.mem.Allocator, t: types.Theme) void { - gpa.free(t.name); - for (t.entries) |e| { - gpa.free(e.key); - gpa.free(e.value); - } - gpa.free(t.entries); -} - -fn freeMotion(gpa: std.mem.Allocator, m: types.Motion) void { - gpa.free(m.name); - for (m.states) |st| { - gpa.free(st.name); - for (st.fields) |f| { - gpa.free(f.name); - gpa.free(f.value); - } - gpa.free(st.fields); - } - gpa.free(m.states); - for (m.transitions) |tr| { - gpa.free(tr.source); - gpa.free(tr.target); - gpa.free(tr.animator); - } - gpa.free(m.transitions); -} - -fn freeAbility(gpa: std.mem.Allocator, a: types.Ability) void { - gpa.free(a.name); - for (a.properties) |prop| { - gpa.free(prop.name); - gpa.free(prop.value); - } - gpa.free(a.properties); - gpa.free(a.rule); -} - -fn freeDialogue(gpa: std.mem.Allocator, d: types.Dialogue) void { - gpa.free(d.name); - freeDialogueElements(gpa, d.elements); -} - fn freeDialogueElements(gpa: std.mem.Allocator, elements: []const types.DialogueElementDesc) void { for (elements) |elem| { switch (elem) { @@ -197,17 +120,6 @@ fn freeDialogueElements(gpa: std.mem.Allocator, elements: []const types.Dialogue gpa.free(elements); } -fn freeQuest(gpa: std.mem.Allocator, q: types.Quest) void { - gpa.free(q.name); - for (q.properties) |prop| { - gpa.free(prop.name); - gpa.free(prop.value); - } - gpa.free(q.properties); - for (q.stages) |stage| freeQuestStage(gpa, stage); - gpa.free(q.stages); -} - fn freeQuestStage(gpa: std.mem.Allocator, stage: types.QuestStageDesc) void { gpa.free(stage.name); for (stage.elements) |elem| { @@ -233,11 +145,6 @@ fn freeQuestStage(gpa: std.mem.Allocator, stage: types.QuestStageDesc) void { gpa.free(stage.elements); } -fn freeBehavior(gpa: std.mem.Allocator, b: types.Behavior) void { - gpa.free(b.name); - freeBTNode(gpa, b.root); -} - fn freeBTNode(gpa: std.mem.Allocator, node: types.BehaviorNode) void { gpa.free(node.when); gpa.free(node.payload); @@ -245,49 +152,17 @@ fn freeBTNode(gpa: std.mem.Allocator, node: types.BehaviorNode) void { gpa.free(node.children); } -fn freeRoutine(gpa: std.mem.Allocator, r: types.Routine) void { - gpa.free(r.name); - for (r.segments) |seg| { - gpa.free(seg.name); - for (seg.triggers) |t| gpa.free(t.value); - gpa.free(seg.triggers); - for (seg.actions) |a| gpa.free(a); - gpa.free(seg.actions); - for (seg.untils) |t| gpa.free(t.value); - gpa.free(seg.untils); - } - gpa.free(r.segments); - for (r.interrupts) |intr| { - gpa.free(intr.event); - gpa.free(intr.target); - } - gpa.free(r.interrupts); -} - -fn freeData(gpa: std.mem.Allocator, t: types.Data) void { - gpa.free(t.name); - gpa.free(t.entry_type); - for (t.entries) |e| { - gpa.free(e.id); - for (e.fields) |f| { - gpa.free(f.name); - gpa.free(f.value); - } - gpa.free(e.fields); - } - gpa.free(t.entries); -} - /// Build every descriptor from `arena`, in declaration order — Level B AND the /// Level C scene/prefab arms, which have their own banner below. The /// AST is expected validated (the type-checker ran clean) — `build` does /// not re-validate, it constructs. -pub fn build(gpa: std.mem.Allocator, arena: *const AstArena) BuildError!Descriptors { +pub fn build(backing: std.mem.Allocator, arena: *const AstArena) BuildError!Descriptors { + // Every builder allocates here, so a failure part-way releases everything + // built so far at once. + var owner = std.heap.ArenaAllocator.init(backing); + errdefer owner.deinit(); + const gpa = owner.allocator(); var list: std.ArrayListUnmanaged(types.Descriptor) = .empty; - errdefer { - for (list.items) |d| freeDescriptor(gpa, d); - list.deinit(gpa); - } const kinds = arena.items.items(.kind); const datas = arena.items.items(.data); var i: u28 = 0; @@ -320,7 +195,8 @@ pub fn build(gpa: std.mem.Allocator, arena: *const AstArena) BuildError!Descript else => {}, } } - return .{ .items = try list.toOwnedSlice(gpa) }; + const items = try list.toOwnedSlice(gpa); + return .{ .items = items, .arena = owner }; } fn buildRoutine(gpa: std.mem.Allocator, arena: *const AstArena, decl: ast_mod.RoutineDecl) BuildError!types.Routine { @@ -753,19 +629,6 @@ fn buildInputCombo(gpa: std.mem.Allocator, arena: *const AstArena, combo: ast_mo // ── widget ────────────────────────────────────────────── -fn freeWidget(gpa: std.mem.Allocator, w: types.Widget) void { - gpa.free(w.name); - gpa.free(w.annotations); - gpa.free(w.when); - for (w.params) |p| { - gpa.free(p.name); - gpa.free(p.type_name); - } - gpa.free(w.params); - for (w.tree) |node| freeUiNode(gpa, node); - gpa.free(w.tree); -} - fn freeUiNode(gpa: std.mem.Allocator, node: types.UiNodeDesc) void { gpa.free(node.head); for (node.children) |child| freeUiNode(gpa, child); @@ -945,15 +808,6 @@ pub fn renderUiCallAlloc(gpa: std.mem.Allocator, arena: *const AstArena, call_no // ── locale ────────────────────────────────────────────── -fn freeLocale(gpa: std.mem.Allocator, l: types.Locale) void { - gpa.free(l.name); - for (l.entries) |e| { - gpa.free(e.key); - gpa.free(e.value); - } - gpa.free(l.entries); -} - /// Build a `locale` descriptor: flat `key = value` string entries (the /// `buildTheme` precedent, both sides decoded string-literal content). fn buildLocale(gpa: std.mem.Allocator, arena: *const AstArena, decl: ast_mod.LocaleDecl) BuildError!types.Locale { @@ -1012,31 +866,6 @@ pub fn renderStmtRunAlloc(gpa: std.mem.Allocator, arena: *const AstArena, body_s return try out.toOwnedSlice(gpa); } -fn freeEffect(gpa: std.mem.Allocator, e: types.Effect) void { - gpa.free(e.name); - for (e.params) |p| { - gpa.free(p.name); - gpa.free(p.type_name); - gpa.free(p.default); - } - gpa.free(e.params); - for (e.emitters) |em| { - gpa.free(em.name); - for (em.props) |pr| { - gpa.free(pr.name); - gpa.free(pr.value); - } - gpa.free(em.props); - } - gpa.free(e.emitters); - for (e.handlers) |h| { - gpa.free(h.emitter); - gpa.free(h.event); - gpa.free(h.body); - } - gpa.free(e.handlers); -} - /// Build an `effect` descriptor: optional annotated params, emitters /// of bare `name: value` properties, and `on Emitter.event { body }` handlers. /// All values flow through the shared canonical renderer (byte-identical with @@ -1133,18 +962,6 @@ fn buildEffect(gpa: std.mem.Allocator, arena: *const AstArena, decl: ast_mod.Eff }; } -fn freeAudioGraph(gpa: std.mem.Allocator, ag: types.AudioGraph) void { - gpa.free(ag.name); - for (ag.params) |p| { - gpa.free(p.name); - gpa.free(p.type_name); - gpa.free(p.default); - } - gpa.free(ag.params); - gpa.free(ag.body); - gpa.free(ag.output); -} - /// Build an `audio_graph` descriptor: optional annotated params, the /// DSP statements rendered "; "-joined, and the mandatory output sink — all /// through the shared canonical renderers (byte-identical with the emit side). @@ -1218,24 +1035,6 @@ fn freeScorePropDescs(gpa: std.mem.Allocator, props: []const types.ScorePropDesc gpa.free(props); } -fn freeAudioScore(gpa: std.mem.Allocator, asc: types.AudioScore) void { - gpa.free(asc.name); - freeScorePropDescs(gpa, asc.props); - for (asc.sections) |sec| { - gpa.free(sec.name); - freeScorePropDescs(gpa, sec.props); - for (sec.can_transition_to) |t| gpa.free(t); - gpa.free(sec.can_transition_to); - gpa.free(sec.on_finish); - } - gpa.free(asc.sections); - for (asc.stems) |stem| { - gpa.free(stem.name); - freeScorePropDescs(gpa, stem.fields); - } - gpa.free(asc.stems); -} - /// Build an `audio_score` descriptor: score properties, sections /// (plain props + can_transition_to targets + on_finish), and stems — all /// rendered through the shared canonical renderers (byte-identical with emit). @@ -1346,24 +1145,6 @@ pub fn renderSequenceKeyframeValueAlloc(gpa: std.mem.Allocator, arena: *const As } } -fn freeSequence(gpa: std.mem.Allocator, seq: types.Sequence) void { - gpa.free(seq.name); - freeScorePropDescs(gpa, seq.props); - gpa.free(seq.on_start); - gpa.free(seq.on_finish); - for (seq.tracks) |tr| { - gpa.free(tr.name); - gpa.free(tr.target); - gpa.free(tr.track_type); - for (tr.keyframes) |kf| { - gpa.free(kf.time); - gpa.free(kf.value); - } - gpa.free(tr.keyframes); - } - gpa.free(seq.tracks); -} - /// Build a `sequence` descriptor: properties, on_start/on_finish /// emits, and tracks of keyframes — all through the shared canonical renderers /// (byte-identical with the codegen emit side). @@ -1491,28 +1272,6 @@ fn freeSceneInstanceInner(gpa: std.mem.Allocator, inst: types.SceneInstanceDesc) gpa.free(inst.overrides); } -fn freeScene(gpa: std.mem.Allocator, sc: types.Scene) void { - gpa.free(sc.name); - gpa.free(sc.version); - freeComponentFields(gpa, sc.metadata); - freeComponentInstances(gpa, sc.resources); - freeSceneEntities(gpa, sc.entities); - for (sc.instances) |inst| freeSceneInstanceInner(gpa, inst); - gpa.free(sc.instances); -} - -fn freePrefab(gpa: std.mem.Allocator, pf: types.Prefab) void { - gpa.free(pf.name); - gpa.free(pf.base); - for (pf.requires) |r| gpa.free(r); - gpa.free(pf.requires); - gpa.free(pf.version); - freeComponentFields(gpa, pf.metadata); - freeSceneEntities(gpa, pf.entities); - gpa.free(pf.on_attach); - gpa.free(pf.on_detach); -} - /// Render a `struct_lit_fields` run into `ComponentFieldDesc`s (name + rendered /// value). A spread field (`name == 0`) renders its name as "..". fn buildComponentFields(gpa: std.mem.Allocator, arena: *const AstArena, fields_start: u32, fields_len: u32) BuildError![]types.ComponentFieldDesc { @@ -1786,38 +1545,6 @@ pub fn renderAnimStateBodyAlloc(gpa: std.mem.Allocator, arena: *const AstArena, return try out.toOwnedSlice(gpa); } -fn freeAnimGraph(gpa: std.mem.Allocator, ag: types.AnimGraph) void { - gpa.free(ag.name); - for (ag.params) |p| { - gpa.free(p.name); - gpa.free(p.type_name); - gpa.free(p.default); - } - gpa.free(ag.params); - for (ag.states) |st| { - gpa.free(st.name); - gpa.free(st.body); - for (st.transitions) |tr| { - gpa.free(tr.target); - gpa.free(tr.when); - } - gpa.free(st.transitions); - gpa.free(st.on_finish); - } - gpa.free(ag.states); - for (ag.layers) |ly| { - gpa.free(ly.name); - for (ly.props) |p| { - gpa.free(p.condition); - gpa.free(p.clip); - for (p.bones) |b| gpa.free(b); - gpa.free(p.bones); - } - gpa.free(ly.props); - } - gpa.free(ag.layers); -} - /// Build an `anim_graph` descriptor: params, states (rendered body + /// transitions + on_finish), and layers — all through the shared canonical /// renderers (byte-identical with the codegen emit side). @@ -1989,18 +1716,6 @@ pub fn renderShaderStageAlloc(gpa: std.mem.Allocator, arena: *const AstArena, he return try out.toOwnedSlice(gpa); } -fn freeShader(gpa: std.mem.Allocator, sh: types.Shader) void { - gpa.free(sh.name); - for (sh.params) |p| { - gpa.free(p.name); - gpa.free(p.type_name); - gpa.free(p.default); - } - gpa.free(sh.params); - gpa.free(sh.vertex); - gpa.free(sh.fragment); -} - /// Build a `shader` descriptor: uniforms + the optional vertex + /// mandatory fragment stages, each rendered through the shared /// `renderShaderStageAlloc` (byte-identical with the codegen emit side). diff --git a/src/etch/ecs_bridge.zig b/src/etch/ecs_bridge.zig index 92e91b1c..d20068d7 100644 --- a/src/etch/ecs_bridge.zig +++ b/src/etch/ecs_bridge.zig @@ -96,16 +96,24 @@ pub const Bridge = struct { self.* = undefined; } + /// Map `name` to `id`, replacing an earlier mapping of the same name. pub fn mapComponent(self: *Bridge, gpa: std.mem.Allocator, name: []const u8, id: ComponentId) !void { - const owned = try gpa.dupe(u8, name); - errdefer gpa.free(owned); - try self.components.put(gpa, owned, id); + try mapInto(&self.components, gpa, name, id); } + /// Map `name` to `id`, replacing an earlier mapping of the same name. pub fn mapResource(self: *Bridge, gpa: std.mem.Allocator, name: []const u8, id: ComponentId) !void { + try mapInto(&self.resources, gpa, name, id); + } + + fn mapInto(map: *std.StringHashMapUnmanaged(ComponentId), gpa: std.mem.Allocator, name: []const u8, id: ComponentId) !void { + if (map.getPtr(name)) |v| { + v.* = id; + return; + } const owned = try gpa.dupe(u8, name); errdefer gpa.free(owned); - try self.resources.put(gpa, owned, id); + try map.put(gpa, owned, id); } pub fn componentIdOf(self: *const Bridge, name: []const u8) ?ComponentId { diff --git a/src/etch/interp.zig b/src/etch/interp.zig index 86c37b6c..47f6b517 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -40,6 +40,9 @@ const ComponentId = weld_core.ecs.registry.ComponentId; /// Storage backend recorded per component at registration — `table | sparse`, /// default `table` (`engine-ecs-internals.md` §2). const StorageKind = weld_core.ecs.registry.StorageKind; +const ResourceStore = weld_core.ecs.resources.ResourceStore; +const PreparedEntry = weld_core.ecs.registry.PreparedEntry; +const StagedClosures = weld_core.ecs.registry.StagedClosures; const FieldDesc = weld_core.ecs.registry.FieldDesc; const FieldKind = weld_core.ecs.registry.FieldKind; const DynamicArchetype = weld_core.ecs.archetype_dynamic.DynamicArchetype; @@ -1264,37 +1267,11 @@ pub const Interpreter = struct { /// route here instead of `pending_tags`, so they apply at the NEXT flush — /// never re-entrantly during the current one (the no-recursion contract). observer_deferred: ?*CommandBuffer = null, - /// The world this program was compiled against (`compile`), borrowed for the - /// persistent-string teardown in `deinit`. The interpreter is - /// already lifecycle-coupled to the world (its observer ctxs are registered - /// into the world's `ObserverRegistry`), so storing it here is consistent; - /// the world MUST outlive the interpreter (the existing contract — `deinit` - /// before `world.deinit`). `null` only before `compile` returns. - world: ?*World = null, - /// Immortal persistent-heap blocks holding compile-time `string` field - /// defaults. Allocated in `compileTypeDecl` via `allocImmortal` - /// (sentinel refcount, so slot decref never frees them) and `destroy`'d here - /// at `deinit` — they have no slot-owner to reclaim them, so the interpreter - /// (their allocator) does. Freed AFTER the per-slot decref so an un-overwritten - /// default (slot still points at its immortal block) is reclaimed exactly once. - persistent_literals: std.ArrayListUnmanaged([*]u8) = .empty, - + /// Touches nothing of the world: its registrations, resource payloads and + /// the blocks they point into belong to the world and outlive this + /// interpreter. pub fn deinit(self: *Interpreter) void { - // Uniform resource-payload teardown. The decref walk over every - // resource's `.string_`/`.array_`/`.map_`/`.set_` slots now lives on - // `World.releaseResourcePayloads`; run it here BEFORE destroying the - // immortal `persistent_literals` below. Ordering is load-bearing: the - // walk zeroes each slot after decref, so the later `World.deinit` call - // is a no-op and never re-reads a slot pointing at a freed immortal - // block (a use-after-free). `decref` no-ops on a sentinel (immortal - // default) and frees a refcounted user-written block; the immortal - // defaults are then reclaimed by `destroy` below (their allocator is - // the interpreter). The walk no longer needs `bridge.resources`, so it - // is decoupled from `bridge.deinit`. - if (self.world) |w| w.releaseResourcePayloads(self.gpa); - for (self.persistent_literals.items) |block| persistent.destroy(self.gpa, block); self.event_sources.deinit(self.gpa); - self.persistent_literals.deinit(self.gpa); for (self.rule_descs) |*r| r.deinit(self.gpa); self.gpa.free(self.rule_descs); self.bridge.deinit(self.gpa); @@ -1393,153 +1370,15 @@ pub const Interpreter = struct { var tag_table = try tags_mod.TagTable.build(gpa, ast, &tag_diags, tags_mod.default_max_tags); errdefer tag_table.deinit(gpa); - // Immortal blocks backing compile-time `string` field defaults. Filled - // by `compileTypeDecl`; moved into the returned interpreter, - // which `destroy`s them at `deinit`. On a compile error path they are - // reclaimed here so no default literal leaks. - var persistent_literals: std.ArrayListUnmanaged([*]u8) = .empty; - errdefer { - for (persistent_literals.items) |block| persistent.destroy(gpa, block); - persistent_literals.deinit(gpa); - } - - // Register the collection block drops before any resource - // collection container is created (Pass A) or dropped (`deinit`). - // Idempotent: every interpreter init registers the same callback. + // Register the collection block drops before any collection container + // exists. Idempotent: every compile registers the same callbacks. persistent.registerDrop(persistent.type_array, dropPersistentArray); persistent.registerDrop(persistent.type_map, dropPersistentMap); // A set's payload is the same `ArrayListUnmanaged(Value)` as an array's, // so the array drop (decref string elements + deinit) applies verbatim. persistent.registerDrop(persistent.type_set, dropPersistentArray); - // PRE-VALIDATION, before the first mutation of the world. Pass A below - // registers as it walks, so a refusal raised where it is DETECTED leaves - // every earlier declaration of a rejected program in a live world. The - // confrontation therefore happens here, while the world is still the one - // the previous image left behind, and Pass A runs only once every declared - // schema is known to match. - try verifySchemas(gpa, ast, &world.registry, &tag_table); - - // Pass A — register components and resources with the world. - var i: u28 = 0; - while (i < ast.items.len) : (i += 1) { - const kind = ast.items.items(.kind)[i]; - const data = ast.items.items(.data)[i]; - switch (kind) { - .component_decl => try compileComponent(gpa, ast, world, &bridge, ast.component_decls.items[data], &persistent_literals), - .resource_decl => try compileResource(gpa, ast, world, &bridge, ast.resource_decls.items[data], &persistent_literals), - else => {}, - } - } - - // After Pass A, resolve every `@requires` closure ONCE. - // - // At the END of the pass and not per declaration: a declaration may name - // a component registered later, Etch admitting forward references, which - // is why the descriptor carries NAMES rather than ids. - // - // A cycle or an unknown requisite here is a PROGRAM error the - // type-checker already reports with a span (E0505 / E0506). This arm is - // the registry's own refusal for the population the type-checker never - // sees — components a host registered from Zig — so reaching it from an - // Etch program means the type-check was skipped, and surfacing it as an - // ordinary error is the honest answer rather than a second diagnostic. - try world.registry.finalizeRequires(gpa); - - // Register the builtin `TagSet` component when the program - // declares any tag — a fixed `[words]u64` bitfield, one slot per entity - // carrying tags. It has no named scalar fields; the runtime reads/writes - // its raw bytes as bits. - var tagset_id: ?ComponentId = null; - if (tag_table.leaf_count > 0) { - // ONE DESCRIPTOR FOR BOTH ARMS. The reuse arm used to take the - // existing id with no confrontation while only the fresh arm derived - // the size from `tag_table.words()` — so a reload crossing a 64-tag - // word boundary kept the NARROWER `TagSet` and every entity's tag - // bitfield was silently too small. `etch-validation-ecs.md` §13 names - // this component as the case the size in the digest exists to - // protect. The two arms now cannot disagree about the layout, - // because there is one layout and they read it. - const size: u16 = @intCast(tag_table.words() * 8); - const zeroed = try gpa.alloc(u8, size); - defer gpa.free(zeroed); - @memset(zeroed, 0); - const desc = tagSetDesc(size, zeroed, try tag_table.contentDigest(gpa)); - // Idempotent on a hot-reload re-compile: reuse the - // already-registered `TagSet` instead of erroring DuplicateComponent. - if (world.registry.idOf("TagSet")) |existing| { - const candidate = weld_core.ecs.registry.schemaDigestOf(desc); - // An ABSENT digest refuses. Measured: the registry has exactly - // one entry-append site and it always derives the digest, and - // `existing` came from `idOf` so it is in range — so the null - // arm is unreachable today and its direction costs nothing. It - // is a refusal rather than an accept because an unknown layout - // is not a matching layout, and refusing a reload leaves the - // running session on the program it already has. - const stored = world.registry.schemaDigest(existing) orelse ~candidate; - if (stored != candidate) { - std.log.warn( - "etch/hot-reload: 'TagSet' changed layout or tag identity — reload REFUSED, " ++ - "previous image kept. live: size={d}; new: size={d} ({d} tag(s)). " ++ - "An unchanged size means the tags themselves were renamed or reordered.", - .{ world.registry.componentSize(existing), size, tag_table.leaf_count }, - ); - return error.SchemaChanged; - } - try bridge.mapComponent(gpa, "TagSet", existing); - tagset_id = existing; - } else { - const id = try world.registry.registerComponentRaw(gpa, desc); - try bridge.mapComponent(gpa, "TagSet", id); - tagset_id = id; - } - } - - // Register the three builtin engine time resources from - // the `types.zig` descriptor table — the same injection point as the - // builtin `TagSet` (after the user-decl registration loop). Idempotent - // on a hot-reload re-compile: the existing registration and the live - // resource values survive the AST swap (the `compileResource` seeding - // discipline). - for (&types_mod.builtin_resources) |*br| { - if (world.registry.idOf(br.name)) |existing| { - try bridge.mapResource(gpa, br.name, existing); - continue; - } - var fields_buf: [8]FieldDesc = undefined; - var default_buf: [64]u8 = @splat(0); - var size: usize = 0; - var max_align: usize = 1; - for (br.fields, 0..) |bf, fi| { - const kind: FieldKind = switch (bf.type_) { - .float_ => .float_, - .int_ => .int_, - .bool_ => .bool_, - else => unreachable, // the table holds POD scalars only - }; - const align_b = kind.alignBytes(); - if (align_b > max_align) max_align = align_b; - const off = std.mem.alignForward(usize, size, align_b); - size = off + kind.sizeBytes(); - fields_buf[fi] = .{ .name = bf.name, .offset = @intCast(off), .kind = kind }; - const v: Value = switch (bf.default) { - .float_ => |x| .{ .float_ = x }, - .int_ => |x| .{ .int_ = x }, - .bool_ => |x| .{ .bool_ = x }, - }; - try bridge_mod.writeValueAsBytes(kind, default_buf[off..], v); - } - size = std.mem.alignForward(usize, size, max_align); - const id = try world.registry.registerComponentRaw(gpa, .{ - .name = br.name, - .size = @intCast(size), - .alignment = @intCast(max_align), - .default_bytes = default_buf[0..size], - .fields = fields_buf[0..br.fields.len], - }); - try bridge.mapResource(gpa, br.name, id); - try world.addResource(gpa, id, default_buf[0..size]); - } + const tagset_id = try registerProgramTypes(gpa, ast, world, &bridge, &tag_table); // Resolve the population handles (ids + field offsets) once. The // lookups cannot miss: the resources were registered from the same @@ -1590,13 +1429,13 @@ pub const Interpreter = struct { for (rule_descs.items) |*r| r.deinit(gpa); rule_descs.deinit(gpa); } - i = 0; + var i: u28 = 0; while (i < ast.items.len) : (i += 1) { const kind = ast.items.items(.kind)[i]; const data = ast.items.items(.data)[i]; if (kind != .rule_decl) continue; - const desc = try compileRule(gpa, ast, &bridge, world, &tag_table, tagset_id, data); - try rule_descs.append(gpa, desc); + try rule_descs.ensureUnusedCapacity(gpa, 1); + rule_descs.appendAssumeCapacity(try compileRule(gpa, ast, &bridge, world, &tag_table, tagset_id, data)); } // Pass C — index top-level `fn` declarations by name for free-call @@ -1692,6 +1531,10 @@ pub const Interpreter = struct { } const slice = try rule_descs.toOwnedSlice(gpa); + errdefer { + for (slice) |*r| r.deinit(gpa); + gpa.free(slice); + } // Enable the tick-based change-detection path iff some rule filters by // `changed` — keeps `changed`-free programs free of tick churn. var any_changed = false; @@ -1716,6 +1559,7 @@ pub const Interpreter = struct { @memset(map, null); break :blk map; } else &.{}; + errdefer if (any_async) gpa.free(rule_tasks); // Per-rule `(entity → live task)` maps for entity-bound async rules, parallel // to `rule_descs`. Empty for non-entity-bound rules. const entity_rule_tasks: []std.AutoHashMapUnmanaged(EntityId, u32) = if (any_async) blk: { @@ -1723,6 +1567,7 @@ pub const Interpreter = struct { for (maps) |*m| m.* = .empty; break :blk maps; } else &.{}; + errdefer if (any_async) gpa.free(entity_rule_tasks); // Pass E — build the Level-B descriptors. Fail-loud on any // expression the canonical renderer does not support. @@ -1751,8 +1596,6 @@ pub const Interpreter = struct { .rule_tasks = rule_tasks, .entity_rule_tasks = entity_rule_tasks, .descriptors = descriptors, - .world = world, - .persistent_literals = persistent_literals, }; } @@ -7057,48 +6900,291 @@ pub fn evalConst(ast: *const AstArena, node: NodeId) !Value { // ── Compilation passes ── -fn compileComponent( +/// Every registration one `compile` makes, prepared before the world is touched: +/// `entries[i]` is committed under id `base_id + i`, and `resources[i]` holds its +/// store buffer when it is a resource. +const PendingTypes = struct { + base_id: ComponentId, + entries: std.ArrayListUnmanaged(PreparedEntry) = .empty, + resources: std.ArrayListUnmanaged(?PendingResource) = .empty, + + /// Releases whatever is still owned: everything before `commit`, nothing after. + fn deinit(self: *PendingTypes, gpa: std.mem.Allocator) void { + for (self.entries.items) |*e| e.deinit(gpa); + for (self.resources.items) |*r| { + if (r.*) |*res| res.deinit(gpa); + } + self.entries.deinit(gpa); + self.resources.deinit(gpa); + } + + fn nextId(self: *const PendingTypes) ComponentId { + return self.base_id + @as(ComponentId, @intCast(self.entries.items.len)); + } + + fn idOf(self: *const PendingTypes, name: []const u8) ?ComponentId { + for (self.entries.items, 0..) |*e, i| { + if (std.mem.eql(u8, e.name(), name)) return self.base_id + @as(ComponentId, @intCast(i)); + } + return null; + } + + fn entryOf(self: *PendingTypes, id: ComponentId) *PreparedEntry { + return &self.entries.items[id - self.base_id]; + } + + /// Takes ownership of `entry` and `resource` on success only. + fn push(self: *PendingTypes, gpa: std.mem.Allocator, entry: PreparedEntry, resource: ?PendingResource) !void { + try self.entries.ensureUnusedCapacity(gpa, 1); + try self.resources.ensureUnusedCapacity(gpa, 1); + self.entries.appendAssumeCapacity(entry); + self.resources.appendAssumeCapacity(resource); + } + + fn resourceCount(self: *const PendingTypes) usize { + var n: usize = 0; + for (self.resources.items) |r| { + if (r != null) n += 1; + } + return n; + } + + /// Adopt every entry, resource and closure into `world` and leave `self` + /// empty. Cannot fail: requires room reserved for every entry and resource, + /// and `closures` staged over `entries`. + fn commit(self: *PendingTypes, gpa: std.mem.Allocator, world: *World, closures: StagedClosures) void { + for (self.entries.items, self.resources.items, 0..) |e, r, i| { + const id = world.registry.commitPrepared(e); + std.debug.assert(id == self.base_id + i); + if (r) |res| { + world.resources.adoptAssumeCapacity(id, res.buf); + // A resource with a collection field starts dirty. + if (res.collection_blocks.len != 0) { + world.resources.setDirty(id, true); + gpa.free(res.collection_blocks); + } + } + } + world.registry.commitClosures(gpa, closures); + self.entries.clearRetainingCapacity(); + self.resources.clearRetainingCapacity(); + } +}; + +/// A resource's store buffer — its default bytes, each collection slot pointing +/// at a fresh container — and those containers, released if never committed. +const PendingResource = struct { + buf: ResourceStore.Buffer, + collection_blocks: []const [*]u8, + + fn deinit(self: *PendingResource, gpa: std.mem.Allocator) void { + for (self.collection_blocks) |b| persistent.decref(gpa, b); + if (self.collection_blocks.len != 0) gpa.free(self.collection_blocks); + gpa.free(self.buf); + } +}; + +/// What registration reads of a `component` or `resource` declaration. +const DeclShape = struct { + name: []const u8, + fields_start: u32, + fields_len: u32, + reg_kind: RegKind, + requires: []const []const u8, + storage: StorageKind, +}; + +/// Register every type the program declares, then the builtin `TagSet` and the +/// builtin time resources, and compute every `@requires` closure — or fail +/// having changed nothing in `world`. Returns the `TagSet` id when the program +/// declares a tag. +fn registerProgramTypes( gpa: std.mem.Allocator, ast: *const AstArena, world: *World, bridge: *Bridge, - decl: ast_mod.ComponentDecl, - literals: *std.ArrayListUnmanaged([*]u8), -) !void { - const name = ast.strings.slice(decl.name); - const req = try types_mod.requiresNamesOf(gpa, ast, decl); - defer gpa.free(req); - _ = try compileTypeDecl(gpa, ast, &world.registry, bridge, name, decl.fields_start, decl.fields_len, .component, req, types_mod.storageModeOf(ast, decl), literals); + tag_table: *const tags_mod.TagTable, +) !?ComponentId { + var pending: PendingTypes = .{ .base_id = @intCast(world.registry.componentCount()) }; + defer pending.deinit(gpa); + + var i: u28 = 0; + while (i < ast.items.len) : (i += 1) { + const data = ast.items.items(.data)[i]; + switch (ast.items.items(.kind)[i]) { + .component_decl => { + const decl = ast.component_decls.items[data]; + const req = try types_mod.requiresNamesOf(gpa, ast, decl); + defer gpa.free(req); + try stageDecl(gpa, ast, world, bridge, &pending, .{ + .name = ast.strings.slice(decl.name), + .fields_start = decl.fields_start, + .fields_len = decl.fields_len, + .reg_kind = .component, + .requires = req, + .storage = types_mod.storageModeOf(ast, decl), + }, null); + }, + .resource_decl => { + const decl = ast.resource_decls.items[data]; + // `@storage` and `@requires` apply to `component` only. + try stageDecl(gpa, ast, world, bridge, &pending, .{ + .name = ast.strings.slice(decl.name), + .fields_start = decl.fields_start, + .fields_len = decl.fields_len, + .reg_kind = .resource, + .requires = &.{}, + .storage = .table, + }, decl); + }, + else => {}, + } + } + const tagset_id = try stageTagSet(gpa, world, bridge, &pending, tag_table); + for (&types_mod.builtin_resources) |*br| try stageBuiltinResource(gpa, world, bridge, &pending, br); + + var closures = try world.registry.stageClosures(gpa, pending.entries.items); + errdefer closures.deinit(gpa); + try world.registry.reserve(gpa, pending.entries.items.len); + try world.resources.reserve(gpa, pending.resourceCount()); + pending.commit(gpa, world, closures); + return tagset_id; } -fn compileResource( +/// Stage one declaration: confront it with the registered or already staged type +/// holding its name, or prepare its entry and, for a resource, its store buffer. +fn stageDecl( gpa: std.mem.Allocator, ast: *const AstArena, world: *World, bridge: *Bridge, - decl: ast_mod.ResourceDecl, - literals: *std.ArrayListUnmanaged([*]u8), + pending: *PendingTypes, + shape: DeclShape, + resource_decl: ?ast_mod.ResourceDecl, ) !void { - const name = ast.strings.slice(decl.name); - // On a hot-reload re-compile the resource is already registered - // AND already lives in the resource store with its current value — adding - // it again would reset it to defaults. Seed the store only on first compile. - const pre_existing = world.registry.idOf(name) != null; - // `.table` and not a resolved mode: `@storage` applies to `component` only - // (`annotationAppliesTo`, refused on a resource with `E0502`), so a resource - // has no mode to read. Passing the default here states that rather than - // leaving a reader to infer it from the absence of a call. - // A resource carries no `@requires`: the annotation's applicability is - // validated to `component` only (`types.zig`), so an empty list here is the - // domain's answer and not a shortcut. - const id = try compileTypeDecl(gpa, ast, &world.registry, bridge, name, decl.fields_start, decl.fields_len, .resource, &.{}, .table, literals); - if (!pre_existing) { - const default_bytes = world.registry.componentDefaultBytes(id); - try world.addResource(gpa, id, default_bytes); - // Allocate the resource's collection field containers now - // that the store slot exists. On a hot-reload re-compile (`pre_existing`) - // the resource keeps its live containers, so this runs first-compile only. - try initResourceCollections(gpa, ast, world, id, decl); + if (world.registry.idOf(shape.name)) |existing| { + try confrontLayout(gpa, ast, shape, liveLayoutOf(&world.registry, existing)); + return mapName(gpa, bridge, shape.reg_kind, shape.name, existing); + } + if (pending.idOf(shape.name)) |staged| { + const e = pending.entryOf(staged); + try confrontLayout(gpa, ast, shape, .{ + .digest = e.schemaDigest(), + .size = e.size(), + .alignment = e.alignment(), + .fields_len = e.fields().len, + }); + return mapName(gpa, bridge, shape.reg_kind, shape.name, staged); + } + if (resource_decl != null and world.resources.contains(pending.nextId())) return error.DuplicateResource; + var entry = try prepareTypeEntry(gpa, ast, &world.registry, shape); + errdefer entry.deinit(gpa); + var resource: ?PendingResource = null; + errdefer if (resource) |*r| r.deinit(gpa); + if (resource_decl) |decl| resource = try prepareResourceBuffer(gpa, ast, &entry, decl); + try mapName(gpa, bridge, shape.reg_kind, shape.name, pending.nextId()); + try pending.push(gpa, entry, resource); +} + +/// Stage the builtin `TagSet` when the program declares any tag, confronting the +/// type already holding the name. Returns its id, committed or staged. +fn stageTagSet( + gpa: std.mem.Allocator, + world: *World, + bridge: *Bridge, + pending: *PendingTypes, + tag_table: *const tags_mod.TagTable, +) !?ComponentId { + if (tag_table.leaf_count == 0) return null; + const size: u16 = @intCast(tag_table.words() * 8); + const content_digest = try tag_table.contentDigest(gpa); + const candidate = weld_core.ecs.registry.schemaDigestOf(tagSetDesc(size, &.{}, content_digest)); + + const holder: ?ComponentId = world.registry.idOf("TagSet") orelse pending.idOf("TagSet"); + if (holder) |id| { + const live: LiveLayout = if (id < pending.base_id) liveLayoutOf(&world.registry, id) else blk: { + const e = pending.entryOf(id); + break :blk .{ .digest = e.schemaDigest(), .size = e.size(), .alignment = e.alignment(), .fields_len = e.fields().len }; + }; + // An absent digest refuses: an unknown layout is not a matching one. + if ((live.digest orelse ~candidate) != candidate) { + std.log.warn( + "etch/hot-reload: 'TagSet' changed layout or tag identity — reload REFUSED, " ++ + "previous image kept. live: size={d}; new: size={d} ({d} tag(s)). " ++ + "An unchanged size means the tags themselves were renamed or reordered.", + .{ live.size, size, tag_table.leaf_count }, + ); + return error.SchemaChanged; + } + try bridge.mapComponent(gpa, "TagSet", id); + return id; + } + + const zeroed = try gpa.alloc(u8, size); + defer gpa.free(zeroed); + @memset(zeroed, 0); + var entry = try world.registry.prepareEntry(gpa, tagSetDesc(size, zeroed, content_digest), &.{}); + errdefer entry.deinit(gpa); + const id = pending.nextId(); + try bridge.mapComponent(gpa, "TagSet", id); + try pending.push(gpa, entry, null); + return id; +} + +/// Stage a builtin time resource unless a type already holds its name. +fn stageBuiltinResource( + gpa: std.mem.Allocator, + world: *World, + bridge: *Bridge, + pending: *PendingTypes, + br: *const types_mod.BuiltinResource, +) !void { + if (world.registry.idOf(br.name) orelse pending.idOf(br.name)) |id| { + return bridge.mapResource(gpa, br.name, id); + } + if (world.resources.contains(pending.nextId())) return error.DuplicateResource; + var fields_buf: [8]FieldDesc = undefined; + var default_buf: [64]u8 = @splat(0); + var size: usize = 0; + var max_align: usize = 1; + for (br.fields, 0..) |bf, fi| { + const kind: FieldKind = switch (bf.type_) { + .float_ => .float_, + .int_ => .int_, + .bool_ => .bool_, + else => unreachable, // the table holds POD scalars only + }; + const align_b = kind.alignBytes(); + if (align_b > max_align) max_align = align_b; + const off = std.mem.alignForward(usize, size, align_b); + size = off + kind.sizeBytes(); + fields_buf[fi] = .{ .name = bf.name, .offset = @intCast(off), .kind = kind }; + const v: Value = switch (bf.default) { + .float_ => |x| .{ .float_ = x }, + .int_ => |x| .{ .int_ = x }, + .bool_ => |x| .{ .bool_ = x }, + }; + try bridge_mod.writeValueAsBytes(kind, default_buf[off..], v); + } + size = std.mem.alignForward(usize, size, max_align); + var entry = try world.registry.prepareEntry(gpa, .{ + .name = br.name, + .size = @intCast(size), + .alignment = @intCast(max_align), + .default_bytes = default_buf[0..size], + .fields = fields_buf[0..br.fields.len], + }, &.{}); + errdefer entry.deinit(gpa); + var resource: PendingResource = .{ .buf = try ResourceStore.allocBuffer(gpa, default_buf[0..size]), .collection_blocks = &.{} }; + errdefer resource.deinit(gpa); + try bridge.mapResource(gpa, br.name, pending.nextId()); + try pending.push(gpa, entry, resource); +} + +fn mapName(gpa: std.mem.Allocator, bridge: *Bridge, reg_kind: RegKind, name: []const u8, id: ComponentId) !void { + switch (reg_kind) { + .component => try bridge.mapComponent(gpa, name, id), + .resource => try bridge.mapResource(gpa, name, id), } } @@ -7222,7 +7308,10 @@ fn initArrayBlock(gpa: std.mem.Allocator, ast: *const AstArena, f: ast_mod.Field // Reserve before promoting so a string allocation never dangles on an // append-time OOM (the block's errdefer drops appended elements). try list.ensureUnusedCapacity(gpa, 1); - const ev = constCollectionValue(gpa, ast, en) catch continue; + const ev = constCollectionValue(gpa, ast, en) catch |e| switch (e) { + error.OutOfMemory => return error.OutOfMemory, + else => continue, + }; list.appendAssumeCapacity(ev); } } @@ -7243,9 +7332,13 @@ fn initMapBlock(gpa: std.mem.Allocator, ast: *const AstArena, f: ast_mod.Field) while (e_i < ml.entries_len) : (e_i += 1) { const entry = ast.map_entries.items[ml.entries_start + e_i]; try list.ensureUnusedCapacity(gpa, 1); - const kv = constCollectionValue(gpa, ast, entry.key) catch continue; - const vv = constCollectionValue(gpa, ast, entry.value) catch { + const kv = constCollectionValue(gpa, ast, entry.key) catch |e| switch (e) { + error.OutOfMemory => return error.OutOfMemory, + else => continue, + }; + const vv = constCollectionValue(gpa, ast, entry.value) catch |e| { if (kv == .string_persistent and kv.string_persistent.ptr != 0) persistent.decref(gpa, @ptrFromInt(kv.string_persistent.ptr)); + if (e == error.OutOfMemory) return error.OutOfMemory; continue; }; list.appendAssumeCapacity(.{ .key = kv, .value = vv }); @@ -7254,34 +7347,37 @@ fn initMapBlock(gpa: std.mem.Allocator, ast: *const AstArena, f: ast_mod.Field) return block; } -/// Initialize a resource's collection fields right after the resource is seeded -/// into the store: `.array_` → `type_array`, -/// `.map_` → `type_map`. Each field gets a fresh container written into its -/// `CollectionSlot` (empty or a literal default), so a live collection field's -/// slot is never `ptr == 0` (the read path relies on this). Registry field order -/// matches `decl.fields` 1:1. -fn initResourceCollections(gpa: std.mem.Allocator, ast: *const AstArena, world: *World, id: ComponentId, decl: ast_mod.ResourceDecl) !void { - const fields = world.registry.componentFields(id); - for (fields, 0..) |fd, i| { +/// A resource's store buffer: its default bytes, with every collection slot +/// pointing at a fresh container, empty or holding the field's literal default. +/// Registry field order matches `decl.fields` 1:1. +fn prepareResourceBuffer( + gpa: std.mem.Allocator, + ast: *const AstArena, + entry: *const PreparedEntry, + decl: ast_mod.ResourceDecl, +) !PendingResource { + const buf = try ResourceStore.allocBuffer(gpa, entry.defaultBytes()); + errdefer gpa.free(buf); + var blocks: std.ArrayListUnmanaged([*]u8) = .empty; + errdefer { + for (blocks.items) |b| persistent.decref(gpa, b); + blocks.deinit(gpa); + } + for (entry.fields(), 0..) |fd, i| { + if (fd.kind != .array_ and fd.kind != .map_ and fd.kind != .set_) continue; const f = ast.fields.items[decl.fields_start + i]; + try blocks.ensureUnusedCapacity(gpa, 1); const block: [*]u8 = switch (fd.kind) { .array_ => try initArrayBlock(gpa, ast, f), .map_ => try initMapBlock(gpa, ast, f), - // A set has no literal form (`etch-reference-part1.md` §3.3), so a set - // field always starts empty (a `= Set.new()`/`Set.from(...)` default - // is a non-const call, not materialized here). - .set_ => try allocEmptySetBlock(gpa), - else => continue, + // A set has no literal form, so it always starts empty. + else => try allocEmptySetBlock(gpa), }; - errdefer persistent.decref(gpa, block); - const buf = world.resources.getMutResource(id) orelse { - persistent.decref(gpa, block); - return; - }; - const slot = buf[fd.offset .. fd.offset + @sizeOf(persistent.CollectionSlot)]; + blocks.appendAssumeCapacity(block); const cs = persistent.CollectionSlot{ .ptr = @intFromPtr(block) }; - @memcpy(slot, std.mem.asBytes(&cs)); + @memcpy(buf[fd.offset..][0..@sizeOf(persistent.CollectionSlot)], std.mem.asBytes(&cs)); } + return .{ .buf = buf, .collection_blocks = try blocks.toOwnedSlice(gpa) }; } /// Registration origin threaded into `compileTypeDecl`: `.resource` unlocks the @@ -7289,15 +7385,9 @@ fn initResourceCollections(gpa: std.mem.Allocator, ast: *const AstArena, world: /// `pub` so the scene cook can drive `compileTypeDecl` against its own registry. pub const RegKind = enum { component, resource }; -/// The `TagSet` descriptor, derived in ONE place. Both the pre-pass and the -/// registration arm read it, for the reason `schemaDigestFor` exists: a builtin -/// whose layout is computed twice is a builtin whose two computations can differ, -/// and that difference IS the defect this milestone closed at the reuse arm. -/// -/// `default_bytes` is the caller's, because `registerComponentRaw` stores it; the -/// digest does not read it (see `schemaDigestFor`). -/// -/// `content_digest` is `TagTable.contentDigest` of the table `size` came from. +/// The `TagSet` descriptor, for both the confrontation and the registration: one +/// derivation, so the two cannot differ. `content_digest` must be +/// `TagTable.contentDigest` of the table `size` came from. fn tagSetDesc(size: u16, default_bytes: []const u8, content_digest: u64) weld_core.ecs.registry.ComponentDesc { return .{ .name = "TagSet", @@ -7309,146 +7399,40 @@ fn tagSetDesc(size: u16, default_bytes: []const u8, content_digest: u64) weld_co }; } -/// Confront every schema this program declares against the live registry BEFORE -/// the first registration. -/// -/// **A refusal per declaration is not a refusal.** `compileTypeDecl` refuses a -/// changed layout where it meets it, and Pass A walks declarations in order, so a -/// program whose third type changed left the first two registered in a world that -/// then kept running the PREVIOUS program — components belonging to an image that -/// was rejected, permanently, with nothing announcing them. The `TagSet` arm is -/// worse still: it runs AFTER the whole of Pass A, so a reload that merely crossed -/// a 64-tag word boundary stranded every type the program declares. -/// -/// The requirement a refusal exists to serve is that the previous image survive -/// it. Intact is a property of the WORLD and not of the declaration being -/// examined, so the check belongs where the world is still untouched. -/// -/// WHAT THIS PASS DOES NOT COVER, and it is named rather than implied: an -/// `OutOfMemory` in the middle of Pass A still leaves a half-registration. That is -/// a different failure — exhaustion, not a layout change — with its own remedies, -/// and closing it means a rollback path the registry has never had. This pass -/// makes the SchemaChanged path total; it does not make registration -/// transactional. -/// -/// The builtin time resources are deliberately absent, and the honest reason is -/// narrower than "their descriptor is a constant". It IS one — `types.zig`'s -/// `builtin_resources` — but that says nothing about what is registered under -/// those NAMES, since nothing reserves them. What excludes them is that this pass -/// walks the PROGRAM's declarations and the builtins are not among them: their own -/// registration arm is first-compile-only (`idOf` → map → `continue`), so a reload -/// mutates nothing there and there is no half-registration to prevent. -/// -/// A residual that is NOT this pass's and predates it: a program declaring a -/// `resource GameTime` of its own registers under that name in Pass A, the builtin -/// arm then takes its `continue`, and the `findField(gid, "dt").?` that follows -/// unwraps a field the user's type need not have. That is a missing name -/// reservation, and it fails by panic rather than by diagnostic. -fn verifySchemas( - gpa: std.mem.Allocator, - ast: *const AstArena, - registry: *Registry, - tag_table: *const tags_mod.TagTable, -) !void { - var i: u28 = 0; - while (i < ast.items.len) : (i += 1) { - const kind = ast.items.items(.kind)[i]; - const data = ast.items.items(.data)[i]; - const shape: struct { - name: []const u8, - fields_start: u32, - fields_len: u32, - reg_kind: RegKind, - } = switch (kind) { - .component_decl => blk: { - const decl = ast.component_decls.items[data]; - break :blk .{ - .name = ast.strings.slice(decl.name), - .fields_start = decl.fields_start, - .fields_len = decl.fields_len, - .reg_kind = .component, - }; - }, - .resource_decl => blk: { - const decl = ast.resource_decls.items[data]; - break :blk .{ - .name = ast.strings.slice(decl.name), - .fields_start = decl.fields_start, - .fields_len = decl.fields_len, - .reg_kind = .resource, - }; - }, - else => continue, - }; - - // A name the registry does not hold cannot fail this check: the refusal - // lives in `compileTypeDecl`'s reuse arm and nowhere else. Skipping it is - // not an optimisation, it is the check's domain. - const existing_id = registry.idOf(shape.name) orelse continue; - - var layout = computeLayout(gpa, ast, shape.fields_start, shape.fields_len, shape.reg_kind) catch |e| switch (e) { - // An invalid field type is a PROGRAM error the type-checker reports - // with a span. Letting it through here hands the same diagnosis to - // `compileTypeDecl`, which is where it has always been raised — this - // pass judges layout IDENTITY, never program validity. - error.InvalidProgram => continue, - else => return e, - }; - defer layout.deinit(gpa); +/// The layout recorded for the type already holding a declaration's name. +const LiveLayout = struct { + digest: ?u64, + size: u16, + alignment: u16, + fields_len: usize, +}; - const candidate = schemaDigestFor(shape.name, layout); - if ((registry.schemaDigest(existing_id) orelse ~candidate) != candidate) { - std.log.warn( - "etch/hot-reload: '{s}' changed layout — reload REFUSED, previous image kept. " ++ - "live: size={d} align={d} fields={d}; new: size={d} align={d} fields={d}", - .{ - shape.name, - registry.componentSize(existing_id), - registry.componentAlignment(existing_id), - registry.componentFields(existing_id).len, - layout.size, - layout.alignment, - layout.fields.items.len, - }, - ); - return error.SchemaChanged; - } - } +fn liveLayoutOf(registry: *const Registry, id: ComponentId) LiveLayout { + return .{ + .digest = registry.schemaDigest(id), + .size = registry.componentSize(id), + .alignment = registry.componentAlignment(id), + .fields_len = registry.componentFields(id).len, + }; +} - // `TagSet` LAST among the checks and still BEFORE every mutation, which is the - // whole point: its own registration arm sits after Pass A, so confronting it - // there could never protect the types Pass A had already written. - if (tag_table.leaf_count > 0) { - if (registry.idOf("TagSet")) |existing| { - const size: u16 = @intCast(tag_table.words() * 8); - const candidate = weld_core.ecs.registry.schemaDigestOf( - tagSetDesc(size, &.{}, try tag_table.contentDigest(gpa)), - ); - if ((registry.schemaDigest(existing) orelse ~candidate) != candidate) { - std.log.warn( - "etch/hot-reload: 'TagSet' changed layout or tag identity — reload REFUSED, " ++ - "previous image kept. live: size={d}; new: size={d} ({d} tag(s)). " ++ - "An unchanged size means the tags themselves were renamed or reordered.", - .{ registry.componentSize(existing), size, tag_table.leaf_count }, - ); - return error.SchemaChanged; - } - } - } +/// Refuse `shape` with `error.SchemaChanged` unless its layout has `live`'s +/// digest. An absent digest refuses: an unknown layout is not a matching one. +fn confrontLayout(gpa: std.mem.Allocator, ast: *const AstArena, shape: DeclShape, live: LiveLayout) !void { + var layout = try computeLayout(gpa, ast, shape.fields_start, shape.fields_len, shape.reg_kind); + defer layout.deinit(gpa); + const candidate = schemaDigestFor(shape.name, layout); + if ((live.digest orelse ~candidate) == candidate) return; + std.log.warn( + "etch/hot-reload: '{s}' changed layout — reload REFUSED, previous image kept. " ++ + "live: size={d} align={d} fields={d}; new: size={d} align={d} fields={d}", + .{ shape.name, live.size, live.alignment, live.fields_len, layout.size, layout.alignment, layout.fields.items.len }, + ); + return error.SchemaChanged; } -/// The LAYOUT half of a type declaration: field descriptors, size, alignment. -/// Extracted because it is EXACTLY what a declaration's schema digest reads and -/// nothing more — `Registry.schemaDigestOf` hashes name, size, alignment, each -/// field's (name, kind, offset) and `content_digest`, which no declaration sets, -/// and never `default_bytes`. Materialising the defaults is the other half of -/// `compileTypeDecl`, it allocates immortal persistent blocks, and the digest -/// never looks at them. -/// -/// That split is what makes the pre-validation pass in `Interpreter.compile` cheap -/// and side-effect-free: it can confront every declared schema against the live -/// registry BEFORE the first registration, without materialising one default and -/// without an intermediate to cache for the pass that follows. +/// The LAYOUT half of a type declaration — field descriptors, size, alignment — +/// which is exactly what its schema digest reads: never its default bytes. const Layout = struct { fields: std.ArrayListUnmanaged(FieldDesc) = .empty, size: usize = 0, @@ -7459,10 +7443,9 @@ const Layout = struct { } }; -/// Compute a declaration's layout. Mutates NOTHING outside the returned value — -/// no registry write, no bridge mapping, no persistent allocation — which is the -/// property the pre-pass rests on and the reason this is a function rather than a -/// comment inside `compileTypeDecl`. +/// Compute a declaration's layout. Mutates nothing outside the returned value, +/// so it may run before anything is registered. `error.InvalidProgram` on a +/// field type it cannot place, `error.LayoutTooLarge` past the registry's 64 KiB. fn computeLayout( gpa: std.mem.Allocator, ast: *const AstArena, @@ -7507,6 +7490,7 @@ fn computeLayout( if (align_b > max_align) max_align = align_b; const off = std.mem.alignForward(usize, size, align_b); size = off + kind.sizeBytes(); + if (size > std.math.maxInt(u16)) return error.LayoutTooLarge; try out.fields.append(gpa, .{ .name = ast.strings.slice(f.name), .offset = @intCast(off), @@ -7515,31 +7499,15 @@ fn computeLayout( }); } out.size = std.mem.alignForward(usize, size, max_align); + if (out.size > std.math.maxInt(u16)) return error.LayoutTooLarge; out.alignment = max_align; return out; } -/// The ONE derivation of a declaration's schema digest, read by the registration -/// site and by the pre-pass alike. Two derivations of one quantity is how the two -/// come to disagree, and a pre-pass that disagrees with the site it protects is -/// worse than no pre-pass: it would refuse reloads the site accepts, or wave -/// through the ones it refuses. -/// -/// **It takes a name and a layout, and nothing else, because nothing else a -/// declaration carries is hashed.** `schemaDigestOf` reads the name, the size, the -/// alignment, each field's (name, kind, offset) and `content_digest`, which no -/// declaration sets — measured, and pinned by `registry.zig`'s « the digest is -/// blind to the default bytes », which names this function as its dependent. -/// `default_bytes`, `storage` and `requires` are all absent from it. -/// -/// Taking a `storage` and a `requires` this function cannot use would be a -/// signature declaring an influence it does not have, and it cost the pre-pass an -/// allocation of `@requires` names for a quantity that never reaches the hash. -/// -/// The consequence is NOT hidden by that omission and is not this function's to -/// repair: a reload that changes only a component's `@storage` mode or its -/// `@requires` set produces the same digest and is ACCEPTED. Whether schema -/// identity should cover them belongs to whoever owns `schemaDigestOf`. +/// The ONE derivation of a declaration's schema digest, matching the digest the +/// registry derives at registration. A reload changing only a component's +/// `@storage` mode or its `@requires` set produces the same digest and is +/// accepted. fn schemaDigestFor(name: []const u8, layout: Layout) u64 { return weld_core.ecs.registry.schemaDigestOf(.{ .name = name, @@ -7550,17 +7518,11 @@ fn schemaDigestFor(name: []const u8, layout: Layout) u64 { }); } -/// Register one Etch `component`/`resource` declaration into `registry`, -/// computing its byte layout (`FieldDesc` + size/alignment) and materializing -/// its compile-time default bytes (POD via `evalConst`, resource `string` via -/// an immortal persistent block, resource `enum` via the variant discriminant). -/// Returns the assigned `ComponentId` (or the existing one on a hot-reload -/// re-compile, idempotent). `bridge` records the name→id mapping. -/// -/// Operates on a bare `*Registry` — World-free by construction (it never touches -/// archetypes/entities). The interpreter passes `&world.registry`; the -/// scene cook (`src/etch/scene_cook.zig`) reuses it verbatim against its own -/// standalone `Registry` so registration is shared, not duplicated. +/// Register one Etch `component`/`resource` declaration into `registry` and map +/// it in `bridge`, returning its id. A name already registered is confronted +/// with its layout and mapped, not registered again. Fails having changed +/// neither. Shared with the scene cook, which drives it against its own +/// registry. pub fn compileTypeDecl( gpa: std.mem.Allocator, ast: *const AstArena, @@ -7570,54 +7532,62 @@ pub fn compileTypeDecl( fields_start: u32, fields_len: u32, reg_kind: RegKind, - /// DIRECT `@requires` names, already read from the declaration. Passed - /// RESOLVED for the same reason `storage` is: this function receives no - /// declaration node, so it cannot read an annotation itself, and handing it - /// the names keeps the reading in ONE place shared by both callers. requires: []const []const u8, - /// Storage backend to record in the registry. Passed as a RESOLVED mode and - /// not as the annotation range, deliberately: this function receives no - /// declaration node — it takes `name`, - /// `fields_start`, `fields_len` and `reg_kind` and therefore cannot reach - /// `annotations_extra` at all — and widening it to take the node would give - /// the registry seam a dependency on AST item shape that its three callers - /// do not share. `storageModeOf` is the single resolver they share instead. storage: StorageKind, - literals: *std.ArrayListUnmanaged([*]u8), ) !ComponentId { - var layout = try computeLayout(gpa, ast, fields_start, fields_len, reg_kind); + const shape: DeclShape = .{ + .name = name, + .fields_start = fields_start, + .fields_len = fields_len, + .reg_kind = reg_kind, + .requires = requires, + .storage = storage, + }; + if (registry.idOf(name)) |existing| { + try confrontLayout(gpa, ast, shape, liveLayoutOf(registry, existing)); + try mapName(gpa, bridge, reg_kind, name, existing); + return existing; + } + var entry = try prepareTypeEntry(gpa, ast, registry, shape); + errdefer entry.deinit(gpa); + try registry.reserve(gpa, 1); + try mapName(gpa, bridge, reg_kind, name, @intCast(registry.componentCount())); + return registry.commitPrepared(entry); +} + +/// The registry entry of a declaration not yet registered: its layout, and its +/// default bytes with the immortal blocks of its `string` defaults, which the +/// entry owns. +fn prepareTypeEntry(gpa: std.mem.Allocator, ast: *const AstArena, registry: *const Registry, shape: DeclShape) !PreparedEntry { + var layout = try computeLayout(gpa, ast, shape.fields_start, shape.fields_len, shape.reg_kind); defer layout.deinit(gpa); const fields = layout.fields; - const size = layout.size; - const max_align = layout.alignment; - var f_i: u32 = 0; - var default_buf: []u8 = try gpa.alloc(u8, size); + const default_buf: []u8 = try gpa.alloc(u8, layout.size); defer gpa.free(default_buf); @memset(default_buf, 0); - while (f_i < fields_len) : (f_i += 1) { - const f = ast.fields.items[fields_start + f_i]; + var blocks: std.ArrayListUnmanaged([*]u8) = .empty; + errdefer { + for (blocks.items) |b| persistent.destroy(gpa, b); + blocks.deinit(gpa); + } + var f_i: u32 = 0; + while (f_i < shape.fields_len) : (f_i += 1) { + const f = ast.fields.items[shape.fields_start + f_i]; const fd = fields.items[f_i]; const slot = default_buf[fd.offset .. fd.offset + @as(u16, @intCast(fd.kind.sizeBytes()))]; - // a collection field's container is allocated at `addResource` - // (initResourceCollections), not here: the default slot stays `{ptr=0}` - // (zeroed), overwritten with the real block pointer then. + // A collection slot stays `{ptr=0}`; `prepareResourceBuffer` points the + // store's copy at a container. if (fd.kind == .array_ or fd.kind == .map_ or fd.kind == .set_) continue; if (fd.kind == .string_) { - // Resource `string` default = compile-time literal → an immortal - // interned block (sentinel refcount): `addResource` copies only the - // 16-byte `{ptr,len}` slot, no per-instance allocation. No default ⇒ - // slot stays `{ptr=0,len=0}` (the empty string; `default_buf` is - // zeroed). The block is owned by `literals` and `destroy`'d at the - // interpreter's `deinit`. Non-literal const string defaults are out - // of the surface; they leave the empty-string slot. + // A literal default → an immortal block (sentinel refcount) the entry + // owns, shared by every copy of the default bytes. No default, or a + // non-literal one, leaves the empty string `{ptr=0,len=0}`. if (!f.default_value.isNone() and ast.exprKind(f.default_value) == .string_lit) { const lit = ast.strings.slice(ast.exprData(f.default_value)); + try blocks.ensureUnusedCapacity(gpa, 1); const block = try persistent.allocImmortal(gpa, persistent.type_string, lit.len); - literals.append(gpa, block) catch |e| { - persistent.destroy(gpa, block); - return e; - }; + blocks.appendAssumeCapacity(block); if (lit.len > 0) @memcpy(block[0..lit.len], lit); const ss = persistent.StringSlot{ .ptr = @intFromPtr(block), .len = @intCast(lit.len) }; @memcpy(slot, std.mem.asBytes(&ss)); @@ -7654,53 +7624,20 @@ pub fn compileTypeDecl( try bridge_mod.writeValueAsBytes(fd.kind, slot, v); } - if (registry.idOf(name)) |existing_id| { - // THE LAST LINE OF DEFENCE, not the first. `Interpreter.compile` confronts - // every declared schema before it registers anything, so a hot-reload - // never reaches this arm with a changed layout. This check stays because - // `scene_cook.zig` drives this function against its own registry and does - // NOT go through that pass — and because a refusal that exists only in the - // caller is a refusal the next caller will not have. - // - // An ABSENT digest refuses, for the reason given at the `TagSet` arm. - const candidate = schemaDigestFor(name, layout); - if ((registry.schemaDigest(existing_id) orelse ~candidate) != candidate) { - std.log.warn( - "etch/hot-reload: '{s}' changed layout — reload REFUSED, previous image kept. " ++ - "live: size={d} align={d} fields={d}; new: size={d} align={d} fields={d}", - .{ - name, - registry.componentSize(existing_id), - registry.componentAlignment(existing_id), - registry.componentFields(existing_id).len, - size, - max_align, - fields.items.len, - }, - ); - return error.SchemaChanged; - } - switch (reg_kind) { - .component => try bridge.mapComponent(gpa, name, existing_id), - .resource => try bridge.mapResource(gpa, name, existing_id), - } - return existing_id; + const owned = try blocks.toOwnedSlice(gpa); + errdefer { + for (owned) |b| persistent.destroy(gpa, b); + gpa.free(owned); } - - const id = try registry.registerComponentRaw(gpa, .{ - .name = name, - .size = @intCast(size), - .alignment = @intCast(max_align), + return registry.prepareEntry(gpa, .{ + .name = shape.name, + .size = @intCast(layout.size), + .alignment = @intCast(layout.alignment), .default_bytes = default_buf, .fields = fields.items, - .storage = storage, - .requires = requires, - }); - switch (reg_kind) { - .component => try bridge.mapComponent(gpa, name, id), - .resource => try bridge.mapResource(gpa, name, id), - } - return id; + .storage = shape.storage, + .requires = shape.requires, + }, owned); } fn fieldKindFromTypeName(name: []const u8, reg_kind: RegKind) ?FieldKind { @@ -7847,8 +7784,9 @@ fn dnfFromPool(gpa: std.mem.Allocator, pool: []const PredicateNode, root: u32, n if (cross) return try crossProduct(gpa, lhs, rhs); var out: Dnf = .empty; errdefer freeDnf(gpa, &out); - for (lhs.items) |t| try out.append(gpa, try t.clone(gpa)); - for (rhs.items) |t| try out.append(gpa, try t.clone(gpa)); + try out.ensureTotalCapacity(gpa, lhs.items.len + rhs.items.len); + for (lhs.items) |t| out.appendAssumeCapacity(try t.clone(gpa)); + for (rhs.items) |t| out.appendAssumeCapacity(try t.clone(gpa)); return out; }, } @@ -8018,22 +7956,42 @@ fn compileRule( &.{}; errdefer freeSelection(gpa, selection); + const resource_deps = try res_deps.toOwnedSlice(gpa); + errdefer gpa.free(resource_deps); + const field_filter_slice = try field_filters.toOwnedSlice(gpa); + errdefer gpa.free(field_filter_slice); + const tag_predicates = try tag_preds.toOwnedSlice(gpa); + errdefer { + for (tag_predicates) |*tp| tp.deinit(gpa); + gpa.free(tag_predicates); + } + const changed_filter_slice = try changed_filters.toOwnedSlice(gpa); + errdefer gpa.free(changed_filter_slice); + const expr_filter_slice = try expr_filters.toOwnedSlice(gpa); + errdefer { + for (expr_filter_slice) |ef| gpa.free(ef.fields); + gpa.free(expr_filter_slice); + } + const expr_cond_slice = try expr_conds.toOwnedSlice(gpa); + errdefer gpa.free(expr_cond_slice); + const resource_expr_filter_slice = try resource_expr_filters.toOwnedSlice(gpa); + return .{ .rule_idx = rule_data, .name = rule.name, .selection = selection, - .resource_deps = try res_deps.toOwnedSlice(gpa), - .field_filters = try field_filters.toOwnedSlice(gpa), - .tag_predicates = try tag_preds.toOwnedSlice(gpa), + .resource_deps = resource_deps, + .field_filters = field_filter_slice, + .tag_predicates = tag_predicates, .entity_param_name = entity_param_name, .is_entity_bound = is_entity_bound, .event_type = event_type, .observer_kind = observer_kind, .observer_component = observer_component, - .changed_filters = try changed_filters.toOwnedSlice(gpa), - .expr_filters = try expr_filters.toOwnedSlice(gpa), - .expr_conds = try expr_conds.toOwnedSlice(gpa), - .resource_expr_filters = try resource_expr_filters.toOwnedSlice(gpa), + .changed_filters = changed_filter_slice, + .expr_filters = expr_filter_slice, + .expr_conds = expr_cond_slice, + .resource_expr_filters = resource_expr_filter_slice, .last_run_tick = initial_tick, .is_async = rule.is_async, }; @@ -8189,11 +8147,12 @@ fn lowerWhen(ctx: *LowerWhenCtx, when_idx: u32) error{ OutOfMemory, InvalidProgr // scope, resolver-checked). const tname = ast.strings.slice(node.type_name); const id = ctx.bridge.componentIdOf(tname) orelse return error.InvalidProgram; + try ctx.expr_filters.ensureUnusedCapacity(ctx.gpa, 1); + try ctx.pool.ensureUnusedCapacity(ctx.gpa, 1); const fields = try captureBoundFields(ctx, node.type_name, id, false); - errdefer ctx.gpa.free(fields); - try ctx.expr_filters.append(ctx.gpa, .{ .component_id = id, .expr = node.filter_value, .fields = fields }); + ctx.expr_filters.appendAssumeCapacity(.{ .component_id = id, .expr = node.filter_value, .fields = fields }); const idx: u32 = @intCast(ctx.pool.items.len); - try ctx.pool.append(ctx.gpa, .{ .kind = .has, .component_id = id }); + ctx.pool.appendAssumeCapacity(.{ .kind = .has, .component_id = id }); ctx.has_component_ref.* = true; return idx; }, @@ -8231,7 +8190,8 @@ fn lowerWhen(ctx: *LowerWhenCtx, when_idx: u32) error{ OutOfMemory, InvalidProgr const path_node = ast.tag_operands.items[tf.operand_start + oi]; try resolveTagOperandBits(ctx, path_node, &bits); } - try ctx.tag_preds.append(ctx.gpa, .{ .op = tf.op, .bits = try bits.toOwnedSlice(ctx.gpa) }); + try ctx.tag_preds.ensureUnusedCapacity(ctx.gpa, 1); + ctx.tag_preds.appendAssumeCapacity(.{ .op = tf.op, .bits = try bits.toOwnedSlice(ctx.gpa) }); ctx.has_component_ref.* = true; const positive = switch (tf.op) { .has_tag, .has_any_tag, .has_all_tags => true, @@ -8555,15 +8515,8 @@ test "resource string field is mutable and the previous value is released" { } test "world+interp teardown frees resource strings once" { - // The resource-payload decref walk is owned by Tier-0 - // `World.releaseResourcePayloads`, called from BOTH `Interpreter.deinit` - // (before its immortal `persistent_literals` are destroyed) and - // `World.deinit` (before `resources.deinit`). Slot zeroing makes the second - // call a no-op, so a written resource string is freed exactly once across - // the two-stage teardown. The teardown runs via LIFO defers below — - // `interp.deinit()` first, then `world.deinit(gpa)` — and - // `std.testing.allocator` flags either a leak (missed free) or a - // double-free (both stages freeing the same block). + // `std.testing.allocator` flags a leak or a double free of the written + // resource string across `interp.deinit()` then `world.deinit(gpa)`. const gpa = std.testing.allocator; var world = World.init(); defer world.deinit(gpa); diff --git a/src/etch/scene_cook.zig b/src/etch/scene_cook.zig index df9fc071..d676a95b 100644 --- a/src/etch/scene_cook.zig +++ b/src/etch/scene_cook.zig @@ -312,7 +312,6 @@ const Builder = struct { // Scratch (gpa-owned, freed by `deinitScratch`). bridge: Bridge, - literals: std.ArrayListUnmanaged([*]u8) = .empty, string_map: std.StringHashMapUnmanaged(u32) = .empty, uuid_map: std.AutoHashMapUnmanaged([16]u8, u32) = .empty, name_to_uuid_idx: std.StringHashMapUnmanaged(u32) = .empty, @@ -346,12 +345,10 @@ const Builder = struct { }; } - /// Free everything NOT owned by the produced model: the bridge, the immortal - /// persistent default blocks `compileTypeDecl` allocated, and the scratch - /// hashmaps. The model arena is transferred to the caller (not freed here). + /// Free everything NOT owned by the produced model: the bridge and the + /// scratch hashmaps. The model arena is transferred to the caller (not freed + /// here); the registry owns the default blocks `compileTypeDecl` allocated. fn deinitScratch(self: *Builder) void { - for (self.literals.items) |block| persistent.destroy(self.gpa, block); - self.literals.deinit(self.gpa); self.bridge.deinit(self.gpa); self.string_map.deinit(self.gpa); self.uuid_map.deinit(self.gpa); @@ -409,7 +406,7 @@ const Builder = struct { .resource_decl => { const decl = self.ast.resource_decls.items[datas[i]]; // `.table`: `@storage` is component-only, so a resource has - // no mode to read (mirror of `compileResource`). + // no mode to read. _ = self.registerOne(self.ast.strings.slice(decl.name), decl.fields_start, decl.fields_len, .resource, &.{}, .table, diag_out) catch |e| return e; }, else => {}, @@ -427,8 +424,9 @@ const Builder = struct { storage: weld_core.ecs.registry.StorageKind, diag_out: ?*[]const u8, ) CookError!ComponentId { - return interp.compileTypeDecl(self.gpa, self.ast, self.registry, &self.bridge, name, fields_start, fields_len, reg_kind, requires, storage, &self.literals) catch |e| switch (e) { + return interp.compileTypeDecl(self.gpa, self.ast, self.registry, &self.bridge, name, fields_start, fields_len, reg_kind, requires, storage) catch |e| switch (e) { error.InvalidProgram => fail(diag_out, error.UnsupportedFieldKind, "component/resource field has an unsupported type (only scalars, plus resource string/enum, are cookable)"), + error.LayoutTooLarge => fail(diag_out, error.UnsupportedFieldKind, "component/resource declaration exceeds the registry's 64 KiB"), error.DuplicateComponent => fail(diag_out, error.DuplicateType, "component/resource type declared more than once"), else => error.OutOfMemory, }; diff --git a/tests/etch/hot_reload_test.zig b/tests/etch/hot_reload_test.zig index 45874181..0a074f93 100644 --- a/tests/etch/hot_reload_test.zig +++ b/tests/etch/hot_reload_test.zig @@ -413,3 +413,253 @@ test "a TagSet refusal leaves no half-registered type behind either" { try std.testing.expect(world.registry.idOf("Extra") == null); try std.testing.expectEqual(@as(usize, 8), world.registry.componentSize(world.registry.idOf("TagSet").?)); } + +const OneShotFailing = weld_core.testing.alloc_counting.OneShotFailing; +test "OneShotFailing fails exactly the chosen allocation and none after it" { + const gpa = std.testing.allocator; + var failing: OneShotFailing = .{ .backing = gpa, .fail_at = 1 }; + const a = failing.allocator(); + const first = try a.alloc(u8, 4); + defer a.free(first); + try std.testing.expectError(error.OutOfMemory, a.alloc(u8, 4)); + const third = try a.alloc(u8, 4); + defer a.free(third); + try std.testing.expect(failing.failed); + try std.testing.expectEqual(@as(usize, 3), failing.count); + try std.testing.expect(!a.resize(third, 8)); +} + +// Reaches every world mutation of `compile` (a component with a requisite, a +// resource with a string and three collection fields, `TagSet`, the builtins), +// every rule-lowering path, a sparse selection, and a descriptor built after the +// rules. +const src_registration = + \\tags { + \\ a { t00, t01 } + \\} + \\component Transform { x: int = 0 } + \\@requires(Transform) + \\component Mesh { v: i32 = 0 } + \\component Tag2 { k: int = 0 } + \\@storage(.sparse) + \\component Hot { h: int = 0 } + \\@storage(.sparse) + \\component Cold { c: int = 0 } + \\struct Item { value: int } + \\data Db: Item { a: { value: 1 }, b: { value: 2 } } + \\resource Inventory { n: int = 0, items: string[] = ["a", "b"], label: string = "hi", counts: [string: int] = ["x": 1], seen: Set } + \\rule tick(entity: Entity) + \\ when entity has Transform + \\{ + \\ entity.get_mut(Transform).x += 1 + \\} + \\rule either(entity: Entity) + \\ when entity has Transform or entity has Tag2 + \\{ + \\ entity.get_mut(Transform).x += 1 + \\} + \\rule filtered(entity: Entity) + \\ when entity has Transform { x * 2 < 10 } + \\{ + \\ entity.get_mut(Transform).x += 1 + \\} + \\rule gated(entity: Entity) + \\ when resource Inventory { n < 10 } and entity has Transform + \\{ + \\ entity.get_mut(Transform).x += 1 + \\} + \\rule tagged(entity: Entity) + \\ when entity has_tag .a.t00 and entity has Transform + \\{ + \\ entity.get_mut(Transform).x += 1 + \\} + \\rule sparse(entity: Entity) + \\ when entity has Hot and not entity has Cold + \\{ + \\ entity.get_mut(Hot).h += 1 + \\} + \\async rule waits(entity: Entity) + \\ when entity has Mesh + \\{ + \\ await wait(1.0s) + \\} +; +// A strict subset of `src_registration`, so a reload onto it adds types. +const src_registration_base = + \\tags { + \\ a { t00, t01 } + \\} + \\component Transform { x: int = 0 } + \\rule tick(entity: Entity) + \\ when entity has Transform + \\{ + \\ entity.get_mut(Transform).x += 1 + \\} +; + +const WorldShape = struct { components: usize, resources: u32, closures: u64 }; + +fn worldShape(world: *World) WorldShape { + var h = std.hash.Wyhash.init(0); + const n = world.registry.componentCount(); + for (0..n) |id| { + const c = world.registry.requiresClosure(@intCast(id)); + h.update(std.mem.asBytes(&id)); + h.update(std.mem.asBytes(&c.len)); + h.update(std.mem.sliceAsBytes(c)); + } + return .{ .components = n, .resources = world.resources.entries.count(), .closures = h.final() }; +} + +const Health = enum { healthy, missing_store_entry, null_collection, unowned_string, missing_closure }; + +fn slotWord(bytes: []const u8, offset: u16) u64 { + return std.mem.bytesToValue(u64, bytes[offset..][0..8]); +} + +/// What `src_registration` leaves in a world once compiled. +fn registrationHealth(world: *World) Health { + for ([_][]const u8{ "GameTime", "UnscaledTime", "RealTime", "Inventory" }) |name| { + const id = world.registry.idOf(name) orelse return .missing_store_entry; + if (world.resources.getResource(id) == null) return .missing_store_entry; + } + const inv = world.registry.idOf("Inventory").?; + const bytes = world.resources.getResource(inv).?; + for ([_][]const u8{ "items", "counts", "seen" }) |field| { + if (slotWord(bytes, world.registry.findField(inv, field).?.offset) == 0) return .null_collection; + } + const label = world.registry.findField(inv, "label").?; + const ptr = slotWord(bytes, label.offset); + const len = std.mem.bytesToValue(u32, bytes[label.offset + 8 ..][0..4]); + const owned = for (world.registry.ownedBlocks(inv)) |b| { + if (@intFromPtr(b) == ptr) break true; + } else false; + if (!owned or len != 2) return .unowned_string; + if (!std.mem.eql(u8, @as([*]const u8, @ptrFromInt(ptr))[0..len], "hi")) return .unowned_string; + const mesh = world.registry.idOf("Mesh") orelse return .missing_closure; + if (!world.registry.isRequiredBy(world.registry.idOf("Transform").?, mesh)) return .missing_closure; + return .healthy; +} + +/// Fail every allocation of one `compile` in turn, onto a world `base` left live. +/// Each failure must surface as `OutOfMemory` and leave the world as it found it +/// or exactly as a successful compile would, and a retry on that world must +/// produce a healthy one. Returns the number of allocations swept. +fn sweepCompile(gpa: std.mem.Allocator, base: ?[]const u8, src: []const u8) !usize { + var pr = try weld_etch.parseSource(gpa, src); + defer pr.deinit(gpa); + try typeCheckClean(gpa, &pr.ast); + var base_ast = if (base) |b| try weld_etch.parseSource(gpa, b) else null; + defer if (base_ast) |*p| p.deinit(gpa); + if (base_ast) |*p| try typeCheckClean(gpa, &p.ast); + + // CONTROLS, same apparatus, same execution: a clean compile reads healthy, + // and the same world with a store entry removed does not. + const committed = blk: { + var world = World.init(); + defer world.deinit(gpa); + var live = if (base_ast) |*p| try Interpreter.compile(gpa, &p.ast, &world) else null; + defer if (live) |*l| l.deinit(); + var it = try Interpreter.compile(gpa, &pr.ast, &world); + defer it.deinit(); + try std.testing.expectEqual(Health.healthy, registrationHealth(&world)); + const inv = world.registry.idOf("Inventory").?; + const kept = world.resources.entries.fetchRemove(inv).?; + try std.testing.expectEqual(Health.missing_store_entry, registrationHealth(&world)); + world.resources.entries.putAssumeCapacity(inv, kept.value); + break :blk worldShape(&world); + }; + + var k: usize = 0; + while (true) : (k += 1) { + var world = World.init(); + defer world.deinit(gpa); + var live = if (base_ast) |*p| try Interpreter.compile(gpa, &p.ast, &world) else null; + defer if (live) |*l| l.deinit(); + const before = worldShape(&world); + + var failing: OneShotFailing = .{ .backing = gpa, .fail_at = k }; + if (Interpreter.compile(failing.allocator(), &pr.ast, &world)) |compiled| { + var it = compiled; + it.deinit(); + // Success after an injected failure means an OutOfMemory was swallowed. + try std.testing.expect(!failing.failed); + return k; + } else |err| { + try std.testing.expectEqual(error.OutOfMemory, err); + const after = worldShape(&world); + try std.testing.expect(std.meta.eql(after, before) or std.meta.eql(after, committed)); + } + var retried = try Interpreter.compile(gpa, &pr.ast, &world); + defer retried.deinit(); + try std.testing.expectEqual(Health.healthy, registrationHealth(&world)); + } +} + +test "a compile failing at any allocation leaves the world whole, first compile" { + try std.testing.expect(try sweepCompile(std.testing.allocator, null, src_registration) > 50); +} + +test "a compile failing at any allocation leaves the world whole, reload adding types" { + try std.testing.expect(try sweepCompile(std.testing.allocator, src_registration_base, src_registration) > 50); +} + +test "a compile failing at any allocation leaves the world whole, reload of the same program" { + try std.testing.expect(try sweepCompile(std.testing.allocator, src_registration, src_registration) > 50); +} + +test "compiling B, then tearing A down, leaves B's session its resources" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try weld_etch.parseSource(gpa, src_registration); + defer pr.deinit(gpa); + try typeCheckClean(gpa, &pr.ast); + + var a = try Interpreter.compile(gpa, &pr.ast, &world); + var b = try Interpreter.compile(gpa, &pr.ast, &world); + defer b.deinit(); + a.deinit(); + + try std.testing.expectEqual(Health.healthy, registrationHealth(&world)); + _ = try b.runFor(&world, 1); + try std.testing.expectEqual(Health.healthy, registrationHealth(&world)); +} + +test "an interpreter torn down before the next compile takes no resource with it" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try weld_etch.parseSource(gpa, src_registration); + defer pr.deinit(gpa); + try typeCheckClean(gpa, &pr.ast); + + var a = try Interpreter.compile(gpa, &pr.ast, &world); + a.deinit(); + var b = try Interpreter.compile(gpa, &pr.ast, &world); + defer b.deinit(); + + try std.testing.expectEqual(Health.healthy, registrationHealth(&world)); + _ = try b.runFor(&world, 1); + try std.testing.expectEqual(Health.healthy, registrationHealth(&world)); +} + +test "a declaration past 64 KiB is refused and registers nothing" { + const gpa = std.testing.allocator; + var src: std.ArrayListUnmanaged(u8) = .empty; + defer src.deinit(gpa); + // A declaration staged BEFORE the oversized one, so that a registration + // committed per declaration would leave it behind. + try src.appendSlice(gpa, "component Small { x: int = 0 }\nresource Big { v0: int = 0"); + for (1..8200) |i| try src.print(gpa, ", v{d}: int = 0", .{i}); + try src.appendSlice(gpa, " }\n"); + + var pr = try weld_etch.parseSource(gpa, src.items); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + try typeCheckClean(gpa, &pr.ast); + var world = World.init(); + defer world.deinit(gpa); + try std.testing.expectError(error.LayoutTooLarge, Interpreter.compile(gpa, &pr.ast, &world)); + try std.testing.expectEqual(@as(usize, 0), world.registry.componentCount()); +} diff --git a/tests/etch_interp/levelb_ir_diff_test.zig b/tests/etch_interp/levelb_ir_diff_test.zig index b21e6a34..ff31b186 100644 --- a/tests/etch_interp/levelb_ir_diff_test.zig +++ b/tests/etch_interp/levelb_ir_diff_test.zig @@ -92,3 +92,47 @@ test "level-b serialized IR: interpreter build == cooked emit, byte-identical" { try std.testing.expectEqualStrings(interp_dump.items, cooked_dump.items); } } + +const OneShotFailing = weld_core.testing.alloc_counting.OneShotFailing; + +test "every level-b program: a compile failing at any allocation surfaces OutOfMemory" { + const gpa = std.testing.allocator; + for (programs) |p| { + var pr = try weld_etch.parseSource(gpa, p.source); + defer pr.deinit(gpa); + var diags: std.ArrayListUnmanaged(weld_etch.Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try weld_etch.typeCheck(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + + const committed = blk: { + var world = World.init(); + defer world.deinit(gpa); + var it = try weld_etch.Interpreter.compile(gpa, &pr.ast, &world); + it.deinit(); + break :blk world.registry.componentCount(); + }; + + var k: usize = 0; + while (true) : (k += 1) { + errdefer std.debug.print("level-b program '{s}', allocation {d}\n", .{ p.name, k }); + var world = World.init(); + defer world.deinit(gpa); + var failing: OneShotFailing = .{ .backing = gpa, .fail_at = k }; + if (weld_etch.Interpreter.compile(failing.allocator(), &pr.ast, &world)) |compiled| { + var it = compiled; + it.deinit(); + // Success after an injected failure means an OutOfMemory was swallowed. + try std.testing.expect(!failing.failed); + break; + } else |err| { + try std.testing.expectEqual(error.OutOfMemory, err); + const n = world.registry.componentCount(); + try std.testing.expect(n == 0 or n == committed); + } + } + } +} diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 4810fa65..439f7cce 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -237,8 +237,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2393, - else => 2395, + .windows => 2401, + else => 2403, }; } From 9d2609cfe1e0c6888f05ce6407796220905b1079 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 23 Sep 2026 08:02:41 +0200 Subject: [PATCH 012/141] docs(brief): record M1.D.43 delivered (M1.D/S5/G8) The ownership decision and its motive, the transaction's four steps and what was set aside, the defects closed with it, the counter-factual per form, the outcome counts of the sweep, the behaviour that changed, the bounds left open, and the gates. Floor 2395 -> 2403, windows 2401. Co-Authored-By: Claude Opus 5.5 --- briefs/m1.d-phase-1-debt.md | 239 +++++++++++++++++++++++++++++++++++- 1 file changed, 237 insertions(+), 2 deletions(-) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index a3808492..e079de15 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -4616,7 +4616,7 @@ it created. probe's own control was silent. Neither claim about it is established, and it is written that way rather than resolved on a blind instrument. -### S6/G8 — `M1.D.43` measured: the registration reserves, the ownership does not +### S5/G8 — `M1.D.43` measured: the registration reserves, the ownership does not Measurement only. No line written, because the entry's own condition was met: one part does not reserve. @@ -4717,7 +4717,7 @@ in the first compile and destroys live closures on the plain reload; reservation alone leaves every post-commit failure dangling, 12 of 12. -### S6/G8 addendum — §12's fourth prohibition, applied to what S5 wrote +### S5/G8 addendum — §12's fourth prohibition, applied to what S5 wrote `engine-zig-conventions.md` §12 now carries FOUR flat prohibitions, the new one being *no decision reasoning — why this choice over another, what was refused and @@ -4856,6 +4856,241 @@ pre-S5 decision reasoning — the `Layout` and `schemaDigestFor` docs, the `schemaDigestOf` bullets. The fourth prohibition is newer than their sweep, and this pass was scoped to what S5 wrote. +### S5/G8 — `M1.D.43` delivered: the entry owns the blocks, the registration is one transaction + +Code in `a3b3ea6a`. Guy's GO on the recommendation above, both halves together, and +on six correctifs enumerated at the code before any was written. + +#### The ownership decision + +A registry entry owns the immortal `persistent` blocks its `default_bytes` point +into, and destroys them with itself (`Entry.owned_blocks`, `freeEntry`, +`Registry.ownedBlocks`). The motive is *whoever references, owns*: the default bytes +are copied by value into the resource store and into every chunk a component +lands in, and the only lifetime that bounds every copy is the entry's. Owning the +blocks anywhere shorter than the registry is what produced both measured halves — +the interpreter's `persistent_literals`, destroyed by a failed compile's `errdefer` +while the world still pointed at them, and destroyed again on the success path when +one interpreter was torn down under another. + +What went with it: + +- `Interpreter` loses `persistent_literals`, the payload release in `deinit`, and + the `world` field, whose one reader was that release. `Interpreter.deinit` now + touches nothing of the world. +- `World.releaseResourcePayloads` becomes private and runs in one place, + `World.deinit`, before the store frees the buffers and before the registry + destroys the blocks. It stops being a conditional no-op: there is one owner, and + one place that frees. +- The scene cook's `Builder` loses its `literals` list. It had the same shape, read + at the code and not measured: `deinitScratch` destroyed the blocks that the + returned `Cooked.registry`'s default bytes point into. The cooked registry owns + them now. + +#### Correctifs 1 to 3 — prepare, reserve, commit + +`compile()`'s four world-mutation sites are replaced by one call, +`registerProgramTypes`, in this order: + +1. **Stage, touching no world state.** Each declaration in AST order, then `TagSet`, + then the builtin time resources. A name already held is confronted with the + holder's layout and mapped. The holder is either a committed entry, or an entry + staged earlier in the same batch, which is how duplicates inside one batch are + covered. A resource whose predicted id the store already holds is refused with + `DuplicateResource`: the verification pass found that the no-clobber + precondition of `adoptAssumeCapacity` rested on an invariant `World.addResource` + does not enforce. A new name is prepared completely: `Registry.prepareEntry`, its + owned blocks, and for a resource its store buffer with one fresh container per + collection field. +2. **Stage the closures over the predicted graph.** `Registry.stageClosures` + computes the transitive closures of the committed entries and the pending ones, + `pending[i]` standing at id `componentCount() + i`. `RequiresCycle` and + `UnknownRequisite` are raised there, with the registry untouched. + `finalizeRequires` becomes stage-then-commit, so on error every closure keeps its + previous value. Before, the function freed each live closure before recomputing + any of them. +3. **Reserve.** `Registry.reserve` covers the entries and `by_name`, and + `ResourceStore.reserve` covers the store. +4. **Commit, with no fallible operation.** `commitPrepared` adopts each entry with + its blocks under the predicted id, `adoptAssumeCapacity` adopts each buffer, and + `commitClosures` adopts the closures. + +The `Bridge` is compile-local and is discarded whole on failure, so mapping names at +staging is safe. A second mapping of a name now replaces its value and allocates +nothing (`mapInto`). The old `put` replaced the value too, but leaked the second +duplicated key. + +**Set aside: staging the whole compile.** Passes B to E still run after the commit. +They read the committed registry, allocate only interpreter-owned state, and a +failure among them leaves exactly the registration a successful compile leaves, +which is a valid world. It was measured and not argued: in the probe below, 102 of +216 failure points land there and every one reads healthy after a retry. Carrying +the passes through staging would have moved a correctness boundary that already +holds. + +**`getMutResource`'s dirty mark is kept as it is,** per Guy's sub-decision. The +commit sets `dirty` on a fresh resource with a collection field and on no other. +That reproduces the old side effect, and the verification pass found it unchanged. + +#### Correctif 4 — one place that frees + +`World.deinit`: release payloads, then the store, then the registry. See above. + +#### Correctif 5 — the defects the same instrument found + +Every one of them is closed in `compile()` and its callees: + +- **Seven leak sites.** + - L1 is the append of `compileRule`'s result. + - L2 is `slice` after `toOwnedSlice`. + - L3 and L4 are `rule_tasks` and `entity_rule_tasks`, which had no `errdefer`. + - L5 is the `toOwnedSlice`s of `compileRule`'s return literal. + - L6 is `lowerWhen`'s `bits`. + - L7 is `dnfFromPool`'s cloned terms. +- **The double free.** Both lists the `has_expr_filter` arm appends to are now + reserved before the fields are captured. Nothing can fail once the fields are in + `expr_filters`, and the local `errdefer` that freed them a second time is gone. +- **The swallowed `OutOfMemory`,** in `initArrayBlock` and, found at the code during + the enumeration, in `initMapBlock` for both the key and the value. Exhaustion + propagates, and only a non-constant element is skipped. +- **The `u16` panic.** `computeLayout` returns `error.LayoutTooLarge` once the running + size, or the final aligned size, passes `maxInt(u16)`. The scene cook maps it to `UnsupportedFieldKind` with + its own message: the verification pass found that the generic field-type message + would have named the wrong cause. + +Found by the same instrument once its program reached them, and closed in the same +gate: + +- **`hybrid_query.planTableDriven`**, a `toOwnedSlice` followed by a fallible step. + The sweep reached it only once a sparse component was in the WITHOUT set. It then + measured 1 allocation leaked per sweep, located by trace at `hybrid_query.zig:436`, + where the verification pass had read it. +- **Descriptor building.** The Level-B sweep leaked **453** allocations across the + nineteen programs, at about thirty sites in `descriptor.zig`. The pass had + read seventeen descriptor functions as leaking, and its refuters had failed to + refute the seven they were given. `descriptor.build` now allocates in an + `ArenaAllocator` that the `Descriptors` value owns and releases whole. Set aside: + the fix site by site, of which four were written and counter-factualled (D1–D4 + below). The class is the shape of the whole file, every builder being a + `toOwnedSlice` or an append followed by a fallible step, and the arena closes it + rather than the instances. `freeDescriptor` and the nineteen `free*` it alone + reached became dead, and were removed to a fixed point: −297 lines. `zig fmt` then + changed whitespace only, which was verified. The `free*` still referenced by the + builders' `errdefer`s stay. Under the arena they release at most the last + allocation, which is harmless. + +#### Correctif 6 — the permanent tests + +- `OneShotFailing` (`core/testing/alloc_counting.zig`) fails exactly one chosen + allocation and refuses every resize and remap, so that each growth goes through a + counted allocation. A caller that succeeds after the failure was reached has + swallowed an `OutOfMemory`. The sticky `FailingAllocator` could not show that. Its + own test lives in `hot_reload_test.zig`, because `alloc_counting.zig` is in no + test closure. +- **Three sweeps** of `compile()`: first compile, a reload that adds types, and a + reload of the same program. Each runs over a program that reaches every world + mutation, every rule-lowering path, a sparse selection, and a `data` declaration + built after the rules. Every failure must surface as `OutOfMemory` and leave the + world either as it was or exactly as committed, and a retry on that world must be + healthy. **Both controls run in the same execution**: a clean compile reads + `healthy`, and the same world with a store entry removed reads + `missing_store_entry`. +- **Two teardown orders**: "compile B, destroy A, run B", and "destroy A, then + compile B". +- **The 64 KiB refusal**, with `component Small` staged BEFORE the oversized + resource. The verification pass showed that a lone oversized declaration would + leave `componentCount() == 0` under either design. +- **A sweep of the nineteen Level-B programs** + (`levelb_ir_diff_test.zig`): every failure surfaces `OutOfMemory`, and the + component count is either zero or the committed one. + +Two defects in my own fixtures were caught before any counter-factual ran, both by +reading the parse output. One test program did not type-check. The 64 KiB program +named its fields `f32`…, which parse as a type name. The test now also asserts zero +parse diagnostics. + +#### Counter-factuals — one per form, on the hot-reload step + +| form restored | red | stayed green | +|---|---|---| +| CF1 — the entry takes no blocks (destroyed at prepare) | the 3 sweeps, both teardown tests | 64 KiB, `OneShotFailing` | +| CF2 — closures computed after the commit (empty closures committed, then `finalizeRequires`) | the 3 sweeps | teardown, 64 KiB | +| CF3 — L1's old form | the 3 sweeps, 11 allocations leaked each | the rest | +| CF4 — `catch continue` restored in `initArrayBlock` | first compile, reload adding types | same-program reload — it creates no collection | +| CF5 — `Interpreter.deinit` releases payloads again | both teardown tests | the 3 sweeps | +| CF6 — the double-free form | the 3 sweeps, the allocator logging the double free | the rest | +| CF7 — the 64 KiB bound removed | the 64 KiB test aborts: `integer does not fit in destination type` | — | +| L2, L3, L5, L6, L7 — each old form | the 3 sweeps each | the rest | +| L4 — old form | **none at first**: nothing could fail after it | — | +| L4 — once `data` joined the sweep program | the 3 sweeps | the rest | +| D1–D4 — the four site-by-site descriptor fixes | the 3 sweeps each | superseded by the arena | +| the arena — `HEAD`'s `build` | the Level-B sweep: 453 allocations | — | +| `planTableDriven` — `HEAD`'s form | the 3 sweeps, 1 allocation each | — | + +L4's first result is what the rule of a counter-factual per form exists to catch. +A fix that no test reached would have been recorded as covered. + +#### Measured on the delivered code + +A probe appended to `hot_reload_test.zig` and retired by `git checkout --` counted +each outcome of the sweep: + +| scenario | failure points | world unchanged | world exactly committed | indistinguishable | healthy after retry | +|---|---|---|---|---|---| +| first compile | 216 | 114 | 102 | 0 | 216 | +| reload adding types | 182 | 80 | 102 | 0 | 182 | +| reload, same program | 131 | — | — | 131 | 131 | + +The 102 are the class the measurement above found **12 of 12 dangling**: failures +after the registration has committed. The figures cannot be compared line by line +with the measurement's 92 / 60 / 44, which was taken on a smaller program under the +sticky allocator. + +#### Behaviour that changed, all reported by the verification pass and none guarded against + +- **Which refusal a multiply-faulty reload reports now follows declaration order.** + The removed `verifySchemas` pre-pass raised every `SchemaChanged` before any + layout failure. Now an `InvalidProgram` or `LayoutTooLarge` on an earlier NEW + declaration wins over a later `SchemaChanged`, or over `TagSet`'s, and then no + "reload REFUSED" warning is logged. It is reachable in type-checked programs, a + `Vec3` field being accepted by the checker and refused by `fieldKindFromTypeName`. +- **The defaults of a name already registered are no longer evaluated.** The + registry kept its first defaults before as well. What disappears is the + `TypeMismatch`, and the `@intCast` panics in the `.i32_`/`.u32_` arms, that this + evaluation could raise on a reload. +- **Closures now see `TagSet` and the builtins,** being staged after them. A + component registered FROM ZIG that requires `"TagSet"` or `"GameTime"` used to fail + every first compile with `UnknownRequisite`, and now compiles. Etch cannot reach + this: the checker's `requisiteDecl` accepts local components only. + +#### Bounds, stated and not closed + +- A host registering through `registerComponentRaw` still cannot hand its blocks to + the entry, because that call passes `&.{}`. The gap is pre-existing and nothing in + the tree reaches it. Such a host owns its blocks, and they must outlive the + registry. +- The 64 KiB test's "registers nothing" half holds by construction, since the + refusal is raised at staging before anything is reserved. It is **not** + counter-factualled: no mutant committing per declaration was run. + +#### Gates + +- `zig build test` (Debug, f32): exit 0, + `316/316 steps succeeded; 2384/2403 tests passed (19 skipped)`. +- `zig build test -Doptimize=ReleaseSafe`: exit 0. +- `zig build test -Dphysics_f64=true`: exit 0. +- `zig build lint`: exit 0, + `conservation OK — closure and the declared suite total agree at 2403`, + `dead-tests: clean`. +- `zig fmt --check .`: exit 0, after a `zig fmt` that removed one blank line in + `hot_reload_test.zig`. +- `zig build bindgen-verify`: exit 0. +- `zig build forge-determinism`: exit 0, `divergence frame : none within K=60`. +- `zig build test-codegen-diff`: exit 0. + +**Floor 2395 → 2403, windows 2401**, re-derived from the suite: seven tests in +`hot_reload_test.zig` and one in `levelb_ir_diff_test.zig`. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree From cfb5f8be5728da3b387560a614701ace63d3ab2f Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 23 Sep 2026 09:21:31 +0200 Subject: [PATCH 013/141] fix(ecs): one owner on both registration paths (M1.D/S5/G8 bis) prepareEntry copies every non-empty string default into an immortal block the entry owns, so registerComponentRaw and the Etch compile follow one ownership rule and a caller keeps all it passed. An empty default is written {0,0} on every path. A collection default that names a container is refused (CollectionDefaultNotEmpty). Five scene tests released a resource string by hand before World.deinit released it again: five decrefs on freed memory, now gone. vk-gen-check depends on the bindgen-verify step itself instead of a lookup made before that step existed. Every TagSet name in the interpreter and the codegen derives from tagset_component_name; the emitted source of the five tagged programs is byte-identical. The parser cites the annotation_args production of etch-grammar.md 1.5. Locking tests: the refusal order of a doubly-faulty reload, no default evaluated on reload, closures that see TagSet and the builtins, every injected name reserved, and the two raw-path ownership properties. Floor 2403 -> 2411, windows 2409. Co-Authored-By: Claude Opus 5.5 --- build.zig | 10 +- src/core/ecs/registry.zig | 166 +++++++++++++++++++++------- src/core/ecs/world.zig | 4 +- src/core/memory/persistent.zig | 30 ++--- src/core/scene/loader.zig | 25 +---- src/etch/ecs_bridge.zig | 6 +- src/etch/interp.zig | 48 +++----- src/etch/parser.zig | 5 +- src/etch/scene_cook.zig | 2 +- src/etch/types.zig | 4 +- src/etch/zig_codegen/lower.zig | 31 +++--- tests/etch/hot_reload_test.zig | 124 ++++++++++++++++++++- tests/scene/load_resources_test.zig | 8 +- tools/weld_lint/dead_tests.zig | 4 +- 14 files changed, 317 insertions(+), 150 deletions(-) diff --git a/build.zig b/build.zig index 5418d42c..a9492f40 100644 --- a/build.zig +++ b/build.zig @@ -413,14 +413,6 @@ pub fn build(b: *std.Build) void { const shaders_check_step = b.step("shaders-check", "Verify .spv on disk matches a fresh glslc regen"); shaders_check_step.dependOn(&shaders_check_run.step); - // `zig build vk-gen-check`: regenerates vk.zig and verifies that - // the diff vs the commit is empty. Delegated to the existing `bindgen-verify` - // which covers all generated bindings. - const vk_gen_check_step = b.step("vk-gen-check", "Verify vk.zig matches a fresh bindgen regen (delegates to bindgen-verify)"); - if (b.top_level_steps.get("bindgen-verify")) |bv| { - vk_gen_check_step.dependOn(&bv.step); - } - // -------------------------------------------------------------- Tests -- const test_step = b.step("test", "Run all tests"); @@ -2456,6 +2448,8 @@ pub fn build(b: *std.Build) void { "Regenerate bindings + assert `git diff --quiet HEAD` on the four generated files", ); bindgen_verify_step.dependOn(&bindgen_verify_diff.step); + const vk_gen_check_step = b.step("vk-gen-check", "Verify vk.zig matches a fresh bindgen regen (delegates to bindgen-verify)"); + vk_gen_check_step.dependOn(bindgen_verify_step); // -------------------------------------- weld_lint (custom linter) -- // diff --git a/src/core/ecs/registry.zig b/src/core/ecs/registry.zig index 800f0afd..7edb8088 100644 --- a/src/core/ecs/registry.zig +++ b/src/core/ecs/registry.zig @@ -5,14 +5,14 @@ //! metadata for the rest of the ECS (dynamic archetype storage, runtime //! queries, the Etch bridge) to operate on raw bytes. //! -//! Two registration paths share the same backing storage: +//! Every registration becomes an entry through `prepareEntry`: //! //! - `registerComponent(gpa, comptime T) ComponentId` — for types known at //! Zig compile time. The descriptor is derived from `@typeInfo(T)`. -//! - `registerComponentRaw(gpa, desc) ComponentId` — for types discovered -//! at runtime (the Etch bridge consumes this path from the parsed AST: -//! component names, field names, default bytes come from the source -//! file). +//! - `registerComponentRaw(gpa, desc) ComponentId` — for one type described +//! at runtime. +//! - `prepareEntry`, `reserve`, `commitPrepared` — for a batch that must be +//! registered whole or not at all, as an Etch compile registers its types. //! //! Coexists with the comptime `(Transform, Velocity)` archetype defined //! in `world.zig` — additive, never replaces it. The struct stores no @@ -71,12 +71,13 @@ pub const FieldKind = enum { f64_, /// A `string` field slot: `{ ptr: u64, len: u32 }` (16 bytes, 8-aligned) /// pointing into the Tier-0 persistent heap (`src/core/memory/persistent.zig`, - /// `StringSlot`). **Resource-only by construction**: the Etch - /// validator rejects `string` on `component` and `fieldKindFromTypeName` - /// only emits this kind for the `.resource` origin, so no component can ever - /// carry it — the component SoA/POD invariant (`ARCH-004`) is - /// untouched. Tier-0 stays string-agnostic: it stores/copies the 16 raw - /// slot bytes; the Etch runtime owns the pointed-to bytes' lifetime. + /// `StringSlot`). **Resource-only in Etch**: the Etch validator rejects + /// `string` on `component` and `fieldKindFromTypeName` only emits this kind + /// for the `.resource` origin, so no Etch component carries it — the + /// component SoA/POD invariant (`ARCH-004`) is untouched. The string behind + /// a DEFAULT slot is copied at registration into a block the registry entry + /// owns; the one behind a live slot is its writer's, released through its + /// refcount. string_, /// An enum field slot: the variant's declaration-order index as a `u32` /// discriminant (4 bytes, 4-aligned). POD — no persistent heap, no decref, @@ -96,8 +97,10 @@ pub const FieldKind = enum { entity_, /// A dynamic-array field slot (`T[]`): a `CollectionSlot` (`{ ptr: u64 }`, /// 8 bytes, 8-aligned) holding the persistent-heap pointer of the owned - /// container block. **Resource-only by construction** like `.string_`. Tier 0 - /// copies the 8 raw slot bytes; the Etch runtime owns the container. + /// container block. **Resource-only in Etch** like `.string_`. Tier 0 copies + /// the 8 raw slot bytes, and `World.deinit` releases the container through + /// its refcount. A DEFAULT slot names no container: `prepareEntry` refuses + /// one that does. array_, /// A map field slot (`[K: V]`). Same 8-byte `CollectionSlot` /// discipline and resource-only gating as `.array_`. @@ -232,11 +235,14 @@ pub fn schemaDigestOf(desc: ComponentDesc) u64 { /// and `registerAlias`; lookup paths never fail (return `?T`). pub const RegistryError = error{ DuplicateComponent, + /// A collection field's default slot is not zero. + CollectionDefaultNotEmpty, OutOfMemory, }; -/// One owned entry. `name`, `default_bytes`, and `fields` are duplicated -/// at registration time so the caller can free its inputs immediately. +/// One owned entry. Every input of its descriptor is copied at registration, +/// the strings its default bytes point at included, so the caller can free its +/// inputs immediately. const Entry = struct { desc: ComponentDesc, /// The TRANSITIVE closure of `desc.requires`, flattened to ids, computed @@ -248,13 +254,52 @@ const Entry = struct { /// Schema identity, derived at registration — beside the descriptor for the /// same reason `closure` is. schema_digest: u64 = 0, - /// Immortal `persistent` blocks that `desc.default_bytes` points into. The - /// entry owns them and destroys them with itself, so every copy of the - /// default bytes — a resource store slot included — stays valid for the - /// registry's lifetime. + /// The immortal `persistent` copies of the strings `desc.default_bytes` + /// points at. Destroyed with the entry, so every copy of the default bytes + /// — a resource store slot included — stays valid for the registry's + /// lifetime. owned_blocks: []const [*]u8 = &.{}, }; +/// Point every non-empty `.string_` slot of `bytes` at an immortal copy of its +/// string and write every empty one as `{ptr=0,len=0}`, returning the copies. On +/// error `bytes` may be partly rewritten and no copy survives. +fn copyStringDefaults(gpa: std.mem.Allocator, fields: []const FieldDesc, bytes: []u8) error{OutOfMemory}![]const [*]u8 { + const Slot = persistent.StringSlot; + var n: usize = 0; + for (fields) |f| { + if (f.kind != .string_) continue; + const slot = bytes[f.offset..][0..@sizeOf(Slot)]; + const ss = std.mem.bytesToValue(Slot, slot); + if (ss.ptr != 0 and ss.len != 0) { + n += 1; + } else { + @memcpy(slot[@offsetOf(Slot, "ptr")..][0..@sizeOf(u64)], std.mem.asBytes(&@as(u64, 0))); + @memcpy(slot[@offsetOf(Slot, "len")..][0..@sizeOf(u32)], std.mem.asBytes(&@as(u32, 0))); + } + } + if (n == 0) return &.{}; + const blocks = try gpa.alloc([*]u8, n); + var made: usize = 0; + errdefer { + for (blocks[0..made]) |b| persistent.destroy(gpa, b); + gpa.free(blocks); + } + for (fields) |f| { + if (f.kind != .string_) continue; + const slot = bytes[f.offset..][0..@sizeOf(Slot)]; + const ss = std.mem.bytesToValue(Slot, slot); + if (ss.ptr == 0) continue; + const block = try persistent.allocImmortal(gpa, persistent.type_string, ss.len); + @memcpy(block[0..ss.len], @as([*]const u8, @ptrFromInt(ss.ptr))[0..ss.len]); + blocks[made] = block; + made += 1; + const ptr: u64 = @intFromPtr(block); + @memcpy(slot[@offsetOf(Slot, "ptr")..][0..@sizeOf(u64)], std.mem.asBytes(&ptr)); + } + return blocks; +} + /// Free every allocation an entry owns. fn freeEntry(gpa: std.mem.Allocator, e: *Entry) void { gpa.free(e.desc.name); @@ -348,26 +393,28 @@ pub const Registry = struct { self.* = undefined; } - /// Register a component described at runtime. The registry duplicates - /// `desc.name`, `desc.default_bytes`, and each `FieldDesc.name`. + /// Register a component described at runtime, on `prepareEntry`'s terms. pub fn registerComponentRaw(self: *Registry, gpa: std.mem.Allocator, desc: ComponentDesc) RegistryError!ComponentId { - var prepared = try self.prepareEntry(gpa, desc, &.{}); + var prepared = try self.prepareEntry(gpa, desc); errdefer prepared.deinit(gpa); try self.reserve(gpa, 1); return self.commitPrepared(prepared); } /// Copy `desc` into an entry `commitPrepared` can adopt, without touching - /// the registry. On success the entry owns `owned_blocks`, which must be the - /// immortal blocks `desc.default_bytes` points into, allocated with `gpa`; - /// on error the caller keeps them. Refuses a name already registered. - pub fn prepareEntry( - self: *const Registry, - gpa: std.mem.Allocator, - desc: ComponentDesc, - owned_blocks: []const [*]u8, - ) RegistryError!PreparedEntry { + /// the registry. The entry copies every input, and a non-zero `.string_` + /// default slot names `len` readable bytes at `ptr`, which it copies into a + /// block of its own: the caller keeps all it passed. Refuses a name already + /// registered, and a collection field whose default slot is not zero. + pub fn prepareEntry(self: *const Registry, gpa: std.mem.Allocator, desc: ComponentDesc) RegistryError!PreparedEntry { if (self.by_name.contains(desc.name)) return RegistryError.DuplicateComponent; + for (desc.fields) |f| switch (f.kind) { + .array_, .map_, .set_ => { + const slot = desc.default_bytes[f.offset..][0..@sizeOf(persistent.CollectionSlot)]; + if (std.mem.bytesToValue(persistent.CollectionSlot, slot).ptr != 0) return RegistryError.CollectionDefaultNotEmpty; + }, + else => {}, + }; const name_owned = try gpa.dupe(u8, desc.name); errdefer gpa.free(name_owned); @@ -375,6 +422,12 @@ pub const Registry = struct { const default_owned = try gpa.dupe(u8, desc.default_bytes); errdefer gpa.free(default_owned); + const blocks_owned = try copyStringDefaults(gpa, desc.fields, default_owned); + errdefer { + for (blocks_owned) |b| persistent.destroy(gpa, b); + if (blocks_owned.len != 0) gpa.free(blocks_owned); + } + const fields_owned = try gpa.alloc(FieldDesc, desc.fields.len); errdefer gpa.free(fields_owned); var dup_count: usize = 0; @@ -411,7 +464,7 @@ pub const Registry = struct { .content_digest = desc.content_digest, }, .schema_digest = schemaDigestOf(desc), - .owned_blocks = owned_blocks, + .owned_blocks = blocks_owned, } }; } @@ -689,15 +742,8 @@ pub const Registry = struct { }; test "the digest is blind to the default bytes" { - // A DEPENDENT RESTS ON THIS. `interp.schemaDigestFor` passes `&.{}` for - // `default_bytes` so the hot-reload pre-validation pass can confront every - // declared schema WITHOUT materialising a single default — materialising them - // allocates immortal persistent blocks, which a pass that may refuse must not - // do. That shortcut is only sound while this property holds. - // - // If a future change makes the digest read the defaults, this test fires and - // names where to go: `schemaDigestFor` must then be given the real bytes, and - // the pre-pass must materialise them and own their rollback. + // `interp.schemaDigestFor` passes `&.{}` for `default_bytes`, so a reload + // confronts a declaration without evaluating its defaults. const fields = [_]FieldDesc{.{ .name = "v", .offset = 0, .kind = .int_ }}; const a: ComponentDesc = .{ .name = "T", @@ -842,3 +888,41 @@ test "registerComponentRaw and findField roundtrip" { try std.testing.expectEqual(FieldKind.float_, f.kind); try std.testing.expect(reg.findField(id, "missing") == null); } + +test "registerComponentRaw copies a string default, so the caller may free its own" { + const gpa = std.testing.allocator; + var reg = Registry.init(); + defer reg.deinit(gpa); + + const host = try gpa.dupe(u8, "hello"); + var default_bytes = [_]u8{0} ** 16; + @memcpy(default_bytes[0..8], std.mem.asBytes(&@as(u64, @intFromPtr(host.ptr)))); + @memcpy(default_bytes[8..12], std.mem.asBytes(&@as(u32, @intCast(host.len)))); + const id = try reg.registerComponentRaw(gpa, .{ + .name = "Titled", + .size = 16, + .alignment = 8, + .default_bytes = &default_bytes, + .fields = &[_]FieldDesc{.{ .name = "title", .offset = 0, .kind = .string_ }}, + }); + @memset(host, 'x'); + gpa.free(host); + + const kept = std.mem.bytesToValue(persistent.StringSlot, reg.componentDefaultBytes(id)[0..16]); + try std.testing.expectEqualStrings("hello", @as([*]const u8, @ptrFromInt(kept.ptr))[0..kept.len]); +} + +test "registerComponentRaw refuses a collection default that names a container" { + const gpa = std.testing.allocator; + var reg = Registry.init(); + defer reg.deinit(gpa); + + const default_bytes = std.mem.toBytes(@as(u64, 0x1000)); + try std.testing.expectError(RegistryError.CollectionDefaultNotEmpty, reg.registerComponentRaw(gpa, .{ + .name = "Listed", + .size = 8, + .alignment = 8, + .default_bytes = &default_bytes, + .fields = &[_]FieldDesc{.{ .name = "xs", .offset = 0, .kind = .array_ }}, + })); +} diff --git a/src/core/ecs/world.zig b/src/core/ecs/world.zig index 657780b8..639379e8 100644 --- a/src/core/ecs/world.zig +++ b/src/core/ecs/world.zig @@ -2417,8 +2417,8 @@ pub const World = struct { } /// Decref and zero every resource's persistent-heap payload slot - /// (`.string_` / `.array_` / `.map_` / `.set_`), whoever wrote it. Called by - /// `deinit` only, before the store frees the buffers holding the slots and + /// (`.string_` / `.array_` / `.map_` / `.set_`), whoever wrote it. Run by + /// `deinit`, before the store frees the buffers holding the slots and /// before the registry destroys the immortal blocks a string slot may point /// at, which `decref` leaves alone. Idempotent: each slot is zeroed after its /// decref. diff --git a/src/core/memory/persistent.zig b/src/core/memory/persistent.zig index 08325566..63265a2e 100644 --- a/src/core/memory/persistent.zig +++ b/src/core/memory/persistent.zig @@ -7,9 +7,8 @@ //! at init) and the open `TypeId` set are exactly what `string[]` / `[K: V]` / //! `Set` register against, with no Etch coupling in this module. //! -//! Tier 0, and the heap is tier-neutral ( -//! `runDrop` is a no-op, no Etch coupling — and resource `string` fields are a -//! Tier-0 capability). API + on-storage layout unchanged. +//! Tier 0 and tier-neutral: a container's drop is a callback the Etch runtime +//! registers (`registerDrop`), so this module imports nothing of Etch. //! //! Layout (`etch-memory-model.md` §4.3 / §5.1). Each block is one system //! allocation laid out as: @@ -35,14 +34,16 @@ //! `@fence`-free idiom — `@fence` was removed in Zig 0.16, cf. //! `src/core/jobs/deque.zig`) followed by the type's drop + the block free. //! A block allocated immortal carries `refcount == sentinel` (`u32.max`): -//! `incref` / `decref` are no-ops on it — compile-time string literals (resource -//! field defaults) use this path so `addResource` allocates nothing. +//! `incref` / `decref` are no-ops on it — the registry's copies of string +//! defaults use this path, so a resource slot seeded from its default holds a +//! block no `decref` frees. //! //! Self-contained: imports only `std` (no other `src/core` coupling), so it sits -//! cleanly at Tier 0. Consumers are the scene loader and the Etch runtime -//! (interp / bridge / cook, which reach it through `weld_core.memory`); the -//! Tier-0 `ResourceStore` itself stays string-agnostic (it stores the raw -//! `StringSlot` bytes). +//! cleanly at Tier 0. Consumers are the registry (its string-default copies), +//! the world (the resource payload release), the scene loader and the Etch +//! runtime (interp / bridge / cook, through `weld_core.memory`); the Tier-0 +//! `ResourceStore` itself stays string-agnostic (it stores the raw `StringSlot` +//! bytes). const std = @import("std"); @@ -99,11 +100,12 @@ comptime { /// `Set`): a single `{ ptr }` (8 bytes, 8-aligned) holding the /// persistent block pointer of the owned container (a `type_array` / `type_map` /// / `type_set` block). Unlike `StringSlot`, `ptr` is never `0` for a live -/// field: an empty collection is a real (empty) container block allocated at -/// `addResource`, so a read always finds a valid container. The block pointer -/// is stable across the container's internal realloc (the buffer moves inside -/// the container, not the block). `Registry.FieldKind.{array_,map_,set_}` report -/// `sizeBytes == 8` / `alignBytes == 8` to match (asserted in `ecs_bridge.zig`). +/// field: an empty collection is a real (empty) container block allocated with +/// the resource's store buffer, so a read always finds a valid container. The +/// block pointer is stable across the container's internal realloc (the buffer +/// moves inside the container, not the block). +/// `Registry.FieldKind.{array_,map_,set_}` report `sizeBytes == 8` / +/// `alignBytes == 8` to match (asserted in `ecs_bridge.zig`). pub const CollectionSlot = extern struct { ptr: u64 = 0, }; diff --git a/src/core/scene/loader.zig b/src/core/scene/loader.zig index 3b8f22bf..bd8670ae 100644 --- a/src/core/scene/loader.zig +++ b/src/core/scene/loader.zig @@ -162,8 +162,8 @@ pub const UuidMap = std.AutoHashMapUnmanaged([16]u8, EntityId); /// /// Ownership: the caller ends the load's life with `deinit` (frees `spawned`, /// the map, and closes `mmap` if present). Loaded resource `string` blocks are -/// refcounted and owned by their `StringSlot`s, not by the -/// `LoadResult` — the resource owner reclaims them at teardown. +/// refcounted and owned by their `StringSlot`s, not by the `LoadResult`; +/// `World.deinit` releases them. pub const LoadResult = struct { spawned: []EntityId, uuid_to_entity: UuidMap, @@ -171,10 +171,8 @@ pub const LoadResult = struct { /// Free the loader-owned allocations and close the backing mmap (if any). /// Does **not** despawn the loaded entities — they belong to the `World` — - /// and does **not** free the loaded resource `string` blocks: those are - /// refcounted, owned by the resources' `StringSlot`s, and reclaimed by - /// the resource owner's teardown exactly like interp-written resource - /// strings (`interp.zig` deinit). Teardown parity — no new mechanism. + /// and does **not** free the loaded resource `string` blocks, which + /// `World.deinit` releases like every other resource payload. pub fn deinit(self: *LoadResult, gpa: std.mem.Allocator) void { gpa.free(self.spawned); self.uuid_to_entity.deinit(gpa); @@ -235,9 +233,8 @@ fn decrefResourceStrings(world: *const World, gpa: std.mem.Allocator, cid: Compo /// Commit the loader's resource writes. For each resource that /// REPLACED a prior value, decref the old string blocks the snapshot captured — /// they are no longer referenced (the live slot holds the new block). The new -/// blocks stay live, owned by the resource slots (freed by the resource owner's -/// teardown — parity with interp-written strings). Frees each snapshot and the -/// journal. Infallible. +/// blocks stay live, owned by the resource slots, which `World.deinit` +/// releases. Frees each snapshot and the journal. Infallible. fn commitResources(world: *const World, gpa: std.mem.Allocator, journal: *ResourceJournal) void { for (journal.items) |edit| { if (edit.snapshot) |snap| { @@ -1144,10 +1141,6 @@ test "resource strings outlive LoadResult.deinit" { try testing.expect(ss.ptr != 0); const loaded: [*]const u8 = @ptrFromInt(ss.ptr); try testing.expectEqualStrings("Verdant Keep", loaded[0..ss.len]); - - // Owner teardown (parity with the interp's resource-string deinit): release - // the slot's refcounted block so the testing allocator sees no leak. - decrefResourceStrings(&world, gpa, settings, buf); } test "loading over an existing resource string releases the previous block" { @@ -1174,8 +1167,6 @@ test "loading over an existing resource string releases the previous block" { @memcpy(std.mem.asBytes(&ss), buf[0..@sizeOf(persistent.StringSlot)]); const loaded: [*]const u8 = @ptrFromInt(ss.ptr); try testing.expectEqualStrings("second", loaded[0..ss.len]); - - decrefResourceStrings(&world, gpa, settings, buf); // release "second" } test "a failed load leaves the world unchanged" { @@ -1210,8 +1201,6 @@ test "a failed load leaves the world unchanged" { @memcpy(std.mem.asBytes(&ss), buf[0..@sizeOf(persistent.StringSlot)]); const held: [*]const u8 = @ptrFromInt(ss.ptr); try testing.expectEqualStrings("old", held[0..ss.len]); - - decrefResourceStrings(&world, gpa, settings, buf); // release "old" } test "rollback restores across duplicate resource entries" { @@ -1241,8 +1230,6 @@ test "rollback restores across duplicate resource entries" { @memcpy(std.mem.asBytes(&ss), buf[0..@sizeOf(persistent.StringSlot)]); const held: [*]const u8 = @ptrFromInt(ss.ptr); try testing.expectEqualStrings("pre", held[0..ss.len]); // pre-load value restored - - decrefResourceStrings(&world, gpa, settings, buf); // release "pre" } /// Test helper: cook a 2-archetype (`A` then `B`), one-entity-each `.scene.bin`. diff --git a/src/etch/ecs_bridge.zig b/src/etch/ecs_bridge.zig index d20068d7..c779919e 100644 --- a/src/etch/ecs_bridge.zig +++ b/src/etch/ecs_bridge.zig @@ -372,9 +372,9 @@ pub fn readBytesAsValue(kind: FieldKind, bytes: []const u8) Value { // borrowed `.array_persistent` view over the owned container block (no // incref — the resource, hence the block, outlives the rule body). `ptr` // is never 0 for a live field (the empty collection is a real block - // allocated at `addResource`). Components never carry a collection kind - // (validator-gated, resource-only), so this is reached only via - // `readResourceField`. + // allocated with the resource's store buffer). Components never carry a + // collection kind (validator-gated, resource-only), so this is reached + // only via `readResourceField`. .array_ => blk: { var cs: persistent.CollectionSlot = undefined; @memcpy(std.mem.asBytes(&cs), bytes[0..@sizeOf(persistent.CollectionSlot)]); diff --git a/src/etch/interp.zig b/src/etch/interp.zig index 47f6b517..35763e3b 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -19,6 +19,7 @@ const std = @import("std"); const ast_mod = @import("ast.zig"); const types_mod = @import("types.zig"); +const tagset_name = types_mod.tagset_component_name; const parser_mod = @import("parser.zig"); const diag_mod = @import("diagnostics.zig"); const value_mod = @import("value.zig"); @@ -7100,7 +7101,7 @@ fn stageTagSet( const content_digest = try tag_table.contentDigest(gpa); const candidate = weld_core.ecs.registry.schemaDigestOf(tagSetDesc(size, &.{}, content_digest)); - const holder: ?ComponentId = world.registry.idOf("TagSet") orelse pending.idOf("TagSet"); + const holder: ?ComponentId = world.registry.idOf(tagset_name) orelse pending.idOf(tagset_name); if (holder) |id| { const live: LiveLayout = if (id < pending.base_id) liveLayoutOf(&world.registry, id) else blk: { const e = pending.entryOf(id); @@ -7109,24 +7110,24 @@ fn stageTagSet( // An absent digest refuses: an unknown layout is not a matching one. if ((live.digest orelse ~candidate) != candidate) { std.log.warn( - "etch/hot-reload: 'TagSet' changed layout or tag identity — reload REFUSED, " ++ + "etch/hot-reload: '" ++ tagset_name ++ "' changed layout or tag identity — reload REFUSED, " ++ "previous image kept. live: size={d}; new: size={d} ({d} tag(s)). " ++ "An unchanged size means the tags themselves were renamed or reordered.", .{ live.size, size, tag_table.leaf_count }, ); return error.SchemaChanged; } - try bridge.mapComponent(gpa, "TagSet", id); + try bridge.mapComponent(gpa, tagset_name, id); return id; } const zeroed = try gpa.alloc(u8, size); defer gpa.free(zeroed); @memset(zeroed, 0); - var entry = try world.registry.prepareEntry(gpa, tagSetDesc(size, zeroed, content_digest), &.{}); + var entry = try world.registry.prepareEntry(gpa, tagSetDesc(size, zeroed, content_digest)); errdefer entry.deinit(gpa); const id = pending.nextId(); - try bridge.mapComponent(gpa, "TagSet", id); + try bridge.mapComponent(gpa, tagset_name, id); try pending.push(gpa, entry, null); return id; } @@ -7173,7 +7174,7 @@ fn stageBuiltinResource( .alignment = @intCast(max_align), .default_bytes = default_buf[0..size], .fields = fields_buf[0..br.fields.len], - }, &.{}); + }); errdefer entry.deinit(gpa); var resource: PendingResource = .{ .buf = try ResourceStore.allocBuffer(gpa, default_buf[0..size]), .collection_blocks = &.{} }; errdefer resource.deinit(gpa); @@ -7390,7 +7391,7 @@ pub const RegKind = enum { component, resource }; /// `TagTable.contentDigest` of the table `size` came from. fn tagSetDesc(size: u16, default_bytes: []const u8, content_digest: u64) weld_core.ecs.registry.ComponentDesc { return .{ - .name = "TagSet", + .name = tagset_name, .size = size, .alignment = 8, .default_bytes = default_bytes, @@ -7555,9 +7556,8 @@ pub fn compileTypeDecl( return registry.commitPrepared(entry); } -/// The registry entry of a declaration not yet registered: its layout, and its -/// default bytes with the immortal blocks of its `string` defaults, which the -/// entry owns. +/// The registry entry of a declaration not yet registered: its layout and its +/// default bytes. fn prepareTypeEntry(gpa: std.mem.Allocator, ast: *const AstArena, registry: *const Registry, shape: DeclShape) !PreparedEntry { var layout = try computeLayout(gpa, ast, shape.fields_start, shape.fields_len, shape.reg_kind); defer layout.deinit(gpa); @@ -7566,11 +7566,6 @@ fn prepareTypeEntry(gpa: std.mem.Allocator, ast: *const AstArena, registry: *con const default_buf: []u8 = try gpa.alloc(u8, layout.size); defer gpa.free(default_buf); @memset(default_buf, 0); - var blocks: std.ArrayListUnmanaged([*]u8) = .empty; - errdefer { - for (blocks.items) |b| persistent.destroy(gpa, b); - blocks.deinit(gpa); - } var f_i: u32 = 0; while (f_i < shape.fields_len) : (f_i += 1) { const f = ast.fields.items[shape.fields_start + f_i]; @@ -7580,17 +7575,15 @@ fn prepareTypeEntry(gpa: std.mem.Allocator, ast: *const AstArena, registry: *con // store's copy at a container. if (fd.kind == .array_ or fd.kind == .map_ or fd.kind == .set_) continue; if (fd.kind == .string_) { - // A literal default → an immortal block (sentinel refcount) the entry - // owns, shared by every copy of the default bytes. No default, or a - // non-literal one, leaves the empty string `{ptr=0,len=0}`. + // A literal default points at the AST's bytes, which `prepareEntry` + // copies into a block the entry owns. No default, or a non-literal + // one, leaves the empty string `{ptr=0,len=0}`. if (!f.default_value.isNone() and ast.exprKind(f.default_value) == .string_lit) { const lit = ast.strings.slice(ast.exprData(f.default_value)); - try blocks.ensureUnusedCapacity(gpa, 1); - const block = try persistent.allocImmortal(gpa, persistent.type_string, lit.len); - blocks.appendAssumeCapacity(block); - if (lit.len > 0) @memcpy(block[0..lit.len], lit); - const ss = persistent.StringSlot{ .ptr = @intFromPtr(block), .len = @intCast(lit.len) }; - @memcpy(slot, std.mem.asBytes(&ss)); + if (lit.len != 0) { + const ss = persistent.StringSlot{ .ptr = @intFromPtr(lit.ptr), .len = @intCast(lit.len) }; + @memcpy(slot, std.mem.asBytes(&ss)); + } } continue; } @@ -7624,11 +7617,6 @@ fn prepareTypeEntry(gpa: std.mem.Allocator, ast: *const AstArena, registry: *con try bridge_mod.writeValueAsBytes(fd.kind, slot, v); } - const owned = try blocks.toOwnedSlice(gpa); - errdefer { - for (owned) |b| persistent.destroy(gpa, b); - gpa.free(owned); - } return registry.prepareEntry(gpa, .{ .name = shape.name, .size = @intCast(layout.size), @@ -7637,7 +7625,7 @@ fn prepareTypeEntry(gpa: std.mem.Allocator, ast: *const AstArena, registry: *con .fields = fields.items, .storage = shape.storage, .requires = shape.requires, - }, owned); + }); } fn fieldKindFromTypeName(name: []const u8, reg_kind: RegKind) ?FieldKind { diff --git a/src/etch/parser.zig b/src/etch/parser.zig index 3bc85ba7..12fe2584 100644 --- a/src/etch/parser.zig +++ b/src/etch/parser.zig @@ -948,8 +948,7 @@ pub const Parser = struct { try self.arena.annot_args.append(self.gpa, arg); args_len += 1; if (!try self.match(.comma)) break; - // `etch-grammar.md` §21.4 gives `annotation` an `arg_list`, whose - // trailing comma is legal. + // `annotation_args` (`etch-grammar.md` §1.5) admits a trailing comma. if (self.peek() == .rparen) break; } } @@ -9302,7 +9301,7 @@ test "parser rejects a positional argument after a named one (§3.3)" { try std.testing.expect(result.diagnostics.len > 0); } -test "parser accepts a trailing argument comma (grammar l.571)" { +test "parser accepts a trailing comma in call and annotation arguments" { const gpa = std.testing.allocator; var result = try parse(gpa, \\@tag(.a,) diff --git a/src/etch/scene_cook.zig b/src/etch/scene_cook.zig index d676a95b..b56068bd 100644 --- a/src/etch/scene_cook.zig +++ b/src/etch/scene_cook.zig @@ -347,7 +347,7 @@ const Builder = struct { /// Free everything NOT owned by the produced model: the bridge and the /// scratch hashmaps. The model arena is transferred to the caller (not freed - /// here); the registry owns the default blocks `compileTypeDecl` allocated. + /// here); the registry owns the blocks its default bytes point at. fn deinitScratch(self: *Builder) void { self.bridge.deinit(self.gpa); self.string_map.deinit(self.gpa); diff --git a/src/etch/types.zig b/src/etch/types.zig index 17028da0..6d095f18 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -250,8 +250,8 @@ pub const builtin_resources = [_]BuiltinResource{ } }, }; -/// The builtin component the interpreter injects when a program declares any -/// tag. `interp.zig` spells it as a literal, which this must equal. +/// The name of the builtin component both backends inject when a program +/// declares any tag. The reservation and every injection read it here. pub const tagset_component_name = "TagSet"; /// True iff the engine itself registers `name` in the ECS registry, so a user diff --git a/src/etch/zig_codegen/lower.zig b/src/etch/zig_codegen/lower.zig index ebb88dc0..ab0d909a 100644 --- a/src/etch/zig_codegen/lower.zig +++ b/src/etch/zig_codegen/lower.zig @@ -34,6 +34,7 @@ const std = @import("std"); const ast_mod = @import("../ast.zig"); const types_mod = @import("../types.zig"); +const tagset_name = types_mod.tagset_component_name; const tags_mod = @import("../tags.zig"); const diag_mod = @import("../diagnostics.zig"); const descriptor_mod = @import("../descriptor.zig"); @@ -176,7 +177,7 @@ pub fn generateFile( // The builtin `TagSet` component: a fixed `[words]u64` bitfield, // one slot per entity carrying tags. Emitted as an `extern struct` so its // layout matches the registry's raw `words*8`-byte / align-8 component - // (`etch-abi-zig.md` §3) — byte-exact with the interpreter's `registerComponentRaw`. + // (`etch-abi-zig.md` §3) — byte-exact with the interpreter's `tagSetDesc`. if (tag_table.leaf_count > 0) { try emitTagSetStruct(&w, tag_table.words()); } @@ -305,7 +306,7 @@ fn emitImports(w: *Writer) CodegenError!void { /// command buffer. Layout matches the registry's raw component /// (size `words*8`, align 8, no named fields). fn emitTagSetStruct(w: *Writer, words: u32) CodegenError!void { - try w.printLine("pub const TagSet = extern struct {{ bits: [{d}]u64 = [_]u64{{0}} ** {d} }};", .{ words, words }); + try w.printLine("pub const " ++ tagset_name ++ " = extern struct {{ bits: [{d}]u64 = [_]u64{{0}} ** {d} }};", .{ words, words }); try w.blankLine(); } @@ -1218,12 +1219,12 @@ fn emitRegister(w: *Writer, ast: *const AstArena, tag_table: *const tags_mod.Tag const content_digest = try tag_table.contentDigest(w.gpa); try w.line("{"); w.indentBy(1); - try w.line("var __tagset_default: TagSet = .{};"); + try w.line("var __tagset_default: " ++ tagset_name ++ " = .{};"); try w.line("_ = try world.registry.registerComponentRaw(gpa, .{"); w.indentBy(1); - try w.line(".name = \"TagSet\","); - try w.line(".size = @sizeOf(TagSet),"); - try w.line(".alignment = @alignOf(TagSet),"); + try w.line(".name = \"" ++ tagset_name ++ "\","); + try w.line(".size = @sizeOf(" ++ tagset_name ++ "),"); + try w.line(".alignment = @alignOf(" ++ tagset_name ++ "),"); try w.line(".default_bytes = std.mem.asBytes(&__tagset_default),"); try w.line(".fields = &.{},"); try w.printLine(".content_digest = {d},", .{content_digest}); @@ -1766,7 +1767,7 @@ fn emitRuleInner(w: *Writer, ast: *const AstArena, rule: ast_mod.RuleDecl, tag_t // already added "TagSet" to `info.components` for the id + `has TagSet` // archetype predicate. if (info.tag_filters.len > 0) { - _ = try body_used.getOrPut(w.gpa, "TagSet"); + _ = try body_used.getOrPut(w.gpa, tagset_name); } if (info.has_or_or_not or info.tag_filters.len > 0 or tag_mutating or program_has_changed) { @@ -2192,7 +2193,7 @@ fn emitArchPredicate(w: *Writer, ast: *const AstArena, when_idx: u32) CodegenErr .tag_filter => { const tf = ast.tag_filters.items[node.aux]; switch (tf.op) { - .has_tag, .has_any_tag, .has_all_tags => try w.write("arch.hasComponent(TagSet_id)"), + .has_tag, .has_any_tag, .has_all_tags => try w.write("arch.hasComponent(" ++ tagset_name ++ "_id)"), .has_no_tag, .has_no_tags => return CodegenError.UnsupportedConstruct, } }, @@ -2820,7 +2821,7 @@ fn emitStmt(w: *Writer, ast: *const AstArena, ctx: *LocalCtx, stmt_id: NodeId) C const bit = tagPathLeafBitCodegen(ast, table, tm.path) orelse return CodegenError.UnsupportedConstruct; const method = if (tm.kind == .add) "setTag" else "clearTag"; try w.writeIndent(); - try w.print("cmd.{s}(__entity, world.registry.idOf(\"TagSet\").?, {d}) catch {{}};\n", .{ method, bit }); + try w.print("cmd.{s}(__entity, world.registry.idOf(\"" ++ tagset_name ++ "\").?, {d}) catch {{}};\n", .{ method, bit }); }, .throw_stmt => { // `throw expression` — the flag+branch @@ -6811,8 +6812,8 @@ fn walkWhen( const tf = ast.tag_filters.items[node.aux]; switch (tf.op) { .has_tag, .has_any_tag, .has_all_tags => { - const gop = try seen.getOrPut(gpa, "TagSet"); - if (!gop.found_existing) try components.append(gpa, "TagSet"); + const gop = try seen.getOrPut(gpa, tagset_name); + if (!gop.found_existing) try components.append(gpa, tagset_name); has_component_ref.* = true; }, .has_no_tag, .has_no_tags => return CodegenError.UnsupportedConstruct, @@ -6879,8 +6880,8 @@ fn collectComponents( const tf = ast.tag_filters.items[node.aux]; switch (tf.op) { .has_tag, .has_any_tag, .has_all_tags => { - const gop = try seen.getOrPut(gpa, "TagSet"); - if (!gop.found_existing) try components.append(gpa, "TagSet"); + const gop = try seen.getOrPut(gpa, tagset_name); + if (!gop.found_existing) try components.append(gpa, tagset_name); }, .has_no_tag, .has_no_tags => return CodegenError.UnsupportedConstruct, } @@ -7000,7 +7001,7 @@ fn emitTagFilterGuard(w: *Writer, tf: TagFilterInfo) CodegenError!void { .has_tag, .has_all_tags => { // Every listed bit must be set. for (entries.items) |e| { - try w.printLine("if ((TagSet_arr[slot].bits[{d}] & 0x{x}) != 0x{x}) continue;", .{ e.word, e.mask, e.mask }); + try w.printLine("if ((" ++ tagset_name ++ "_arr[slot].bits[{d}] & 0x{x}) != 0x{x}) continue;", .{ e.word, e.mask, e.mask }); } }, .has_any_tag => { @@ -7009,7 +7010,7 @@ fn emitTagFilterGuard(w: *Writer, tf: TagFilterInfo) CodegenError!void { try w.write("if ("); for (entries.items, 0..) |e, idx| { if (idx > 0) try w.write(" and "); - try w.print("(TagSet_arr[slot].bits[{d}] & 0x{x}) == 0", .{ e.word, e.mask }); + try w.print("(" ++ tagset_name ++ "_arr[slot].bits[{d}] & 0x{x}) == 0", .{ e.word, e.mask }); } try w.write(") continue;\n"); }, diff --git a/tests/etch/hot_reload_test.zig b/tests/etch/hot_reload_test.zig index 0a074f93..43b60232 100644 --- a/tests/etch/hot_reload_test.zig +++ b/tests/etch/hot_reload_test.zig @@ -644,15 +644,21 @@ test "an interpreter torn down before the next compile takes no resource with it try std.testing.expectEqual(Health.healthy, registrationHealth(&world)); } +/// Append `resource Big`, a declaration past the registry's 64 KiB. +fn appendOversized(gpa: std.mem.Allocator, src: *std.ArrayListUnmanaged(u8)) !void { + try src.appendSlice(gpa, "resource Big { v0: int = 0"); + for (1..8200) |i| try src.print(gpa, ", v{d}: int = 0", .{i}); + try src.appendSlice(gpa, " }\n"); +} + test "a declaration past 64 KiB is refused and registers nothing" { const gpa = std.testing.allocator; var src: std.ArrayListUnmanaged(u8) = .empty; defer src.deinit(gpa); // A declaration staged BEFORE the oversized one, so that a registration // committed per declaration would leave it behind. - try src.appendSlice(gpa, "component Small { x: int = 0 }\nresource Big { v0: int = 0"); - for (1..8200) |i| try src.print(gpa, ", v{d}: int = 0", .{i}); - try src.appendSlice(gpa, " }\n"); + try src.appendSlice(gpa, "component Small { x: int = 0 }\n"); + try appendOversized(gpa, &src); var pr = try weld_etch.parseSource(gpa, src.items); defer pr.deinit(gpa); @@ -663,3 +669,115 @@ test "a declaration past 64 KiB is refused and registers nothing" { try std.testing.expectError(error.LayoutTooLarge, Interpreter.compile(gpa, &pr.ast, &world)); try std.testing.expectEqual(@as(usize, 0), world.registry.componentCount()); } + +const CountingAllocator = weld_core.testing.alloc_counting.CountingAllocator; +const src_counter = "component Counter { value: int = 0 }\n"; + +/// Reload, onto a world running `src_counter`, a program declaring an oversized +/// resource and a widened `Counter`, in the order `oversized_first` gives. +fn reloadWithTwoFaults(gpa: std.mem.Allocator, oversized_first: bool) !void { + var src: std.ArrayListUnmanaged(u8) = .empty; + defer src.deinit(gpa); + if (oversized_first) try appendOversized(gpa, &src); + try src.appendSlice(gpa, "component Counter { value: int = 0, extra: int = 0 }\n"); + if (!oversized_first) try appendOversized(gpa, &src); + + var base = try weld_etch.parseSource(gpa, src_counter); + defer base.deinit(gpa); + var pr = try weld_etch.parseSource(gpa, src.items); + defer pr.deinit(gpa); + try typeCheckClean(gpa, &pr.ast); + var world = World.init(); + defer world.deinit(gpa); + var live = try Interpreter.compile(gpa, &base.ast, &world); + defer live.deinit(); + var it = try Interpreter.compile(gpa, &pr.ast, &world); + it.deinit(); +} + +test "a reload with two refusals reports the first declaration's: the oversized one" { + try std.testing.expectError(error.LayoutTooLarge, reloadWithTwoFaults(std.testing.allocator, true)); +} + +test "a reload with two refusals reports the first declaration's: the widened one" { + try std.testing.expectError(error.SchemaChanged, reloadWithTwoFaults(std.testing.allocator, false)); +} + +/// Allocations `compile` makes for `src` onto a world already running `base`, or +/// onto a fresh world when `base` is null. +fn compileAllocations(gpa: std.mem.Allocator, base: ?[]const u8, src: []const u8) !u64 { + var base_pr = if (base) |b| try weld_etch.parseSource(gpa, b) else null; + defer if (base_pr) |*p| p.deinit(gpa); + var pr = try weld_etch.parseSource(gpa, src); + defer pr.deinit(gpa); + try typeCheckClean(gpa, &pr.ast); + var world = World.init(); + defer world.deinit(gpa); + var live = if (base_pr) |*p| try Interpreter.compile(gpa, &p.ast, &world) else null; + defer if (live) |*l| l.deinit(); + + var counting = CountingAllocator.init(gpa); + var it = try Interpreter.compile(counting.allocator(), &pr.ast, &world); + const n = counting.snapshot().alloc_count; + it.deinit(); + return n; +} + +test "a reload evaluates none of the defaults of a type already registered" { + const gpa = std.testing.allocator; + const bare = "resource R { s: string }\n"; + const defaulted = "resource R { s: string = \"abc\" }\n"; + // Control: on a fresh world the same count sees the default being copied. + try std.testing.expect(try compileAllocations(gpa, null, defaulted) > try compileAllocations(gpa, null, bare)); + try std.testing.expectEqual(try compileAllocations(gpa, bare, bare), try compileAllocations(gpa, bare, defaulted)); +} + +/// Compile `src` onto a world where a Zig component requiring `requisite` was +/// registered first, and report whether its closure reaches `requisite`. +fn zigRequisiteResolves(gpa: std.mem.Allocator, requisite: []const u8, src: []const u8) !bool { + var pr = try weld_etch.parseSource(gpa, src); + defer pr.deinit(gpa); + try typeCheckClean(gpa, &pr.ast); + var world = World.init(); + defer world.deinit(gpa); + const requirer = try world.registry.registerComponentRaw(gpa, .{ + .name = "ZigRequirer", + .size = 4, + .alignment = 4, + .default_bytes = &[_]u8{0} ** 4, + .fields = &.{}, + .requires = &.{requisite}, + }); + var it = try Interpreter.compile(gpa, &pr.ast, &world); + defer it.deinit(); + return world.registry.isRequiredBy(world.registry.idOf(requisite).?, requirer); +} + +test "a Zig requisite on TagSet resolves at the first compile" { + try std.testing.expect(try zigRequisiteResolves(std.testing.allocator, weld_etch.types.tagset_component_name, "tags {\n a { t00 }\n}\n")); +} + +test "a Zig requisite on a builtin time resource resolves at the first compile" { + try std.testing.expect(try zigRequisiteResolves(std.testing.allocator, "GameTime", "component Plain { x: int = 0 }\n")); +} + +test "every type a compile registers without the program declaring it has a reserved name" { + const gpa = std.testing.allocator; + var pr = try weld_etch.parseSource(gpa, "tags {\n a { t00 }\n}\n"); + defer pr.deinit(gpa); + try typeCheckClean(gpa, &pr.ast); + var world = World.init(); + defer world.deinit(gpa); + var it = try Interpreter.compile(gpa, &pr.ast, &world); + defer it.deinit(); + + const n = world.registry.componentCount(); + try std.testing.expectEqual(1 + weld_etch.types.builtin_resources.len, n); + for (0..n) |id| { + const name = world.registry.componentName(@intCast(id)); + if (!weld_etch.types.isReservedEngineTypeName(name)) { + std.debug.print("registered and not reserved: {s}\n", .{name}); + return error.TestUnexpectedResult; + } + } +} diff --git a/tests/scene/load_resources_test.zig b/tests/scene/load_resources_test.zig index a58abb81..3ebb6563 100644 --- a/tests/scene/load_resources_test.zig +++ b/tests/scene/load_resources_test.zig @@ -3,8 +3,7 @@ //! `string` field, loads it, and asserts the field reads back the cooked value: //! the loaded string is interned into `weld_core.memory.persistent` as a //! **refcounted** block owned by the resource's `StringSlot` and NOT by -//! `LoadResult`, released here at test teardown exactly as the resource's real -//! owner — the interpreter — would. `weld_core` only. +//! `LoadResult`, which `World.deinit` releases. `weld_core` only. const std = @import("std"); const weld_core = @import("weld_core"); @@ -71,11 +70,6 @@ test "resource string fields round-trip through the persistent heap" { try std.testing.expectEqual(@as(u32, title_value.len), ss.len); const loaded: [*]const u8 = @ptrFromInt(ss.ptr); try std.testing.expectEqualStrings(title_value, loaded[0..ss.len]); - - // D1: the loaded block is refcounted and owned by the slot (not by - // `LoadResult`). Release it here — parity with the interp's resource-string - // teardown — so `std.testing.allocator` sees no leak. - persistent.decref(gpa, @ptrFromInt(ss.ptr)); } test "loader rejects a resource collection field (guard)" { diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 439f7cce..02d6619a 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -237,8 +237,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2401, - else => 2403, + .windows => 2409, + else => 2411, }; } From 5708fcebcd012f69ed678e4ce4b76fe0d91f6439 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 23 Sep 2026 09:54:49 +0200 Subject: [PATCH 014/141] fix(scene): never trust a string slot a scene file carries (S5/G8 bis) The loader copied a resource's raw data into the store and rewrote only the string slots the string table names, so a scene file with non-zero bytes at a string offset put a pointer nobody owns in the store, which rollback, commit and World.deinit then decref. Every string slot is now zeroed before any allocation. prepareEntry refuses a field reaching past its default bytes (FieldOutOfBounds), and both passes of the string-default copy read the caller's immutable bytes, so overlapping slots can no longer make the copies outnumber their count. A false build claim is corrected in each place it stood: vk-gen-check's old block, the "RENDER IS HELD" note on wired tests, the stub and refusal counts, the counter-proof manifest, and shaders-check, which no CI runs. forge-asm-inventory takes each cell's precision, so the f64 build is inventoried. Comments this gate touched hold each fact once. Floor 2411 -> 2415, windows 2413. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- build.zig | 16 +- src/core/ecs/registry.zig | 171 ++++++++++++------ src/core/memory/persistent.zig | 11 +- src/core/scene/loader.zig | 67 +++++-- src/etch/ecs_bridge.zig | 4 + src/etch/interp.zig | 11 +- src/etch/scene_cook.zig | 2 +- src/etch/value.zig | 4 +- src/etch/zig_codegen/lower.zig | 4 - tests/core/ecs/access_counterproof/build.zig | 4 +- .../ecs/access_counterproof/build.zig.zon | 5 +- tests/etch/hot_reload_test.zig | 6 +- tests/scene/load_resources_test.zig | 7 +- tools/shader_compiler/main.zig | 6 +- tools/weld_lint/dead_tests.zig | 4 +- 16 files changed, 200 insertions(+), 124 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5594a775..453288b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -164,7 +164,7 @@ jobs: - name: zig build forge-asm-inventory if: matrix.os == 'ubuntu-24.04' && matrix.mode == 'Debug' - run: zig build forge-asm-inventory -Dcpu=${{ env.ZIG_CPU }} + run: zig build forge-asm-inventory -Dphysics_f64=${{ matrix.precision }} -Dcpu=${{ env.ZIG_CPU }} - name: zig build lint if: matrix.os == 'ubuntu-24.04' && matrix.mode == 'Debug' diff --git a/build.zig b/build.zig index a9492f40..22f163d5 100644 --- a/build.zig +++ b/build.zig @@ -245,7 +245,7 @@ pub fn build(b: *std.Build) void { } const stub_plugins_step = b.step( "stub-plugins", - "Build the three stub plugin libraries used by the plugin_loader tests", + "Build the four stub plugin libraries used by the plugin_loader tests", ); for (stub_install_steps) |s| stub_plugins_step.dependOn(s); @@ -302,9 +302,9 @@ pub fn build(b: *std.Build) void { // matching only the shared refusal marker would let any one case stand in // for any other. // - // The control runs in the SAME step and must SUCCEED. Without it the three + // The control runs in the SAME step and must SUCCEED. Without it the six // refusals prove nothing: a view that refused every access would satisfy - // all three. + // all six. const counterproof_dir = "tests/core/ecs/access_counterproof"; const CounterproofCase = struct { step: ?[]const u8, @@ -2627,16 +2627,6 @@ pub fn build(b: *std.Build) void { // (8) and `src/modules/audio/` (1). Found by counting source `test` blocks // against the suite's own per-target totals, then confirming each by // appending a deliberately failing test and watching the suite stay green. - // RENDER IS HELD, and the reason is what the sweep was for. Wiring its 49 - // never-run tests turns two of them red, and a probe settled why: the - // render-graph passes return a `Pass` whose `reads`/`writes` slices point at - // an anonymous literal in `buildPass`'s OWN STACK FRAME. Measured on - // `depth_prepass`: `writes.ptr` is a stack address, `depth_attachment` reads - // `false` immediately after the call, and a fresh call at the SAME address - // reads `true` — a use-after-return, long-lived and invisible because - // nothing ever compiled the tests that assert it. `forward.zig` fails - // identically. The fix is an ownership decision in the render graph, not a - // determinism change, so it is reported rather than taken here. const render_tests = b.addTest(.{ .root_module = render_module }); test_step.dependOn(&b.addRunArtifact(render_tests).step); diff --git a/src/core/ecs/registry.zig b/src/core/ecs/registry.zig index 7edb8088..cadc8e7c 100644 --- a/src/core/ecs/registry.zig +++ b/src/core/ecs/registry.zig @@ -69,15 +69,8 @@ pub const FieldKind = enum { u32_, f32_, f64_, - /// A `string` field slot: `{ ptr: u64, len: u32 }` (16 bytes, 8-aligned) - /// pointing into the Tier-0 persistent heap (`src/core/memory/persistent.zig`, - /// `StringSlot`). **Resource-only in Etch**: the Etch validator rejects - /// `string` on `component` and `fieldKindFromTypeName` only emits this kind - /// for the `.resource` origin, so no Etch component carries it — the - /// component SoA/POD invariant (`ARCH-004`) is untouched. The string behind - /// a DEFAULT slot is copied at registration into a block the registry entry - /// owns; the one behind a live slot is its writer's, released through its - /// refcount. + /// A `string` field slot: a `persistent.StringSlot` (16 bytes, 8-aligned). + /// Etch admits it on resources only. string_, /// An enum field slot: the variant's declaration-order index as a `u32` /// discriminant (4 bytes, 4-aligned). POD — no persistent heap, no decref, @@ -97,10 +90,8 @@ pub const FieldKind = enum { entity_, /// A dynamic-array field slot (`T[]`): a `CollectionSlot` (`{ ptr: u64 }`, /// 8 bytes, 8-aligned) holding the persistent-heap pointer of the owned - /// container block. **Resource-only in Etch** like `.string_`. Tier 0 copies - /// the 8 raw slot bytes, and `World.deinit` releases the container through - /// its refcount. A DEFAULT slot names no container: `prepareEntry` refuses - /// one that does. + /// container block. Etch admits it on resources only. Tier 0 copies the 8 raw + /// slot bytes. array_, /// A map field slot (`[K: V]`). Same 8-byte `CollectionSlot` /// discipline and resource-only gating as `.array_`. @@ -235,14 +226,13 @@ pub fn schemaDigestOf(desc: ComponentDesc) u64 { /// and `registerAlias`; lookup paths never fail (return `?T`). pub const RegistryError = error{ DuplicateComponent, - /// A collection field's default slot is not zero. + FieldOutOfBounds, CollectionDefaultNotEmpty, OutOfMemory, }; -/// One owned entry. Every input of its descriptor is copied at registration, -/// the strings its default bytes point at included, so the caller can free its -/// inputs immediately. +/// One registered type: the descriptor `prepareEntry` copied, and what the +/// registry derives from it. const Entry = struct { desc: ComponentDesc, /// The TRANSITIVE closure of `desc.requires`, flattened to ids, computed @@ -254,48 +244,47 @@ const Entry = struct { /// Schema identity, derived at registration — beside the descriptor for the /// same reason `closure` is. schema_digest: u64 = 0, - /// The immortal `persistent` copies of the strings `desc.default_bytes` - /// points at. Destroyed with the entry, so every copy of the default bytes - /// — a resource store slot included — stays valid for the registry's - /// lifetime. + /// The blocks `desc.default_bytes` points at, destroyed with the entry, so + /// every copy of the default bytes — a resource store slot included — stays + /// valid for the registry's lifetime. owned_blocks: []const [*]u8 = &.{}, }; -/// Point every non-empty `.string_` slot of `bytes` at an immortal copy of its -/// string and write every empty one as `{ptr=0,len=0}`, returning the copies. On -/// error `bytes` may be partly rewritten and no copy survives. -fn copyStringDefaults(gpa: std.mem.Allocator, fields: []const FieldDesc, bytes: []u8) error{OutOfMemory}![]const [*]u8 { +/// Point every `.string_` slot of `out` whose slot in `src` is non-empty at an +/// immortal copy of that string, and write every other one as `{ptr=0,len=0}`, +/// returning the copies. Immortal because a store slot seeded from the default +/// bytes holds one, and `decref` must leave it alone. Reads only `src`, which +/// `out` copies. On error no copy survives. +fn copyStringDefaults(gpa: std.mem.Allocator, fields: []const FieldDesc, src: []const u8, out: []u8) error{OutOfMemory}![]const [*]u8 { const Slot = persistent.StringSlot; var n: usize = 0; for (fields) |f| { if (f.kind != .string_) continue; - const slot = bytes[f.offset..][0..@sizeOf(Slot)]; - const ss = std.mem.bytesToValue(Slot, slot); - if (ss.ptr != 0 and ss.len != 0) { - n += 1; - } else { - @memcpy(slot[@offsetOf(Slot, "ptr")..][0..@sizeOf(u64)], std.mem.asBytes(&@as(u64, 0))); - @memcpy(slot[@offsetOf(Slot, "len")..][0..@sizeOf(u32)], std.mem.asBytes(&@as(u32, 0))); - } + const ss = std.mem.bytesToValue(Slot, src[f.offset..][0..@sizeOf(Slot)]); + if (ss.ptr != 0 and ss.len != 0) n += 1; } - if (n == 0) return &.{}; - const blocks = try gpa.alloc([*]u8, n); + const blocks: [][*]u8 = if (n == 0) &.{} else try gpa.alloc([*]u8, n); var made: usize = 0; errdefer { for (blocks[0..made]) |b| persistent.destroy(gpa, b); - gpa.free(blocks); + if (n != 0) gpa.free(blocks); } for (fields) |f| { if (f.kind != .string_) continue; - const slot = bytes[f.offset..][0..@sizeOf(Slot)]; - const ss = std.mem.bytesToValue(Slot, slot); - if (ss.ptr == 0) continue; - const block = try persistent.allocImmortal(gpa, persistent.type_string, ss.len); - @memcpy(block[0..ss.len], @as([*]const u8, @ptrFromInt(ss.ptr))[0..ss.len]); - blocks[made] = block; - made += 1; - const ptr: u64 = @intFromPtr(block); + const ss = std.mem.bytesToValue(Slot, src[f.offset..][0..@sizeOf(Slot)]); + var ptr: u64 = 0; + var len: u32 = 0; + if (ss.ptr != 0 and ss.len != 0) { + const block = try persistent.allocImmortal(gpa, persistent.type_string, ss.len); + @memcpy(block[0..ss.len], @as([*]const u8, @ptrFromInt(ss.ptr))[0..ss.len]); + blocks[made] = block; + made += 1; + ptr = @intFromPtr(block); + len = ss.len; + } + const slot = out[f.offset..][0..@sizeOf(Slot)]; @memcpy(slot[@offsetOf(Slot, "ptr")..][0..@sizeOf(u64)], std.mem.asBytes(&ptr)); + @memcpy(slot[@offsetOf(Slot, "len")..][0..@sizeOf(u32)], std.mem.asBytes(&len)); } return blocks; } @@ -402,19 +391,23 @@ pub const Registry = struct { } /// Copy `desc` into an entry `commitPrepared` can adopt, without touching - /// the registry. The entry copies every input, and a non-zero `.string_` - /// default slot names `len` readable bytes at `ptr`, which it copies into a - /// block of its own: the caller keeps all it passed. Refuses a name already - /// registered, and a collection field whose default slot is not zero. + /// the registry. The entry copies every input, the string a `.string_` + /// default slot names included (`len` readable bytes at `ptr`, when both are + /// non-zero): the caller keeps all it passed. Refuses a name already + /// registered, a field reaching past `desc.default_bytes`, and a collection + /// field whose default slot is not zero. pub fn prepareEntry(self: *const Registry, gpa: std.mem.Allocator, desc: ComponentDesc) RegistryError!PreparedEntry { if (self.by_name.contains(desc.name)) return RegistryError.DuplicateComponent; - for (desc.fields) |f| switch (f.kind) { - .array_, .map_, .set_ => { - const slot = desc.default_bytes[f.offset..][0..@sizeOf(persistent.CollectionSlot)]; - if (std.mem.bytesToValue(persistent.CollectionSlot, slot).ptr != 0) return RegistryError.CollectionDefaultNotEmpty; - }, - else => {}, - }; + for (desc.fields) |f| { + if (@as(usize, f.offset) + f.kind.sizeBytes() > desc.default_bytes.len) return RegistryError.FieldOutOfBounds; + switch (f.kind) { + .array_, .map_, .set_ => { + const slot = desc.default_bytes[f.offset..][0..@sizeOf(persistent.CollectionSlot)]; + if (std.mem.bytesToValue(persistent.CollectionSlot, slot).ptr != 0) return RegistryError.CollectionDefaultNotEmpty; + }, + else => {}, + } + } const name_owned = try gpa.dupe(u8, desc.name); errdefer gpa.free(name_owned); @@ -422,7 +415,7 @@ pub const Registry = struct { const default_owned = try gpa.dupe(u8, desc.default_bytes); errdefer gpa.free(default_owned); - const blocks_owned = try copyStringDefaults(gpa, desc.fields, default_owned); + const blocks_owned = try copyStringDefaults(gpa, desc.fields, desc.default_bytes, default_owned); errdefer { for (blocks_owned) |b| persistent.destroy(gpa, b); if (blocks_owned.len != 0) gpa.free(blocks_owned); @@ -918,11 +911,69 @@ test "registerComponentRaw refuses a collection default that names a container" defer reg.deinit(gpa); const default_bytes = std.mem.toBytes(@as(u64, 0x1000)); - try std.testing.expectError(RegistryError.CollectionDefaultNotEmpty, reg.registerComponentRaw(gpa, .{ - .name = "Listed", - .size = 8, + for ([_]FieldKind{ .array_, .map_, .set_ }) |kind| { + try std.testing.expectError(RegistryError.CollectionDefaultNotEmpty, reg.registerComponentRaw(gpa, .{ + .name = "Listed", + .size = 8, + .alignment = 8, + .default_bytes = &default_bytes, + .fields = &[_]FieldDesc{.{ .name = "xs", .offset = 0, .kind = kind }}, + })); + } +} + +test "registerComponentRaw writes an empty string default as {0,0} and owns nothing for it" { + const gpa = std.testing.allocator; + var reg = Registry.init(); + defer reg.deinit(gpa); + + var default_bytes = [_]u8{0} ** 16; + @memcpy(default_bytes[0..8], std.mem.asBytes(&@as(u64, 0x1000))); + const id = try reg.registerComponentRaw(gpa, .{ + .name = "Blank", + .size = 16, + .alignment = 8, + .default_bytes = &default_bytes, + .fields = &[_]FieldDesc{.{ .name = "title", .offset = 0, .kind = .string_ }}, + }); + const kept = std.mem.bytesToValue(persistent.StringSlot, reg.componentDefaultBytes(id)[0..16]); + try std.testing.expectEqual(@as(u64, 0), kept.ptr); + try std.testing.expectEqual(@as(usize, 0), reg.ownedBlocks(id).len); +} + +test "overlapping string slots in a raw default copy exactly the strings the input names" { + const gpa = std.testing.allocator; + var reg = Registry.init(); + defer reg.deinit(gpa); + + const host = "hello"; + var default_bytes = [_]u8{0} ** 20; + @memcpy(default_bytes[0..8], std.mem.asBytes(&@as(u64, @intFromPtr(host.ptr)))); + @memcpy(default_bytes[8..12], std.mem.asBytes(&@as(u32, host.len))); + const id = try reg.registerComponentRaw(gpa, .{ + .name = "Overlapped", + .size = 20, + .alignment = 4, + .default_bytes = &default_bytes, + .fields = &[_]FieldDesc{ + .{ .name = "a", .offset = 0, .kind = .string_ }, + .{ .name = "b", .offset = 4, .kind = .string_ }, + }, + }); + try std.testing.expectEqual(@as(usize, 1), reg.ownedBlocks(id).len); +} + +test "registerComponentRaw refuses a field reaching past its default bytes" { + const gpa = std.testing.allocator; + var reg = Registry.init(); + defer reg.deinit(gpa); + + const default_bytes = [_]u8{0} ** 8; + try std.testing.expectError(RegistryError.FieldOutOfBounds, reg.registerComponentRaw(gpa, .{ + .name = "Short", + .size = 16, .alignment = 8, .default_bytes = &default_bytes, - .fields = &[_]FieldDesc{.{ .name = "xs", .offset = 0, .kind = .array_ }}, + .fields = &[_]FieldDesc{.{ .name = "title", .offset = 0, .kind = .string_ }}, })); } diff --git a/src/core/memory/persistent.zig b/src/core/memory/persistent.zig index 63265a2e..b6cddfb9 100644 --- a/src/core/memory/persistent.zig +++ b/src/core/memory/persistent.zig @@ -7,9 +7,6 @@ //! at init) and the open `TypeId` set are exactly what `string[]` / `[K: V]` / //! `Set` register against, with no Etch coupling in this module. //! -//! Tier 0 and tier-neutral: a container's drop is a callback the Etch runtime -//! registers (`registerDrop`), so this module imports nothing of Etch. -//! //! Layout (`etch-memory-model.md` §4.3 / §5.1). Each block is one system //! allocation laid out as: //! @@ -34,13 +31,11 @@ //! `@fence`-free idiom — `@fence` was removed in Zig 0.16, cf. //! `src/core/jobs/deque.zig`) followed by the type's drop + the block free. //! A block allocated immortal carries `refcount == sentinel` (`u32.max`): -//! `incref` / `decref` are no-ops on it — the registry's copies of string -//! defaults use this path, so a resource slot seeded from its default holds a -//! block no `decref` frees. +//! `incref` / `decref` are no-ops on it. //! //! Self-contained: imports only `std` (no other `src/core` coupling), so it sits -//! cleanly at Tier 0. Consumers are the registry (its string-default copies), -//! the world (the resource payload release), the scene loader and the Etch +//! cleanly at Tier 0. Consumers are the registry, the world, the scene loader +//! and the Etch //! runtime (interp / bridge / cook, through `weld_core.memory`); the Tier-0 //! `ResourceStore` itself stays string-agnostic (it stores the raw `StringSlot` //! bytes). diff --git a/src/core/scene/loader.zig b/src/core/scene/loader.zig index bd8670ae..0f7ac49c 100644 --- a/src/core/scene/loader.zig +++ b/src/core/scene/loader.zig @@ -161,9 +161,7 @@ pub const UuidMap = std.AutoHashMapUnmanaged([16]u8, EntityId); /// lifetime is the caller's to end (`engine-scene-serialization.md` §4). /// /// Ownership: the caller ends the load's life with `deinit` (frees `spawned`, -/// the map, and closes `mmap` if present). Loaded resource `string` blocks are -/// refcounted and owned by their `StringSlot`s, not by the `LoadResult`; -/// `World.deinit` releases them. +/// the map, and closes `mmap` if present). pub const LoadResult = struct { spawned: []EntityId, uuid_to_entity: UuidMap, @@ -233,8 +231,8 @@ fn decrefResourceStrings(world: *const World, gpa: std.mem.Allocator, cid: Compo /// Commit the loader's resource writes. For each resource that /// REPLACED a prior value, decref the old string blocks the snapshot captured — /// they are no longer referenced (the live slot holds the new block). The new -/// blocks stay live, owned by the resource slots, which `World.deinit` -/// releases. Frees each snapshot and the journal. Infallible. +/// blocks stay live, owned by the resource slots. Frees each snapshot and the +/// journal. Infallible. fn commitResources(world: *const World, gpa: std.mem.Allocator, journal: *ResourceJournal) void { for (journal.items) |edit| { if (edit.snapshot) |snap| { @@ -699,12 +697,11 @@ pub fn runtimeDeactivate(world: *World, gpa: std.mem.Allocator, entity: EntityId /// Load the resources block — the load-side mirror of the non-POD /// resource path, following the `ecs_bridge` write discipline: -/// for each resource, snapshot its current bytes, install the POD `data` -/// (string-field slots are zeroed on disk), then for each `string` field intern -/// the cooked value into the **Tier-0 persistent heap** as a **refcounted** block +/// for each resource, snapshot its current bytes, install the POD `data` with +/// every `string` slot zeroed, then for each `string` field intern the cooked +/// value into the **Tier-0 persistent heap** as a **refcounted** block /// (`persistent.alloc`, not immortal) and write its `StringSlot`. The new blocks -/// are owned by the slot, reclaimed by the resource owner's teardown (parity with -/// interp-written strings); the old blocks the snapshot captured are decreffed at +/// are owned by the slot; the old blocks the snapshot captured are decreffed at /// commit. Each touched resource is recorded in `journal` so the load is /// transactional. An empty string keeps the zeroed slot (`ptr == 0`). /// @@ -745,8 +742,7 @@ fn loadResources( // after the resource is mutated (reserve-then-mutate). try journal.ensureUnusedCapacity(gpa, 1); - // Install the POD image (string slots zeroed on disk), capturing the - // pre-write snapshot. For an existing resource the snapshot holds the old + // Install the POD image, capturing the pre-write snapshot. For an existing resource the snapshot holds the old // string slots (decreffed at commit / restored at rollback); for a fresh // one the snapshot is null (rollback removes it). // Capture the pre-write dirty bit BEFORE `getMutResource` @@ -770,6 +766,13 @@ fn loadResources( // rolls the whole resource back (decref partial new blocks + restore). journal.appendAssumeCapacity(.{ .cid = cid, .snapshot = snapshot, .dirty_before = dirty_before }); + // A string slot of `data` is the file's bytes, and the rollback and the + // world decref whatever a slot holds: zero them all before any + // allocation can fail. + for (world.registry.componentFields(cid)) |fd| { + if (fd.kind == .string_) @memset(dst[fd.offset..][0..@sizeOf(persistent.StringSlot)], 0); + } + // Per string field: alloc a REFCOUNTED block owned by the slot, copy // the cooked value, write the new `StringSlot`. for (world.registry.componentFields(cid)) |fd| { @@ -910,6 +913,31 @@ fn buildStringResourceScene(gpa: std.mem.Allocator, reg: *const Registry, res_ci return try writer.write(gpa, model, reg); } +/// Test helper: cook a scene whose string resource carries `garbage` in its +/// slot bytes and no string-table entry for them. +fn buildGarbageStringSlotScene(gpa: std.mem.Allocator, reg: *const Registry, res_cid: ComponentId, garbage: u64) ![]u8 { + var arena = std.heap.ArenaAllocator.init(gpa); + const a = arena.allocator(); + const data = try a.alloc(u8, 16); + @memset(data, 0); + @memcpy(data[0..8], std.mem.asBytes(&garbage)); + @memcpy(data[8..12], std.mem.asBytes(&@as(u32, 5))); + const resources = try a.dupe(format.ResourceEntry, &.{.{ + .schema_id = res_cid, + .data = data, + .string_fields = &.{}, + }}); + var model: format.CookModel = .{ + .strings = &.{}, + .uuids = &.{}, + .resources = resources, + .archetypes = &.{}, + .arena = arena, + }; + defer model.deinit(); + return try writer.write(gpa, model, reg); +} + /// Test helper: cook a scene that spawns one `ecid` entity, overrides string /// resource `settings_cid` with `"new"`, then carries a collection resource /// `bag_cid` (LAST) — so the loader's collection rejection fires AFTER the spawn @@ -1143,6 +1171,21 @@ test "resource strings outlive LoadResult.deinit" { try testing.expectEqualStrings("Verdant Keep", loaded[0..ss.len]); } +test "a string slot's bytes in a scene file never reach the store" { + const gpa = testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + const settings = try registerStringResource(gpa, &world.registry, "Settings"); + + const bytes = try buildGarbageStringSlotScene(gpa, &world.registry, settings, 0xDEAD_BEE0); + defer gpa.free(bytes); + var result = try loadFromBytes(&world, gpa, bytes, null); + result.deinit(gpa); + + const buf = world.resources.getResource(settings).?; + try testing.expectEqualSlices(u8, &([_]u8{0} ** 16), buf[0..16]); +} + test "loading over an existing resource string releases the previous block" { const gpa = testing.allocator; var world = World.init(); diff --git a/src/etch/ecs_bridge.zig b/src/etch/ecs_bridge.zig index c779919e..9b2698b7 100644 --- a/src/etch/ecs_bridge.zig +++ b/src/etch/ecs_bridge.zig @@ -42,11 +42,15 @@ comptime { // `StringSlot` layout (`persistent.zig`) the bridge reads/writes — one // source of truth across the Tier-0 / Etch boundary. std.debug.assert(@sizeOf(persistent.StringSlot) == FieldKind.string_.sizeBytes()); + std.debug.assert(@alignOf(persistent.StringSlot) == FieldKind.string_.alignBytes()); // Same one-source-of-truth guard for the collection slot stride: // `CollectionSlot { ptr }` must match `.array_`/`.map_`/`.set_` sizeBytes. std.debug.assert(@sizeOf(persistent.CollectionSlot) == FieldKind.array_.sizeBytes()); std.debug.assert(@sizeOf(persistent.CollectionSlot) == FieldKind.map_.sizeBytes()); std.debug.assert(@sizeOf(persistent.CollectionSlot) == FieldKind.set_.sizeBytes()); + std.debug.assert(@alignOf(persistent.CollectionSlot) == FieldKind.array_.alignBytes()); + std.debug.assert(@alignOf(persistent.CollectionSlot) == FieldKind.map_.alignBytes()); + std.debug.assert(@alignOf(persistent.CollectionSlot) == FieldKind.set_.alignBytes()); } /// Surfaced so callers of `Bridge.dispatchEntityGet` / diff --git a/src/etch/interp.zig b/src/etch/interp.zig index 35763e3b..35fda9c9 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -7176,7 +7176,7 @@ fn stageBuiltinResource( .fields = fields_buf[0..br.fields.len], }); errdefer entry.deinit(gpa); - var resource: PendingResource = .{ .buf = try ResourceStore.allocBuffer(gpa, default_buf[0..size]), .collection_blocks = &.{} }; + var resource: PendingResource = .{ .buf = try ResourceStore.allocBuffer(gpa, entry.defaultBytes()), .collection_blocks = &.{} }; errdefer resource.deinit(gpa); try bridge.mapResource(gpa, br.name, pending.nextId()); try pending.push(gpa, entry, resource); @@ -7575,9 +7575,8 @@ fn prepareTypeEntry(gpa: std.mem.Allocator, ast: *const AstArena, registry: *con // store's copy at a container. if (fd.kind == .array_ or fd.kind == .map_ or fd.kind == .set_) continue; if (fd.kind == .string_) { - // A literal default points at the AST's bytes, which `prepareEntry` - // copies into a block the entry owns. No default, or a non-literal - // one, leaves the empty string `{ptr=0,len=0}`. + // A non-empty literal points at the AST's bytes; `prepareEntry` + // copies them. Any other default leaves the empty string. if (!f.default_value.isNone() and ast.exprKind(f.default_value) == .string_lit) { const lit = ast.strings.slice(ast.exprData(f.default_value)); if (lit.len != 0) { @@ -8673,8 +8672,8 @@ test "resource string[] whole-field reassignment releases previous backing" { var world = World.init(); defer world.deinit(gpa); - // Literal-array default `["a","b"]` (materialized at addResource), reassigned - // to `["x","y","z"]` on tick 1. + // Literal-array default `["a","b"]` (materialized with the store buffer), + // reassigned to `["x","y","z"]` on tick 1. const source = \\resource Inventory { items: string[] = ["a", "b"] } \\rule reset() diff --git a/src/etch/scene_cook.zig b/src/etch/scene_cook.zig index b56068bd..766cc065 100644 --- a/src/etch/scene_cook.zig +++ b/src/etch/scene_cook.zig @@ -347,7 +347,7 @@ const Builder = struct { /// Free everything NOT owned by the produced model: the bridge and the /// scratch hashmaps. The model arena is transferred to the caller (not freed - /// here); the registry owns the blocks its default bytes point at. + /// here). fn deinitScratch(self: *Builder) void { self.bridge.deinit(self.gpa); self.string_map.deinit(self.gpa); diff --git a/src/etch/value.zig b/src/etch/value.zig index fb6a6a8c..fc936e4b 100644 --- a/src/etch/value.zig +++ b/src/etch/value.zig @@ -131,8 +131,8 @@ pub const Value = union(enum) { /// runtime discriminant, drop dispatched by `type_id`. The read path returns /// it without incref — safe for the rule body because the resource (hence the /// block) outlives it. Never `0` for a live field (the empty collection is a - /// real empty block allocated at `addResource`). String elements are stored - /// as owned `.string_persistent`; POD elements inline. + /// real empty block allocated with the resource's store buffer). String + /// elements are stored as owned `.string_persistent`; POD elements inline. array_persistent: u64, /// A borrowed view over a resource `[K: V]` field's persistent-heap block, /// whose payload is the owned insertion-ordered pair list. Same borrowing and diff --git a/src/etch/zig_codegen/lower.zig b/src/etch/zig_codegen/lower.zig index ab0d909a..a9d938e9 100644 --- a/src/etch/zig_codegen/lower.zig +++ b/src/etch/zig_codegen/lower.zig @@ -174,10 +174,6 @@ pub fn generateFile( try emitSetContainsPrelude(&w); } - // The builtin `TagSet` component: a fixed `[words]u64` bitfield, - // one slot per entity carrying tags. Emitted as an `extern struct` so its - // layout matches the registry's raw `words*8`-byte / align-8 component - // (`etch-abi-zig.md` §3) — byte-exact with the interpreter's `tagSetDesc`. if (tag_table.leaf_count > 0) { try emitTagSetStruct(&w, tag_table.words()); } diff --git a/tests/core/ecs/access_counterproof/build.zig b/tests/core/ecs/access_counterproof/build.zig index 2b7cc4df..859efcbf 100644 --- a/tests/core/ecs/access_counterproof/build.zig +++ b/tests/core/ecs/access_counterproof/build.zig @@ -5,8 +5,8 @@ //! fixtures a correctly wired `weld_core` without this file re-deriving the //! parent's module graph. //! -//! **Why a sub-project and not four objects in the parent build.** Three of the -//! four fixtures must FAIL to compile. A compile step that fails inside the +//! **Why a sub-project and not seven objects in the parent build.** Six of the +//! seven fixtures must FAIL to compile. A compile step that fails inside the //! parent graph fails the parent build, so the failures have to be driven as //! subprocesses — and a subprocess that re-enters the parent's own `build.zig` //! would contend with it for the build cache. Each case therefore gets its own diff --git a/tests/core/ecs/access_counterproof/build.zig.zon b/tests/core/ecs/access_counterproof/build.zig.zon index b0a508b4..92aba2b2 100644 --- a/tests/core/ecs/access_counterproof/build.zig.zon +++ b/tests/core/ecs/access_counterproof/build.zig.zon @@ -16,6 +16,9 @@ "control.zig", "case_undeclared.zig", "case_mutable_on_read.zig", - "case_missing_accesses.zig", + "case_mismatched_pair.zig", + "case_view_promotion.zig", + "case_erased_in_job.zig", + "case_erased_wrapped_in_job.zig", }, } diff --git a/tests/etch/hot_reload_test.zig b/tests/etch/hot_reload_test.zig index 43b60232..57aa069a 100644 --- a/tests/etch/hot_reload_test.zig +++ b/tests/etch/hot_reload_test.zig @@ -684,6 +684,7 @@ fn reloadWithTwoFaults(gpa: std.mem.Allocator, oversized_first: bool) !void { var base = try weld_etch.parseSource(gpa, src_counter); defer base.deinit(gpa); + try typeCheckClean(gpa, &base.ast); var pr = try weld_etch.parseSource(gpa, src.items); defer pr.deinit(gpa); try typeCheckClean(gpa, &pr.ast); @@ -708,6 +709,7 @@ test "a reload with two refusals reports the first declaration's: the widened on fn compileAllocations(gpa: std.mem.Allocator, base: ?[]const u8, src: []const u8) !u64 { var base_pr = if (base) |b| try weld_etch.parseSource(gpa, b) else null; defer if (base_pr) |*p| p.deinit(gpa); + if (base_pr) |*p| try typeCheckClean(gpa, &p.ast); var pr = try weld_etch.parseSource(gpa, src); defer pr.deinit(gpa); try typeCheckClean(gpa, &pr.ast); @@ -723,7 +725,7 @@ fn compileAllocations(gpa: std.mem.Allocator, base: ?[]const u8, src: []const u8 return n; } -test "a reload evaluates none of the defaults of a type already registered" { +test "a reload allocates nothing for the defaults of a type already registered" { const gpa = std.testing.allocator; const bare = "resource R { s: string }\n"; const defaulted = "resource R { s: string = \"abc\" }\n"; @@ -772,7 +774,7 @@ test "every type a compile registers without the program declaring it has a rese defer it.deinit(); const n = world.registry.componentCount(); - try std.testing.expectEqual(1 + weld_etch.types.builtin_resources.len, n); + try std.testing.expect(n >= 1 + weld_etch.types.builtin_resources.len); for (0..n) |id| { const name = world.registry.componentName(@intCast(id)); if (!weld_etch.types.isReservedEngineTypeName(name)) { diff --git a/tests/scene/load_resources_test.zig b/tests/scene/load_resources_test.zig index 3ebb6563..f67cc362 100644 --- a/tests/scene/load_resources_test.zig +++ b/tests/scene/load_resources_test.zig @@ -1,9 +1,4 @@ -//! Resource `string` fields round-trip through the Tier-0 persistent -//! heap. Cooks (in-memory, via the writer) a scene with one resource carrying a -//! `string` field, loads it, and asserts the field reads back the cooked value: -//! the loaded string is interned into `weld_core.memory.persistent` as a -//! **refcounted** block owned by the resource's `StringSlot` and NOT by -//! `LoadResult`, which `World.deinit` releases. `weld_core` only. +//! `weld_core` only: cooks in memory through the writer, then loads. const std = @import("std"); const weld_core = @import("weld_core"); diff --git a/tools/shader_compiler/main.zig b/tools/shader_compiler/main.zig index 0fe51fad..ac442a4a 100644 --- a/tools/shader_compiler/main.zig +++ b/tools/shader_compiler/main.zig @@ -14,7 +14,7 @@ //! `shaders-check` mode: //! - Compiles into a temp folder. //! - Diff vs the committed `.spv`. Exit code 0 if diff empty, non-zero otherwise. -//! - The CI step `shaders-check` blocks the merge on a diff. +//! - No CI workflow runs `shaders-check`. const std = @import("std"); const shader = @import("shader_pipeline_compiler"); @@ -46,9 +46,7 @@ pub fn main(init: std.process.Init) !void { try stdout.print("shader_compiler: glslc not in PATH — skipping ({s} mode)\n", .{ if (args.check) "check" else "build", }); - // In check mode, the absence of glslc is not blocking: we - // assume the committed .spv are valid (the Linux CI that has - // glslc is the authority). + // Without glslc, check mode compares nothing and still exits 0. return; } diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 02d6619a..c19ac2ed 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -237,8 +237,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2409, - else => 2411, + .windows => 2413, + else => 2415, }; } From e1ee3287ec9ab2206f2d9688f015683addc6d10b Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 23 Sep 2026 10:13:31 +0200 Subject: [PATCH 015/141] docs(brief): record M1.D/S5/G8 bis and what it raises The three found items settled, the two bounds closed, the three locks and their counter-factuals, what the sweep brought back and closed, and seven findings raised at the stop for arbitration, each measured. Floor 2403 -> 2415, windows 2413. Co-Authored-By: Claude Opus 5.5 --- briefs/m1.d-phase-1-debt.md | 241 ++++++++++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index e079de15..2f3a1df5 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -5091,6 +5091,247 @@ sticky allocator. **Floor 2395 → 2403, windows 2401**, re-derived from the suite: seven tests in `hot_reload_test.zig` and one in `levelb_ir_diff_test.zig`. +### S5/G8 bis — the three found, the two bounds, the three locks + +Code in `cfb5f8be` and `5708fceb`. Guy's directive: whatever G8 found and did not +fix is settled here, and nothing is noted for later, the brief included. + +#### `vk-gen-check` checks something + +The step looked `bindgen-verify` up by name before that step existed, so the +lookup returned `null` and the step depended on nothing. It now depends on the +`bindgen_verify_step` variable, declared after it, so a reordering no longer +compiles instead of going silent. The two witnesses were measured, first with +`bindgen-verify`'s known-good control intact, then with it broken +(`git --no-such-flag`): + +| | control intact | control broken | +|---|---|---| +| before | exit 0, `1/1 steps` | `bindgen-verify` exit 1, `vk-gen-check` **exit 0**, `1/1 steps` | +| after | exit 0, `10/10 steps` | exit 1, `6/10 steps (1 failed)` | + +`top_level_steps` had no other reader in the repository, sub-project builds +included. + +#### S5/G4's claim on `tagset_component_name`, made true + +The claim was *« `TagSet` gains a named constant so the reservation and the +injection cannot disagree »*. The measurement that contradicted it was taken at +the G4 commit `40d45cb9`: +- `interp.zig` spelled `"TagSet"` as a literal on six lines: five production + sites and one test. +- The constant was read nowhere outside `types.zig`. +- The codegen had its own literals. Two were registry keys (the registration + name and the `idOf` lookup), five were component-list keys, and seven were + Zig identifiers derived from the name. + +Correcting the claim could have meant rewriting the sentence. That would have +left a real disagreement open, which the milestone's rule forbids, so the code +now makes the claim true: +- In the interpreter, the lookup, both bridge mappings, the descriptor name and + the refusal message read the constant. +- In the codegen, every registry key, list key and derived identifier is built + from it by comptime concatenation. +- The constant's doc now states the contract instead of the precondition. + +The emitted source is unchanged, and this was measured rather than argued. Five +programs of the corpus declare tags (43, 68, 69, 70 and 84). Cooked with +`HEAD`'s `lower.zig` and with the new one, all five compare byte-identical: 5 777, +67 931, 67 309, 67 408 and 144 509 bytes, 42 occurrences of `TagSet` in all. + +The lock is a behaviour, not a grep: *every type a compile registers without the +program declaring it has a reserved name*. Its counter-factuals are two. +- **D** registers TagSet under a literal the reservation does not hold: the lock + goes red, with twelve other tests. +- **E** renames the constant itself: the lock and the Zig-requisite test stay + green, which shows the injection follows the constant; the five tests that + spell `"TagSet"` go red, and so does the codegen diff. + +#### `etch-grammar.md` §1.5 against §21.4, closed by Guy + +Spec checked at `sha256 950fd344…`, 2 287 lines: §1.5 gives +`annotation_args = annotation_arg , { "," , annotation_arg } , [ "," ]`, and +§21.4 no longer defines `annotation`. The parser comment now cites §1.5. The +parser test that cited a grammar line number for both call and annotation +commas is renamed so it cites none. + +#### The raw path follows the ownership rule G8 set + +G8 made the entry own the blocks its default bytes point at, on the Etch path. +The way chosen to extend it: `prepareEntry` itself **copies** every non-empty +string default into an immortal block the entry owns, and rewrites the slot. + +The motive is that the registry already copied every other input (name, +default bytes, field names, requires). Copying the strings too means the caller +keeps everything it passed, on every path, and there is one mechanism instead of +two. + +Set aside: a handover parameter on the raw path. It would have changed a frozen +signature, and it would have enforced nothing, since a host could still pass a +pointer it had not handed over. The interpreter no longer allocates a default +block: its slot points at the AST's bytes and `prepareEntry` copies them. + +Two refusals are raised before any allocation: +- **`CollectionDefaultNotEmpty`**, for a collection field whose default slot is + not zero. Tier 0 cannot copy a container. +- **`FieldOutOfBounds`**, for a field reaching past the default bytes. This + second one came from the verification pass. The first form of the copy + re-read, in its second pass, bytes its first pass had rewritten. Two + overlapping string slots could then make the copies outnumber their count, + and `blocks[made]` wrote past the array. + +Both passes now read the caller's immutable bytes, so the count equals the +copies by construction. The bounds refusal makes every slice of the copy safe in +all modes. + +An empty default is written `{0,0}` on both paths. That is the form the loader +and the runtime writes already produced; only `= ""` produced a zero-length +block with a non-zero pointer. The builtin time resources now seed their store +buffer from the entry's bytes, as every other resource does. + +#### The 64 KiB test's "registers nothing" half, counter-factualled + +Mutant: a failing resource declaration commits what was staged before it. +Predicted red: the 64 KiB test and the two sweeps that register new types. Green: +the rest of the step. Measured: exactly those three red, 16 green, and the +64 KiB test fell on `expected 0, found 1`, which is its "registers nothing" half. + +#### The three locks + +| lock | form of the test | counter-factual | red | stayed green | +|---|---|---|---|---| +| refusal order | a reload holding an oversized new resource and a widened live `Counter`, in both orders: `LayoutTooLarge` first, `SchemaChanged` first | **A** — a pre-pass confronts every live name before staging | the oversized-first test: `expected error.LayoutTooLarge, found error.SchemaChanged` | the other order, and the rest | +| defaults not evaluated on reload | allocations of a reload compile with and without a string default are equal; control on a fresh world: they differ | **B** — a live declaration's defaults evaluated and discarded | that test only | the rest | +| closures see TagSet and the builtins | a Zig component requiring `TagSet` / `GameTime` registered before the first compile resolves | **C** — closures computed before TagSet and the builtins are staged | both tests only | the rest | + +The allocation form was chosen because it does not depend on an unchecked AST. +Its title says what it measures, *a reload allocates nothing for the defaults…*: +a default whose evaluation allocates nothing is not observable this way. + +#### Found by the sweep and closed here + +A read-only sweep of five lenses and a critic ran over a snapshot of `9d2609cf`, +followed by an adversarial pass of four lenses whose twenty findings each went +to a refuter: **twenty held, none refuted.** What was closed: + +- **Five decrefs on freed memory, every suite run.** Four loader tests and + `load_resources_test` released a resource string by hand without zeroing the + slot, then `World.deinit` released it again. This had been silent since HF2: + the second decref read a refcount of `0xaaaaaaaa`, the debug allocator's free + pattern, and wrote into freed memory. + - Measured by a probe on every payload release in the suite. Before: 5 at + `0xaaaaaaaa`, 9 live, 542 immortal. After: 0, 14, 542. + - The manual releases are removed; `World.deinit` is the one place that frees. +- **A scene file could put a pointer nobody owns in the store.** The loader + copied a resource's raw data and rewrote only the string slots the string + table names. Every string slot is now zeroed before any allocation of the + install. Test: a crafted file with `0xDEAD_BEE0` at a string offset loads and + the slot reads zero. Counter-factual **I** crashes it. +- **False build claims**: + - the "RENDER IS HELD" note above two lines that wire the render tests; + - "three" stub plugins, where there are four; + - "three" counter-proof refusals, where there are six; + - the counter-proof sub-project's header and manifest, which named a fixture + deleted at M1.A and omitted four; + - `shaders-check`'s claim that CI blocks on it: no workflow runs it. +- **`forge-asm-inventory` inventoried f32 only.** It inherited + `physics_f64 = false` and CI passed no precision, so the two cells that run it + both inventoried f32. It now takes the cell's precision. Measured locally + first: the f64 inventory passes, 43 471 call sites against 43 225 at f32. +- **Ownership docs made false by G8 or by this gate.** Corrected in `registry.zig`, + `persistent.zig`, `loader.zig`, `scene_cook.zig`, `ecs_bridge.zig`, `value.zig` + and `lower.zig`, then reduced so each fact stands once, where its absence would + allow the wrong fix. `persistent.zig` claimed the slot alignments were asserted + in `ecs_bridge.zig`; they were not, and the asserts are now there. + +#### Counter-factuals of the new forms + +| form removed | red | stayed green | +|---|---|---| +| **I** — the loader's zeroing of string slots | the crafted-file test (abort) | the rest of the suite | +| **K** — the copy's second pass reads the rewritten bytes | the overlapping-slot test (abort) | the rest | +| **L** — the bounds refusal | the out-of-bounds test (abort) | the rest | +| **M** — the `{0,0}` normalisation | the empty-default test | the rest | +| **F2** — no copy: the entry keeps the caller's pointer | the three raw-path copy tests, the three sweeps, both teardown tests, the reload-allocation test, one interpreter test | the rest | +| **G2** — the collection refusal | the refusal test, over `.array_`, `.map_` and `.set_` | the rest | +| **H** — `World.deinit` releases nothing | the five tests that had released by hand, by leak, with 20 others | — | + +#### Raised at the stop, for arbitration + +Each was measured and none is fixed here: every one is either a language or +format question, or a class too wide to close by instance. + +1. **The type-checker enforces no literal range.** `etch-resolver-types.md` §4.3 + requires that a literal in a typed context fit the expected type. The single + site that applies it, `literalTypeFits` (about thirty callers), checks only + the literal's family. + - Measured, with both controls firing: `v: i32 = 3000000000`, + `v: u32 = -1`, `v: u32 = 5000000000`, an out-of-range literal assigned to + an `i32` field, a `let x: i32`, an `i32` argument and a `const` are all + accepted. + - Consequence: the first `compile` of `component C { v: i32 = 3000000000 }` + panics (`integer does not fit in destination type`) in Debug, which is + undefined behaviour in ReleaseFast. + - The tree-walker otherwise checks its arithmetic and reports + `IntegerOverflow`; `writeValueAsBytes` narrows with a raw `@intCast`. + - To arbitrate: the float half of §4.3's « Idem », the overflow of constant + arithmetic in a default (`evalConst` fails and `catch continue` writes + zero), and wrap-in-release (spec) against the tree-walker's typed error. +2. **`named_types` indexed on nodes that are not `.named`.** + - About twenty codegen sites and two interpreter sites read + `named_types.items[typeNodeData(node)]` with no kind guard, where the + data indexes the node's own slab. + - Measured: `resource R { xs: int[] }` cooks to `xs: i64` with + `FieldKind.int_`, a silent miscompile; the interpreter registers `.array_`. + - The verification pass traced the same read on fn and method parameters and + return types, `throws` returns, and rule parameters in both backends. For + `T?` the read can go out of bounds. + - One accessor that answers null on another kind would close the class; it + is not closed by instance here. +3. **A collection field's default is never type-checked.** `xs: int[] = 5` and + `xs: int[] = "no"` pass with no diagnostic, against a control + (`n: int = "no"`) that fires. +4. **`parseAnnotationArg` diverges from §1.5**, measured with controls: + - `@tag(v as f32)` is refused, while `@tag(1 as f32)` and + `@tag(k: v as f32)` parse; + - `@tag(none)` becomes an identifier and `@tag(some(1))` a call, where the + named form gives `none_lit`; + - `@tag(Name: 1)` is refused. +5. **Build-graph false greens that need a decision:** + - the capture PSNR test returns `SkipZigTest` on every CI cell, since no step + builds the triangle it spawns; + - `shaders-check` exits 0 without `glslc`; + - `examples/vertical_slice/math.zig` holds a dead test outside the + dead-test guard's scan roots; + - the nested builds forward neither `-Dcpu` nor `-Dphysics_f64`. +6. **The `asset` reader claims "exactly the §21.4 value grammar"** and refuses + the optional comma after a field that `asset_field` allows. This was read in + `parseFields`. +7. **A Zig requisite may name a resource.** The lock written at Guy's request + pins that `GameTime` resolves as a requisite. The registry cannot tell a + component from a resource, so adding such a component would add the + resource's type to the entity. Whether that should be refused is a question + about the registry. + +#### Gates + +On the final code: +- `zig build test`, Debug and ReleaseSafe at f32, and Debug at + `-Dphysics_f64=true`: exit 0, `316/316 steps; 2396/2415 tests passed + (19 skipped)` in all three. +- `zig build lint`: exit 0, conservation OK at 2415. +- `zig fmt --check .`: exit 0. +- `bindgen-verify`: exit 0. +- `vk-gen-check`: exit 0, `10/10 steps`. +- `forge-determinism`: exit 0, `none within K=60`. +- `test-codegen-diff`: exit 0. +- `ecs-access-counterproof`: exit 0, `8/8 steps`. +- `forge-asm-inventory -Dphysics_f64=true`: exit 0. + +**Floor 2403 → 2415, windows 2413**, re-derived from the suite. The twelve new +tests: six in `hot_reload_test.zig`, five inline in `registry.zig` and one inline +in `loader.zig`. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree From ba10b3cbbd1fb3a2d54974d663209336dfe5ead1 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 23 Sep 2026 14:12:32 +0200 Subject: [PATCH 016/141] fix(etch): read a type node's name only when it is named (S5/G8 ter) A type node's `data` indexes the slab of its own kind. Forty-five sites read `named_types` through it, and eleven had no guard on the kind: a resource `xs: int[]` lowered as `xs: i64` with `FieldKind.int_`, a rule parameter `xs: int[]` beside an `Entity` one was bound to the entity, and an optional node's data read far out of bounds. `AstArena.namedTypeName` returns null for every kind but `.named`, and all forty-five reads go through it, each writing its non-named branch: the codegen refuses (`UnsupportedConstruct`; `fnTypeZig` becomes fallible), the interpreter binds `.unit`, skips, or refuses (`InvalidProgram`, `RuntimeFailure`), and the guarded sites keep the branch they had. Two adjacents on the same lines. `checkRule` now refuses every non-named rule parameter with the message it already carried, instead of only an unresolvable one. The interpreter resolves `type` aliases on rule parameters as the codegen and the checker do, so `type Ent = Entity` makes the entity parameter and `type Seconds = float` binds a float. `no_raw_named_type_read` flags any `.named_types` access outside `src/etch/ast.zig`, with a bad fixture run through the real binary. Floor 2415 -> 2436 / 2434, measured: 2417/2436 passed, 19 skipped. Co-Authored-By: Claude Opus 5.5 --- build.zig | 2 + src/etch/ast.zig | 27 ++-- src/etch/descriptor.zig | 24 ++-- src/etch/interp.zig | 119 ++++++++++++++---- src/etch/types.zig | 93 ++++++-------- src/etch/zig_codegen/lower.zig | 116 ++++++++--------- tests/etch/type_node_kind_test.zig | 110 ++++++++++++++++ .../bad/raw_named_type_read/index_by_data.zig | 8 ++ tests/lint/runner_test.zig | 5 + tools/weld_lint/dead_tests.zig | 4 +- tools/weld_lint/main.zig | 10 +- .../rules/no_raw_named_type_read.zig | 84 +++++++++++++ tools/weld_lint/tests.zig | 1 + 13 files changed, 425 insertions(+), 178 deletions(-) create mode 100644 tests/etch/type_node_kind_test.zig create mode 100644 tests/lint/bad/raw_named_type_read/index_by_data.zig create mode 100644 tools/weld_lint/rules/no_raw_named_type_read.zig diff --git a/build.zig b/build.zig index 22f163d5..68111384 100644 --- a/build.zig +++ b/build.zig @@ -964,6 +964,8 @@ pub fn build(b: *std.Build) void { // and writes its row, and the codegen refuses a sparse program. // `.etch = true` for `weld_etch`; `weld_core` is unconditional here. .{ .path = "tests/etch/storage_mode_test.zig", .etch = true }, + // Every reader of a type node's name decides its non-`.named` branch. + .{ .path = "tests/etch/type_node_kind_test.zig", .etch = true }, // one test per type-checker diagnostic code: each names its code and // asserts PRESENCE, so it reddens the day emission stops. .{ .path = "tests/etch/diagnostic_coverage_test.zig", .etch = true }, diff --git a/src/etch/ast.zig b/src/etch/ast.zig index 88117a52..0df73433 100644 --- a/src/etch/ast.zig +++ b/src/etch/ast.zig @@ -3089,15 +3089,11 @@ pub const AstArena = struct { outer: while (guard <= max) : (guard += 1) { for (self.type_alias_decls.items) |alias| { if (alias.name == current) { - // A `.path` alias target (`type HA = m.Member`) - // has no single ultimate name in this arena — stop the - // by-name chain here (returning `current`) rather than - // mis-indexing `named_types`. The qualified target is - // resolved by node kind at the consult sites (a `.path` - // TypeNode → `resolvePathTypeNode`), not by this walk. - if (self.typeNodeKind(alias.target) != .named) break :outer; - const named = self.named_types.items[self.typeNodeData(alias.target)]; - current = named.name; + // A `.path` alias target (`type HA = m.Member`) has no + // single ultimate name in this arena: the chain stops at + // `current`, and the consult sites resolve the qualified + // target by node kind (`resolvePathTypeNode`). + current = self.namedTypeName(alias.target) orelse break :outer; continue :outer; } } @@ -3120,9 +3116,8 @@ pub const AstArena = struct { /// shape is NOT `Entity` — the `await entity_event` target must be a bare /// `Entity` (§9.4). pub fn fieldTypeIsEntity(self: *const AstArena, field: Field) bool { - if (self.typeNodeKind(field.type_node) != .named) return false; - const named = self.named_types.items[self.typeNodeData(field.type_node)]; - return std.mem.eql(u8, self.strings.slice(self.resolveTypeAliasName(named.name)), "Entity"); + const name = self.namedTypeName(field.type_node) orelse return false; + return std.mem.eql(u8, self.strings.slice(self.resolveTypeAliasName(name)), "Entity"); } /// Resolve the event's designated `Entity` field for `await entity_event` @@ -4003,6 +3998,14 @@ pub const AstArena = struct { return self.type_nodes.items(.data)[id.index]; } + /// The name a `.named` type node carries, or null for any other kind. Every + /// kind's `data` indexes its own slab, so reading `named_types` through the + /// data of a non-`.named` node selects an unrelated name. + pub fn namedTypeName(self: *const AstArena, id: NodeId) ?StringId { + if (self.typeNodeKind(id) != .named) return null; + return self.named_types.items[self.typeNodeData(id)].name; + } + pub fn isEmpty(self: *const AstArena) bool { return self.items.len == 0; } diff --git a/src/etch/descriptor.zig b/src/etch/descriptor.zig index d35ec1bf..c2212f87 100644 --- a/src/etch/descriptor.zig +++ b/src/etch/descriptor.zig @@ -837,16 +837,11 @@ fn buildLocale(gpa: std.mem.Allocator, arena: *const AstArena, decl: ast_mod.Loc /// `renderAbilityRuleAlloc` precedent; a generic/compound type fails loud). /// SHARED by both backends so a params-block field renders identically. pub fn renderFieldTypeAlloc(gpa: std.mem.Allocator, arena: *const AstArena, type_node: NodeId) BuildError![]u8 { - // A non-named field type (collection `.slice`/`.map_type`/`.set_type`, tuple, - // function, …) has no descriptor-construct surface: fail loud. Collections in - // particular type-check ONLY on `resource` (cooked via `interp.compileTypeDecl`, - // not this path), so a collection type node is unreachable here for a valid - // program — the rejection is the cook's defensive fail-loud contract. A - // collection-specific error was weighed and refused: it would ripple through - // ~15 codegen `BuildError` switches for zero behavioural gain, both mapping - // to `UnsupportedConstruct`. - if (arena.typeNodeKind(type_node) != .named) return error.UnsupportedDescriptorExpr; - return try gpa.dupe(u8, arena.strings.slice(arena.named_types.items[arena.typeNodeData(type_node)].name)); + // A non-named field type has no descriptor-construct surface. Collections + // type-check only on `resource`, which cooks through `interp.compileTypeDecl` + // and not through this path. + const name = arena.namedTypeName(type_node) orelse return error.UnsupportedDescriptorExpr; + return try gpa.dupe(u8, arena.strings.slice(name)); } /// Render a statement run (a `(start, len)` slice of `arena.extra`) to "; "-joined @@ -1699,12 +1694,10 @@ pub fn renderShaderStageAlloc(gpa: std.mem.Allocator, arena: *const AstArena, he if (p != 0) try out.appendSlice(gpa, ", "); try out.appendSlice(gpa, arena.strings.slice(param.name)); try out.appendSlice(gpa, ": "); - if (arena.typeNodeKind(param.type_node) != .named) return error.UnsupportedDescriptorExpr; - try out.appendSlice(gpa, arena.strings.slice(arena.named_types.items[arena.typeNodeData(param.type_node)].name)); + try out.appendSlice(gpa, arena.strings.slice(arena.namedTypeName(param.type_node) orelse return error.UnsupportedDescriptorExpr)); } try out.appendSlice(gpa, ") -> "); - if (arena.typeNodeKind(stage.return_type) != .named) return error.UnsupportedDescriptorExpr; - try out.appendSlice(gpa, arena.strings.slice(arena.named_types.items[arena.typeNodeData(stage.return_type)].name)); + try out.appendSlice(gpa, arena.strings.slice(arena.namedTypeName(stage.return_type) orelse return error.UnsupportedDescriptorExpr)); try out.appendSlice(gpa, " { "); var st: u32 = 0; while (st < stage.body_len) : (st += 1) { @@ -2107,8 +2100,7 @@ pub fn renderAbilityRuleAlloc(gpa: std.mem.Allocator, arena: *const AstArena, ru if (p != 0) try out.appendSlice(gpa, ", "); try out.appendSlice(gpa, arena.strings.slice(param.name)); try out.appendSlice(gpa, ": "); - if (arena.typeNodeKind(param.type_node) != .named) return error.UnsupportedDescriptorExpr; - try out.appendSlice(gpa, arena.strings.slice(arena.named_types.items[arena.typeNodeData(param.type_node)].name)); + try out.appendSlice(gpa, arena.strings.slice(arena.namedTypeName(param.type_node) orelse return error.UnsupportedDescriptorExpr)); } try out.appendSlice(gpa, ")"); if (rule.when_root != ast_mod.RuleDecl.none_when) { diff --git a/src/etch/interp.zig b/src/etch/interp.zig index 35fda9c9..334ab0f8 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -4437,9 +4437,9 @@ pub const Interpreter = struct { if (self.ast.exprKind(let.value) == .struct_lit) { const sl = self.ast.struct_lits.items[self.ast.exprData(let.value)]; if (sl.type_name == 0) { - if (let.type_annotation.isNone() or self.ast.typeNodeKind(let.type_annotation) != .named) return error.RuntimeFailure; - const named = self.ast.named_types.items[self.ast.typeNodeData(let.type_annotation)]; - break :blk try self.evalStructLitAs(world, locals, sl, self.ast.resolveTypeAliasName(named.name)); + if (let.type_annotation.isNone()) return error.RuntimeFailure; + const annotated = self.ast.namedTypeName(let.type_annotation) orelse return error.RuntimeFailure; + break :blk try self.evalStructLitAs(world, locals, sl, self.ast.resolveTypeAliasName(annotated)); } } break :blk try self.evalExpr(world, locals, let.value); @@ -4901,9 +4901,7 @@ pub const Interpreter = struct { /// `null` when the field is not enum-typed or the variant is unknown /// (the resolver has already rejected those programs). fn enumFieldShorthand(self: *Interpreter, f: ast_mod.Field, value: NodeId) ?Value { - if (self.ast.typeNodeKind(f.type_node) != .named) return null; - const named = self.ast.named_types.items[self.ast.typeNodeData(f.type_node)]; - const ename = self.ast.resolveTypeAliasName(named.name); + const ename = self.ast.resolveTypeAliasName(self.ast.namedTypeName(f.type_node) orelse return null); const edecl = self.enum_decls.get(ename) orelse return null; // Expression-position `tag_path` data IS the variant ident (the // parser interns it directly; multi-segment is a parse error there). @@ -4933,9 +4931,7 @@ pub const Interpreter = struct { /// declared-type lookup as `enumFieldShorthand`, for the anonymous /// `.{ … }` field-value resolution. fn structFieldTypeName(self: *Interpreter, f: ast_mod.Field) ?StringId { - if (self.ast.typeNodeKind(f.type_node) != .named) return null; - const named = self.ast.named_types.items[self.ast.typeNodeData(f.type_node)]; - const sname = self.ast.resolveTypeAliasName(named.name); + const sname = self.ast.resolveTypeAliasName(self.ast.namedTypeName(f.type_node) orelse return null); if (self.struct_decls.get(sname) == null) return null; return sname; } @@ -6245,8 +6241,8 @@ pub const Interpreter = struct { // storage concern handled on write, not in the Value. const c = self.ast.casts.items[data]; const v = try self.evalExpr(world, locals, c.operand); - const named = self.ast.named_types.items[self.ast.typeNodeData(c.type_node)]; - const tname = self.ast.strings.slice(self.ast.resolveTypeAliasName(named.name)); + const target = self.ast.namedTypeName(c.type_node) orelse return error.RuntimeFailure; + const tname = self.ast.strings.slice(self.ast.resolveTypeAliasName(target)); const to_float = std.mem.eql(u8, tname, "float") or std.mem.eql(u8, tname, "f32") or std.mem.eql(u8, tname, "f64"); return switch (v) { .int_ => |x| if (to_float) Value{ .float_ = @floatFromInt(x) } else Value{ .int_ = x }, @@ -6706,8 +6702,8 @@ fn bindParams( while (i < rule.params_len) : (i += 1) { const p = ast.rule_params.items[rule.params_start + i]; const v: Value = blk: { - const tnode = ast.named_types.items[ast.typeNodeData(p.type_node)]; - const tname = ast.strings.slice(tnode.name); + const declared = ast.namedTypeName(p.type_node) orelse break :blk Value{ .unit = {} }; + const tname = ast.strings.slice(ast.resolveTypeAliasName(declared)); if (std.mem.eql(u8, tname, "Entity")) { if (entity_id) |id| break :blk Value{ .entity_id = id }; break :blk Value{ .entity_id = value_mod.invalid_entity }; @@ -7464,17 +7460,14 @@ fn computeLayout( const f = ast.fields.items[fields_start + f_i]; var enum_type_id: u32 = 0; const kind: FieldKind = kb: { - // a resource `T[]` field is a `.slice` type node (NOT - // `.named`): map it to `.array_` (a CollectionSlot) BEFORE the named- - // type decode below, which would mis-index `named_types`. Resource- - // only (validator-gated). Fixed `T[N]` (`.array`) and `.map_type` / - // `.set_type` are out of the surface. + // A resource collection field (`T[]`, `[K: V]`, `Set`) is a + // CollectionSlot. Resource-only (validator-gated). if (reg_kind == .resource and ast.typeNodeKind(f.type_node) == .slice) break :kb .array_; if (reg_kind == .resource and ast.typeNodeKind(f.type_node) == .map_type) break :kb .map_; if (reg_kind == .resource and ast.typeNodeKind(f.type_node) == .set_type) break :kb .set_; - const tnode = ast.named_types.items[ast.typeNodeData(f.type_node)]; + const type_name = ast.namedTypeName(f.type_node) orelse return error.InvalidProgram; // Resolve through any top-level `type` alias chain. - const resolved_name_id = ast.resolveTypeAliasName(tnode.name); + const resolved_name_id = ast.resolveTypeAliasName(type_name); const tname = ast.strings.slice(resolved_name_id); if (fieldKindFromTypeName(tname, reg_kind)) |k| break :kb k; // Enum resource field: a declared enum type, resource-only @@ -7900,8 +7893,8 @@ fn compileRule( var p_i: u32 = 0; while (p_i < rule.params_len) : (p_i += 1) { const p = ast.rule_params.items[rule.params_start + p_i]; - const tnode = ast.named_types.items[ast.typeNodeData(p.type_node)]; - const tname = ast.strings.slice(tnode.name); + const declared = ast.namedTypeName(p.type_node) orelse continue; + const tname = ast.strings.slice(ast.resolveTypeAliasName(declared)); if (std.mem.eql(u8, tname, "Entity")) { entity_param_name = p.name; break; @@ -16195,3 +16188,85 @@ test "a sync with no arena value in scope stays accepted" { \\} , .rule_arena_value_escapes)); } + +/// Compiles `source` with no type-check, which is how a caller that skips the +/// checker reaches the interpreter. +fn compileUnchecked(gpa: std.mem.Allocator, pr: *const parser_mod.ParseResult, world: *World) !Interpreter { + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + return Interpreter.compile(gpa, &pr.ast, world); +} + +test "a component collection field is refused, not laid out as a named type" { + const gpa = std.testing.allocator; + var pr = try parser_mod.parse(gpa, "component C { xs: int[] }"); + defer pr.deinit(gpa); + var world = World.init(); + defer world.deinit(gpa); + try std.testing.expectError(error.InvalidProgram, compileUnchecked(gpa, &pr, &world)); +} + +test "the same component with a named field type compiles" { + const gpa = std.testing.allocator; + var pr = try parser_mod.parse(gpa, "component C { xs: int }"); + defer pr.deinit(gpa); + var world = World.init(); + defer world.deinit(gpa); + var interp = try compileUnchecked(gpa, &pr, &world); + interp.deinit(); +} + +test "a non-named rule parameter is never taken for the entity parameter" { + const gpa = std.testing.allocator; + var pr = try parser_mod.parse(gpa, + \\rule q(e: Entity) {} + \\rule r(xs: int[]) {} + ); + defer pr.deinit(gpa); + var world = World.init(); + defer world.deinit(gpa); + var interp = try compileUnchecked(gpa, &pr, &world); + defer interp.deinit(); + try std.testing.expect(interp.rule_descs[0].entity_param_name != null); + try std.testing.expectEqual(@as(?StringId, null), interp.rule_descs[1].entity_param_name); +} + +test "a non-named rule parameter is bound as unit, not as the entity" { + const gpa = std.testing.allocator; + var pr = try parser_mod.parse(gpa, "rule r(e: Entity, xs: int[]) {}"); + defer pr.deinit(gpa); + const rule = pr.ast.rule_decls.items[0]; + var locals: Locals = .{}; + defer locals.deinit(gpa); + try bindParams(gpa, &pr.ast, rule, null, &locals); + const xs = pr.ast.rule_params.items[rule.params_start + 1].name; + try std.testing.expect(locals.get(xs).? == .unit); +} + +test "a rule parameter typed by an alias of Entity is the entity parameter" { + const gpa = std.testing.allocator; + var pr = try parser_mod.parse(gpa, + \\type Ent = Entity + \\rule r(e: Ent) {} + ); + defer pr.deinit(gpa); + var world = World.init(); + defer world.deinit(gpa); + var interp = try compileUnchecked(gpa, &pr, &world); + defer interp.deinit(); + try std.testing.expect(interp.rule_descs[0].entity_param_name != null); +} + +test "a rule parameter typed by an alias of a scalar is bound as that scalar" { + const gpa = std.testing.allocator; + var pr = try parser_mod.parse(gpa, + \\type Seconds = float + \\rule r(e: Entity, dt: Seconds) {} + ); + defer pr.deinit(gpa); + const rule = pr.ast.rule_decls.items[0]; + var locals: Locals = .{}; + defer locals.deinit(gpa); + try bindParams(gpa, &pr.ast, rule, null, &locals); + const dt = pr.ast.rule_params.items[rule.params_start + 1].name; + try std.testing.expect(locals.get(dt).? == .float_); +} diff --git a/src/etch/types.zig b/src/etch/types.zig index 6d095f18..91c4a9d7 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -388,9 +388,8 @@ fn methodKey(type_name: StringId, method_name: StringId) u64 { /// `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)]; - const resolved = decl_arena.resolveTypeAliasName(named.name); + const name = decl_arena.namedTypeName(type_node) orelse return null; + const resolved = decl_arena.resolveTypeAliasName(name); const tname = decl_arena.strings.slice(resolved); if (std.mem.eql(u8, tname, "string")) return .string_; return BuiltinType.fromName(tname); @@ -1071,9 +1070,8 @@ pub const TypeChecker = struct { /// same reason as `foreignReturnType`. fn foreignFieldType(self: *TypeChecker, a: *const AstArena, type_node: NodeId) ResolvedType { _ = self; - if (a.typeNodeKind(type_node) != .named) return ResolvedType.unknown; - const named = a.named_types.items[a.typeNodeData(type_node)]; - const tname = a.strings.slice(a.resolveTypeAliasName(named.name)); + const name = a.namedTypeName(type_node) orelse return ResolvedType.unknown; + const tname = a.strings.slice(a.resolveTypeAliasName(name)); if (std.mem.eql(u8, tname, "string")) return .{ .builtin = .string_ }; if (BuiltinType.fromName(tname)) |bt| return .{ .builtin = bt }; return ResolvedType.unknown; @@ -1085,9 +1083,8 @@ pub const TypeChecker = struct { // "`unknown` ≈ unit, the house convention" (this file, `synthCall`). // `ResolvedType` has no unit variant to return instead. if (method.return_type.isNone()) return ResolvedType.unknown; - if (a.typeNodeKind(method.return_type) != .named) return ResolvedType.unknown; - const named = a.named_types.items[a.typeNodeData(method.return_type)]; - const tname = a.strings.slice(a.resolveTypeAliasName(named.name)); + const name = a.namedTypeName(method.return_type) orelse return ResolvedType.unknown; + const tname = a.strings.slice(a.resolveTypeAliasName(name)); if (std.mem.eql(u8, tname, "string")) return .{ .builtin = .string_ }; if (BuiltinType.fromName(tname)) |bt| return .{ .builtin = bt }; return ResolvedType.unknown; @@ -1747,8 +1744,7 @@ pub const TypeChecker = struct { /// the common set inlined, the open `…` treated as this fixed list). All /// resolve to a `.named` type node. fn isKnownAnimParamType(self: *TypeChecker, type_node: NodeId) bool { - if (self.arena.typeNodeKind(type_node) != .named) return false; - const name = self.arena.strings.slice(self.arena.named_types.items[self.arena.typeNodeData(type_node)].name); + const name = self.arena.strings.slice(self.arena.namedTypeName(type_node) orelse return false); const cat = [_][]const u8{ "bool", "float", "int", "i32", "u32", "f32", "f64", "Vec2", "Vec3", "Vec4", "Trajectory" }; for (cat) |c| { if (std.mem.eql(u8, name, c)) return true; @@ -3883,19 +3879,16 @@ pub const TypeChecker = struct { // Collection / composite field types (`T[]`, `[K: V]`, `Set`, // and fixed `T[N]`) are not part of the component/resource - // surface — fields stay scalar POD. Reject anything non-named here - // rather than mis-indexing the `named_types` slab (collections - // land as locals, not fields). + // surface — fields stay scalar POD. const tspan = self.arena.typeNodeSpan(field.type_node); - if (self.arena.typeNodeKind(field.type_node) != .named) { + const field_type_name = self.arena.namedTypeName(field.type_node) orelse { // One bounded exception: a `struct` field // may be `Error?` — the builtin Error's `source` chaining field // (part1 §10.2). General optional fields are unsupported. if (origin == .struct_ and self.arena.typeNodeKind(field.type_node) == .optional) { const payload: NodeId = @bitCast(self.arena.typeNodeData(field.type_node)); - if (self.arena.typeNodeKind(payload) == .named) { - const pn = self.arena.named_types.items[self.arena.typeNodeData(payload)]; - if (pn.name == self.arena.error_type_name) continue; + if (self.arena.namedTypeName(payload)) |pn| { + if (pn == self.arena.error_type_name) continue; } } // Resource collection fields: `T[]` (`.slice`), @@ -3930,14 +3923,12 @@ pub const TypeChecker = struct { } try self.emit(.undefined_symbol, .error_, tspan, "collection / composite field types are not supported in E1 — component and resource fields must be scalar POD", .{}); continue; - } - const named_idx = self.arena.typeNodeData(field.type_node); - const named = self.arena.named_types.items[named_idx]; + }; // A field typed by an in-scope generic param (`min: T`) is a // generic field — accepted (type-erased). Only structs // are generic; component / resource fields never reach this branch. - if (self.generic_scope.contains(named.name)) continue; - const resolved_name = self.arena.resolveTypeAliasName(named.name); + if (self.generic_scope.contains(field_type_name)) continue; + const resolved_name = self.arena.resolveTypeAliasName(field_type_name); const tname = self.arena.strings.slice(resolved_name); if (BuiltinType.fromName(tname) == null) { @@ -4016,19 +4007,14 @@ pub const TypeChecker = struct { /// element STORAGE and promotion wiring live in the interpreter. fn checkResourceCollectionElement(self: *TypeChecker, elem: NodeId) !void { const espan = self.arena.typeNodeSpan(elem); - switch (self.arena.typeNodeKind(elem)) { - .named => {}, - .slice, .array, .map_type, .set_type => { - try self.emit(.collection_field_element_invalid, .error_, espan, "nested collections are not supported as a resource collection element (Phase 1)", .{}); - return; - }, - else => { - try self.emit(.collection_field_element_invalid, .error_, espan, "resource collection element must be a scalar POD, string, or enum", .{}); - return; - }, - } - const named = self.arena.named_types.items[self.arena.typeNodeData(elem)]; - const resolved = self.arena.resolveTypeAliasName(named.name); + const elem_name = self.arena.namedTypeName(elem) orelse { + switch (self.arena.typeNodeKind(elem)) { + .slice, .array, .map_type, .set_type => try self.emit(.collection_field_element_invalid, .error_, espan, "nested collections are not supported as a resource collection element (Phase 1)", .{}), + else => try self.emit(.collection_field_element_invalid, .error_, espan, "resource collection element must be a scalar POD, string, or enum", .{}), + } + return; + }; + const resolved = self.arena.resolveTypeAliasName(elem_name); const tname = self.arena.strings.slice(resolved); // Value-POD builtins + `string` + a declared enum are the supported // element set: they are stored inline (POD) or promoted into an owned @@ -4157,13 +4143,12 @@ pub const TypeChecker = struct { fn namedTypeToResolved(self: *TypeChecker, type_node: NodeId) ResolvedType { switch (self.arena.typeNodeKind(type_node)) { .named => { - const named_idx = self.arena.typeNodeData(type_node); - const named = self.arena.named_types.items[named_idx]; + const name = self.arena.namedTypeName(type_node).?; // A type-parameter name in scope resolves to a generic variable // checked before alias / builtin / symbol. - if (self.generic_scope.contains(named.name)) return .{ .generic = named.name }; + if (self.generic_scope.contains(name)) return .{ .generic = name }; // Resolve through any top-level `type` alias chain first. - const resolved_name = self.arena.resolveTypeAliasName(named.name); + const resolved_name = self.arena.resolveTypeAliasName(name); const tname = self.arena.strings.slice(resolved_name); // `string` in a declared-type position. // Kept out of `fromName` so the component/resource POD @@ -4560,14 +4545,12 @@ pub const TypeChecker = struct { while (i < rule.params_len) : (i += 1) { const p = self.arena.rule_params.items[rule.params_start + i]; const ptype = self.namedTypeToResolved(p.type_node); - if (ptype == .unknown) { - if (self.arena.typeNodeKind(p.type_node) == .named) { - const tname_idx = self.arena.typeNodeData(p.type_node); - const tname = self.arena.strings.slice(self.arena.named_types.items[tname_idx].name); - try self.emit(.undefined_symbol, .error_, self.arena.typeNodeSpan(p.type_node), "unknown type '{s}' on rule parameter", .{tname}); - } else { - try self.emit(.undefined_symbol, .error_, self.arena.typeNodeSpan(p.type_node), "unsupported parameter type in E1 (rule parameters must be scalar or Entity)", .{}); + if (self.arena.namedTypeName(p.type_node)) |tn| { + if (ptype == .unknown) { + try self.emit(.undefined_symbol, .error_, self.arena.typeNodeSpan(p.type_node), "unknown type '{s}' on rule parameter", .{self.arena.strings.slice(tn)}); } + } else { + try self.emit(.undefined_symbol, .error_, self.arena.typeNodeSpan(p.type_node), "unsupported parameter type in E1 (rule parameters must be scalar or Entity)", .{}); } try ctx.locals.put(self.gpa, p.name, .{ .type_ = ptype, .is_mut = false }); } @@ -6968,15 +6951,15 @@ pub const TypeChecker = struct { fn unifyGeneric(self: *TypeChecker, decl: ast_mod.FnDecl, formal: NodeId, actual: ResolvedType, subst: *std.AutoHashMapUnmanaged(StringId, ResolvedType), span: SourceSpan) TypeError!void { switch (self.arena.typeNodeKind(formal)) { .named => { - const named = self.arena.named_types.items[self.arena.typeNodeData(formal)]; - if (!self.isGenericParamOf(decl, named.name)) return; // concrete formal — no binding + const name = self.arena.namedTypeName(formal).?; + if (!self.isGenericParamOf(decl, name)) return; // concrete formal — no binding if (actual == .unknown) return; // don't pin a param to a post-error unknown - if (subst.get(named.name)) |prev| { + if (subst.get(name)) |prev| { if (!ResolvedType.eql(prev, actual)) { - try self.emit(.inconsistent_generic_inference, .error_, span, "type parameter '{s}' is inferred as two different types", .{self.arena.strings.slice(named.name)}); + try self.emit(.inconsistent_generic_inference, .error_, span, "type parameter '{s}' is inferred as two different types", .{self.arena.strings.slice(name)}); } } else { - try subst.put(self.gpa, named.name, actual); + try subst.put(self.gpa, name, actual); } }, .array, .slice => { @@ -6999,9 +6982,9 @@ pub const TypeChecker = struct { fn substituteGeneric(self: *TypeChecker, decl: ast_mod.FnDecl, node: NodeId, subst: *std.AutoHashMapUnmanaged(StringId, ResolvedType)) ResolvedType { switch (self.arena.typeNodeKind(node)) { .named => { - const named = self.arena.named_types.items[self.arena.typeNodeData(node)]; - if (self.isGenericParamOf(decl, named.name)) { - return subst.get(named.name) orelse ResolvedType{ .generic = named.name }; + const name = self.arena.namedTypeName(node).?; + if (self.isGenericParamOf(decl, name)) { + return subst.get(name) orelse ResolvedType{ .generic = name }; } return self.namedTypeToResolved(node); }, diff --git a/src/etch/zig_codegen/lower.zig b/src/etch/zig_codegen/lower.zig index a9d938e9..fe7895a2 100644 --- a/src/etch/zig_codegen/lower.zig +++ b/src/etch/zig_codegen/lower.zig @@ -335,11 +335,12 @@ fn emitComponentLikeStruct(w: *Writer, ast: *const AstArena, data: u32, kind: De var f_i: u32 = 0; while (f_i < fields_len) : (f_i += 1) { const f = ast.fields.items[fields_start + f_i]; - const tnode = ast.named_types.items[ast.typeNodeData(f.type_node)]; + // A resource collection field has no lowering here. + const declared = ast.namedTypeName(f.type_node) orelse return CodegenError.UnsupportedConstruct; // Resolve through any `type` alias chain: `x: Meters` where // `type Meters = float` emits as `x: f64`, identical to the layout // the interpreter computes, keeping the differential byte-exact. - const etch_type = ast.strings.slice(ast.resolveTypeAliasName(tnode.name)); + const etch_type = ast.strings.slice(ast.resolveTypeAliasName(declared)); const zig_type = type_map.mapBuiltin(etch_type) orelse return CodegenError.NonPodComponent; const fname = ast.strings.slice(f.name); if (f.default_value.isNone()) { @@ -533,10 +534,7 @@ fn isErrorName(name: StringId, err_id: ?StringId, code_id: ?StringId) bool { /// optional payload (`Error?`). fn typeNodeNamesError(ast: *const AstArena, type_node: NodeId, err_id: ?StringId, code_id: ?StringId) bool { switch (ast.typeNodeKind(type_node)) { - .named => { - const named = ast.named_types.items[ast.typeNodeData(type_node)]; - return isErrorName(ast.resolveTypeAliasName(named.name), err_id, code_id); - }, + .named => return isErrorName(ast.resolveTypeAliasName(ast.namedTypeName(type_node).?), err_id, code_id), .optional => { const payload: NodeId = @bitCast(ast.typeNodeData(type_node)); return typeNodeNamesError(ast, payload, err_id, code_id); @@ -976,9 +974,8 @@ fn emitStructDecl(w: *Writer, ast: *const AstArena, data: u32) CodegenError!void var f_i: u32 = 0; while (f_i < decl.fields_len) : (f_i += 1) { const f = ast.fields.items[decl.fields_start + f_i]; - if (ast.typeNodeKind(f.type_node) != .named) continue; - const tnode = ast.named_types.items[ast.typeNodeData(f.type_node)]; - if (std.mem.eql(u8, ast.strings.slice(ast.resolveTypeAliasName(tnode.name)), "string")) has_string = true; + const declared = ast.namedTypeName(f.type_node) orelse continue; + if (std.mem.eql(u8, ast.strings.slice(ast.resolveTypeAliasName(declared)), "string")) has_string = true; } try w.printLine("pub const {s} = {s}struct {{", .{ name, if (has_string) "" else "extern " }); w.indentBy(1); @@ -986,11 +983,9 @@ fn emitStructDecl(w: *Writer, ast: *const AstArena, data: u32) CodegenError!void while (f_i < decl.fields_len) : (f_i += 1) { const f = ast.fields.items[decl.fields_start + f_i]; // Optional fields (`Error?`) have no codegen lowering yet — deferred - // to the Optional-ops tranche (interpreter reference). The guard also - // protects the `named_types` index below. - if (ast.typeNodeKind(f.type_node) != .named) return CodegenError.UnsupportedConstruct; - const tnode = ast.named_types.items[ast.typeNodeData(f.type_node)]; - const resolved = ast.resolveTypeAliasName(tnode.name); + // to the Optional-ops tranche (interpreter reference). + const declared = ast.namedTypeName(f.type_node) orelse return CodegenError.UnsupportedConstruct; + const resolved = ast.resolveTypeAliasName(declared); const etch_type = ast.strings.slice(resolved); const fname = ast.strings.slice(f.name); // `string` field: `[]const u8`, empty default; @@ -1129,14 +1124,14 @@ fn emitMethod(w: *Writer, ast: *const AstArena, struct_name: []const u8, method: while (p_i < method.params_len) : (p_i += 1) { if (wrote_param) try w.write(", "); const p = ast.fn_params.items[method.params_start + p_i]; - const zig_t = fnTypeZig(ast, p.type_node); + const zig_t = try fnTypeZig(ast, p.type_node); try w.ident(ast.strings.slice(p.name)); try w.print(": {s}", .{zig_t}); wrote_param = true; try ctx.records.append(w.gpa, .{ .key = .{ .name = p.name }, .info = .{ .kind = .value, .zig_type = zig_t, .is_mut = false } }); } try w.write(") "); - try w.write(if (method.return_type.isNone()) "void" else fnTypeZig(ast, method.return_type)); + try w.write(if (method.return_type.isNone()) "void" else try fnTypeZig(ast, method.return_type)); try w.write(" {\n"); w.indentBy(1); var s: u32 = 0; @@ -1253,10 +1248,10 @@ fn emitRegisterCall( var f_i: u32 = 0; while (f_i < fields_len) : (f_i += 1) { const f = ast.fields.items[fields_start + f_i]; - const tnode = ast.named_types.items[ast.typeNodeData(f.type_node)]; + const declared = ast.namedTypeName(f.type_node) orelse return CodegenError.UnsupportedConstruct; // Resolve through any `type` alias chain, matching the struct // emission and the interpreter's FieldKind resolution. - const etch_t = ast.strings.slice(ast.resolveTypeAliasName(tnode.name)); + const etch_t = ast.strings.slice(ast.resolveTypeAliasName(declared)); const zig_t = type_map.mapBuiltin(etch_t) orelse return CodegenError.NonPodComponent; const fname = ast.strings.slice(f.name); const fkind = fieldKindLiteral(zig_t); @@ -1412,14 +1407,14 @@ fn emitFnDecl(w: *Writer, ast: *const AstArena, decl: ast_mod.FnDecl) CodegenErr if (decl.generics_len > 0) return CodegenError.UnsupportedConstruct; // generic monomorphisation is not emitted if (decl.is_async) return CodegenError.UnsupportedConstruct; // async is not emitted - const ret_zig: []const u8 = if (decl.return_type.isNone()) "" else fnTypeZig(ast, decl.return_type); + const ret_zig: []const u8 = if (decl.return_type.isNone()) "" else try fnTypeZig(ast, decl.return_type); if (decl.throws and !decl.return_type.isNone()) { // The throwing path returns `zeroDefault(ret)` — only meaningful for // builtin-mapped scalars (checked on the ETCH type name; `fnTypeZig` // passes user names through 1:1). A `throws` fn returning a user // type is deferred (interpreter reference). - const tnode = ast.named_types.items[ast.typeNodeData(decl.return_type)]; - const tname = ast.strings.slice(ast.resolveTypeAliasName(tnode.name)); + const declared = ast.namedTypeName(decl.return_type) orelse return CodegenError.UnsupportedConstruct; + const tname = ast.strings.slice(ast.resolveTypeAliasName(declared)); if (type_map.mapBuiltin(tname) == null) return CodegenError.UnsupportedConstruct; } @@ -1436,7 +1431,7 @@ fn emitFnDecl(w: *Writer, ast: *const AstArena, decl: ast_mod.FnDecl) CodegenErr while (p_i < decl.params_len) : (p_i += 1) { if (p_i > 0) try w.write(", "); const p = ast.fn_params.items[decl.params_start + p_i]; - const zig_t = fnTypeZig(ast, p.type_node); + const zig_t = try fnTypeZig(ast, p.type_node); try w.ident(ast.strings.slice(p.name)); try w.print(": {s}", .{zig_t}); try ctx.records.append(w.gpa, .{ .key = .{ .name = p.name }, .info = .{ .kind = .value, .zig_type = zig_t, .is_mut = false } }); @@ -1529,9 +1524,9 @@ fn fnBodyCanThrow(ast: *const AstArena, decl: ast_mod.FnDecl) bool { /// Map a `fn` parameter / return type node to its Zig type name. /// Block-2 fns use named scalar types (alias-resolved); a builtin maps through /// `type_map`, a user type passes through 1:1 (same as rule params). -fn fnTypeZig(ast: *const AstArena, type_node: NodeId) []const u8 { - const tnode = ast.named_types.items[ast.typeNodeData(type_node)]; - const tname = ast.strings.slice(ast.resolveTypeAliasName(tnode.name)); +fn fnTypeZig(ast: *const AstArena, type_node: NodeId) CodegenError![]const u8 { + const declared = ast.namedTypeName(type_node) orelse return CodegenError.UnsupportedConstruct; + const tname = ast.strings.slice(ast.resolveTypeAliasName(declared)); // `string` params/returns lower to `[]const u8` — the same mapping the struct-field // emitter delivers. A raw-name fallback here would emit invalid Zig // (`name: string`), which is the no-silently-wrong-output doctrine breached. The @@ -2516,8 +2511,8 @@ const LocalCtx = struct { var p_i: u32 = 0; while (p_i < rule.params_len) : (p_i += 1) { const p = ast.rule_params.items[rule.params_start + p_i]; - const tnode = ast.named_types.items[ast.typeNodeData(p.type_node)]; - const tname = ast.strings.slice(ast.resolveTypeAliasName(tnode.name)); + const declared = ast.namedTypeName(p.type_node) orelse return CodegenError.UnsupportedConstruct; + const tname = ast.strings.slice(ast.resolveTypeAliasName(declared)); if (std.mem.eql(u8, tname, "Entity")) { // Entity params are handled by the iteration machinery; the // ident never reaches `emitExpr` in a compliant program. @@ -3022,7 +3017,7 @@ fn emitLet(w: *Writer, ast: *const AstArena, ctx: *LocalCtx, let: ast_mod.LetStm try w.write(" = "); try emitThrowsCallExpr(w, ast, ctx, call, call_idx); try w.write(";\n"); - const ret_zig = if (callee.return_type.isNone()) "" else fnTypeZig(ast, callee.return_type); + const ret_zig = if (callee.return_type.isNone()) "" else try fnTypeZig(ast, callee.return_type); try ctx.records.append(w.gpa, .{ .key = .{ .name = let.name }, .info = .{ .kind = .value, .zig_type = ret_zig, .is_mut = let.is_mut }, @@ -3210,9 +3205,9 @@ fn emitLet(w: *Writer, ast: *const AstArena, ctx: *LocalCtx, let: ast_mod.LetStm if (ast.exprKind(let.value) == .struct_lit) { const sl = ast.struct_lits.items[ast.exprData(let.value)]; if (sl.type_name == 0) { - if (let.type_annotation.isNone() or ast.typeNodeKind(let.type_annotation) != .named) return CodegenError.UnsupportedConstruct; - const named = ast.named_types.items[ast.typeNodeData(let.type_annotation)]; - const sname = ast.resolveTypeAliasName(named.name); + if (let.type_annotation.isNone()) return CodegenError.UnsupportedConstruct; + const annotated = ast.namedTypeName(let.type_annotation) orelse return CodegenError.UnsupportedConstruct; + const sname = ast.resolveTypeAliasName(annotated); if (!isStructName(ast, sname)) return CodegenError.UnsupportedConstruct; try w.writeIndent(); try w.print("{s} ", .{if (let.is_mut) "var" else "const"}); @@ -3945,8 +3940,8 @@ fn emitExpr(w: *Writer, ast: *const AstArena, ctx: *LocalCtx, id: NodeId) Codege // in `@as(T, …)`. The conversion builtin // is picked from the operand's inferred domain vs the target's. const c = ast.casts.items[data]; - const named = ast.named_types.items[ast.typeNodeData(c.type_node)]; - const zig_t = type_map.mapBuiltin(ast.strings.slice(ast.resolveTypeAliasName(named.name))) orelse return CodegenError.UnsupportedConstruct; + const target = ast.namedTypeName(c.type_node) orelse return CodegenError.UnsupportedConstruct; + const zig_t = type_map.mapBuiltin(ast.strings.slice(ast.resolveTypeAliasName(target))) orelse return CodegenError.UnsupportedConstruct; const target_is_float = std.mem.eql(u8, zig_t, "f32") or std.mem.eql(u8, zig_t, "f64"); const src_zig = inferExprZigType(ast, ctx, c.operand); const src_is_float = std.mem.eql(u8, src_zig, "f32") or std.mem.eql(u8, src_zig, "f64"); @@ -4349,9 +4344,9 @@ fn emitIfChain(w: *Writer, ast: *const AstArena, ctx: *LocalCtx, data: u32) Code /// expects annotated scalar params; a missing / non-scalar annotation falls /// back to `i64` (the interpreter is the reference for richer closures). fn closureParamZigType(ast: *const AstArena, p: ast_mod.ClosureParam) []const u8 { - if (p.type_node.isNone() or ast.typeNodeKind(p.type_node) != .named) return "i64"; - const tnode = ast.named_types.items[ast.typeNodeData(p.type_node)]; - return type_map.mapBuiltin(ast.strings.slice(ast.resolveTypeAliasName(tnode.name))) orelse "i64"; + if (p.type_node.isNone()) return "i64"; + const declared = ast.namedTypeName(p.type_node) orelse return "i64"; + return type_map.mapBuiltin(ast.strings.slice(ast.resolveTypeAliasName(declared))) orelse "i64"; } /// One captured outer binding of a closure: the Etch @@ -4571,10 +4566,10 @@ fn inferZigType(ast: *const AstArena, ctx: *LocalCtx, expr: NodeId, annotation: // Only a named-type annotation maps to a scalar Zig type here; collection // annotations (`T[]`, `[K: V]`, `Set`, `T[N]`) leave the binding // un-annotated so Zig infers the array / slice type. - if (!annotation.isNone() and ast.typeNodeKind(annotation) == .named) { - const tnode = ast.named_types.items[ast.typeNodeData(annotation)]; - const tname = ast.strings.slice(ast.resolveTypeAliasName(tnode.name)); - if (type_map.mapBuiltin(tname)) |z| return z; + if (!annotation.isNone()) { + if (ast.namedTypeName(annotation)) |declared| { + if (type_map.mapBuiltin(ast.strings.slice(ast.resolveTypeAliasName(declared)))) |z| return z; + } } // `T?` optional annotation → `?`: used so `let o: // int? = none` emits `const o: ?i64 = null;`. A `some(...)` RHS self-types @@ -4590,9 +4585,8 @@ fn inferZigType(ast: *const AstArena, ctx: *LocalCtx, expr: NodeId, annotation: /// payload (deferred) yields `null`. fn optionalAnnotationZig(ast: *const AstArena, type_node: NodeId) ?[]const u8 { const payload_node: NodeId = @bitCast(ast.typeNodeData(type_node)); - if (ast.typeNodeKind(payload_node) != .named) return null; - const tnode = ast.named_types.items[ast.typeNodeData(payload_node)]; - const tname = ast.strings.slice(ast.resolveTypeAliasName(tnode.name)); + const declared = ast.namedTypeName(payload_node) orelse return null; + const tname = ast.strings.slice(ast.resolveTypeAliasName(declared)); // `string?`: `string` is deliberately not in // `type_map.mapBuiltin` (the let-routing leaves plain string bindings // un-annotated) — only the optional path needs its Zig spelling. @@ -4683,9 +4677,8 @@ const map_types_table = .{ /// element → the caller fails loud). fn sliceAnnotationListType(ast: *const AstArena, annotation: NodeId) ?[]const u8 { const at = ast.array_types.items[ast.typeNodeData(annotation)]; - if (ast.typeNodeKind(at.elem) != .named) return null; - const named = ast.named_types.items[ast.typeNodeData(at.elem)]; - const elem_zig = type_map.mapBuiltin(ast.strings.slice(ast.resolveTypeAliasName(named.name))) orelse return null; + const elem = ast.namedTypeName(at.elem) orelse return null; + const elem_zig = type_map.mapBuiltin(ast.strings.slice(ast.resolveTypeAliasName(elem))) orelse return null; return dynArrayZigType(elem_zig); } @@ -4693,11 +4686,10 @@ fn sliceAnnotationListType(ast: *const AstArena, annotation: NodeId) ?[]const u8 /// the key/value pair is outside the emitter's map table. fn mapAnnotationListType(ast: *const AstArena, annotation: NodeId) ?[]const u8 { const mt = ast.map_types.items[ast.typeNodeData(annotation)]; - if (ast.typeNodeKind(mt.key) != .named or ast.typeNodeKind(mt.value) != .named) return null; - const knamed = ast.named_types.items[ast.typeNodeData(mt.key)]; - const vnamed = ast.named_types.items[ast.typeNodeData(mt.value)]; - const key_zig = type_map.mapBuiltin(ast.strings.slice(ast.resolveTypeAliasName(knamed.name))) orelse return null; - const value_zig = type_map.mapBuiltin(ast.strings.slice(ast.resolveTypeAliasName(vnamed.name))) orelse return null; + const key = ast.namedTypeName(mt.key) orelse return null; + const value = ast.namedTypeName(mt.value) orelse return null; + const key_zig = type_map.mapBuiltin(ast.strings.slice(ast.resolveTypeAliasName(key))) orelse return null; + const value_zig = type_map.mapBuiltin(ast.strings.slice(ast.resolveTypeAliasName(value))) orelse return null; return mapZigType(key_zig, value_zig); } @@ -4735,9 +4727,8 @@ const set_types_table = .{ /// the element is outside the emitter's set table. fn setAnnotationListType(ast: *const AstArena, annotation: NodeId) ?[]const u8 { const st = ast.set_types.items[ast.typeNodeData(annotation)]; - if (ast.typeNodeKind(st.elem) != .named) return null; - const named = ast.named_types.items[ast.typeNodeData(st.elem)]; - const elem_zig = type_map.mapBuiltin(ast.strings.slice(ast.resolveTypeAliasName(named.name))) orelse return null; + const elem = ast.namedTypeName(st.elem) orelse return null; + const elem_zig = type_map.mapBuiltin(ast.strings.slice(ast.resolveTypeAliasName(elem))) orelse return null; return setZigType(elem_zig); } @@ -4755,9 +4746,7 @@ fn structFieldEnumName(ast: *const AstArena, type_name: StringId, field_name: St while (f_i < sd.fields_len) : (f_i += 1) { const f = ast.fields.items[sd.fields_start + f_i]; if (f.name != field_name) continue; - if (ast.typeNodeKind(f.type_node) != .named) return null; - const named = ast.named_types.items[ast.typeNodeData(f.type_node)]; - const resolved = ast.resolveTypeAliasName(named.name); + const resolved = ast.resolveTypeAliasName(ast.namedTypeName(f.type_node) orelse return null); if (!isEnumName(ast, resolved)) return null; return ast.strings.slice(resolved); } @@ -4844,9 +4833,7 @@ fn structFieldStructName(ast: *const AstArena, type_name: StringId, field_name: while (f_i < sd.fields_len) : (f_i += 1) { const f = ast.fields.items[sd.fields_start + f_i]; if (f.name != field_name) continue; - if (ast.typeNodeKind(f.type_node) != .named) return null; - const named = ast.named_types.items[ast.typeNodeData(f.type_node)]; - const resolved = ast.resolveTypeAliasName(named.name); + const resolved = ast.resolveTypeAliasName(ast.namedTypeName(f.type_node) orelse return null); if (!isStructName(ast, resolved)) return null; return resolved; } @@ -6405,8 +6392,8 @@ fn inferExprZigType(ast: *const AstArena, ctx: *LocalCtx, expr: NodeId) []const .method_get, .method_get_mut => "struct", // not directly inferable; should not appear at let-rhs after method_get handling .cast => blk: { const c = ast.casts.items[data]; - const named = ast.named_types.items[ast.typeNodeData(c.type_node)]; - break :blk type_map.mapBuiltin(ast.strings.slice(ast.resolveTypeAliasName(named.name))) orelse "i64"; + const target = ast.namedTypeName(c.type_node) orelse break :blk "i64"; + break :blk type_map.mapBuiltin(ast.strings.slice(ast.resolveTypeAliasName(target))) orelse "i64"; }, .match_expr => blk: { // The match result type is the (unified) type of its arm bodies; @@ -6477,10 +6464,7 @@ fn fieldZigTypeOnComponent(ast: *const AstArena, comp_name: []const u8, field_na if (std.mem.eql(u8, fname, field_name)) { // A non-named field type (`Error?` — the builtin Error's // `source`) has no scalar Zig name; the caller falls back. - // Guards the `named_types` mis-index too. - if (ast.typeNodeKind(f.type_node) != .named) return null; - const tnode = ast.named_types.items[ast.typeNodeData(f.type_node)]; - const resolved = ast.resolveTypeAliasName(tnode.name); + const resolved = ast.resolveTypeAliasName(ast.namedTypeName(f.type_node) orelse return null); const etch_t = ast.strings.slice(resolved); // `string` fields (`Error.message`) → the codegen string type, driving // `.len()` dispatch; enum-typed fields (`Error.code`) map 1:1, driving diff --git a/tests/etch/type_node_kind_test.zig b/tests/etch/type_node_kind_test.zig new file mode 100644 index 00000000..04e019c6 --- /dev/null +++ b/tests/etch/type_node_kind_test.zig @@ -0,0 +1,110 @@ +//! A type node's name is read only when the node is `.named`: every other kind +//! gets its own decision, in the checker, the interpreter and the codegen. + +const std = @import("std"); +const weld_etch = @import("weld_etch"); + +const lower = weld_etch.codegen_zig.lower; + +/// The diagnostics of `src`, as their primary messages. +fn checkMessages(gpa: std.mem.Allocator, src: []const u8, out: *std.ArrayListUnmanaged([]const u8)) !void { + var pr = try weld_etch.parseSource(gpa, src); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(weld_etch.Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try weld_etch.typeCheck(gpa, &pr.ast, &diags); + for (diags.items) |d| try out.append(gpa, try gpa.dupe(u8, d.primary_message)); +} + +fn freeMessages(gpa: std.mem.Allocator, list: *std.ArrayListUnmanaged([]const u8)) void { + for (list.items) |m| gpa.free(m); + list.deinit(gpa); +} + +/// Whether type-checking `src` reports the rule-parameter refusal. +fn refusesRuleParam(gpa: std.mem.Allocator, src: []const u8) !bool { + var msgs: std.ArrayListUnmanaged([]const u8) = .empty; + defer freeMessages(gpa, &msgs); + try checkMessages(gpa, src, &msgs); + for (msgs.items) |m| { + if (std.mem.indexOf(u8, m, "rule parameters must be scalar or Entity") != null) return true; + } + return false; +} + +/// Lowers `src`, type-checking it first unless `checked` is false. +fn lowerSource(gpa: std.mem.Allocator, src: []const u8, checked: bool) !void { + var pr = try weld_etch.parseSource(gpa, src); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + if (checked) { + var diags: std.ArrayListUnmanaged(weld_etch.Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try weld_etch.typeCheck(gpa, &pr.ast, &diags); + for (diags.items) |d| std.debug.print("unexpected diagnostic: {s}\n", .{d.primary_message}); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + } + var buf: std.ArrayListUnmanaged(u8) = .empty; + defer buf.deinit(gpa); + _ = try lower.generateFile(gpa, &pr.ast, "type_node_kind_test.etch", &buf); +} + +test "a collection rule parameter is refused by the checker" { + try std.testing.expect(try refusesRuleParam(std.testing.allocator, "rule r(entity: Entity, xs: int[]) {}")); +} + +test "an optional rule parameter is refused by the checker" { + try std.testing.expect(try refusesRuleParam(std.testing.allocator, "rule r(entity: Entity, x: int?) {}")); +} + +test "a scalar rule parameter is accepted by the checker" { + try std.testing.expect(!try refusesRuleParam(std.testing.allocator, "rule r(entity: Entity, dt: float) {}")); +} + +test "the codegen refuses a non-named rule parameter, even unchecked" { + try std.testing.expectError( + error.UnsupportedConstruct, + lowerSource(std.testing.allocator, "rule r(entity: Entity, xs: int[]) {}", false), + ); +} + +test "the codegen refuses a resource array field" { + try std.testing.expectError(error.UnsupportedConstruct, lowerSource(std.testing.allocator, "resource R { xs: int[] }", true)); +} + +test "the codegen refuses a resource map field" { + try std.testing.expectError(error.UnsupportedConstruct, lowerSource(std.testing.allocator, "resource R { m: [int: int] }", true)); +} + +test "the codegen refuses a resource set field" { + try std.testing.expectError(error.UnsupportedConstruct, lowerSource(std.testing.allocator, "resource R { s: Set }", true)); +} + +test "the same resource with a scalar field lowers" { + try lowerSource(std.testing.allocator, "resource R { xs: int }", true); +} + +test "the codegen refuses a collection fn parameter" { + try std.testing.expectError( + error.UnsupportedConstruct, + lowerSource(std.testing.allocator, "fn total(xs: int[]) -> int { 0 }", true), + ); +} + +test "the codegen refuses an optional fn return" { + try std.testing.expectError( + error.UnsupportedConstruct, + lowerSource(std.testing.allocator, "fn first() -> int? { none }", true), + ); +} + +test "the same fn with scalar types lowers" { + try lowerSource(std.testing.allocator, "fn total(xs: int) -> int { 0 }", true); +} diff --git a/tests/lint/bad/raw_named_type_read/index_by_data.zig b/tests/lint/bad/raw_named_type_read/index_by_data.zig new file mode 100644 index 00000000..e7aabd21 --- /dev/null +++ b/tests/lint/bad/raw_named_type_read/index_by_data.zig @@ -0,0 +1,8 @@ +//! Fixture — `named_types` indexed by a type node's data outside `ast.zig`. Rule +//! `no_raw_named_type_read` must fire: a non-`.named` node's data indexes +//! another slab. + +/// The name of a type node, read the way the rule forbids. +pub fn nameOf(ast: anytype, node: anytype) u32 { + return ast.named_types.items[ast.typeNodeData(node)].name; +} diff --git a/tests/lint/runner_test.zig b/tests/lint/runner_test.zig index 5e56df0a..5b212f0b 100644 --- a/tests/lint/runner_test.zig +++ b/tests/lint/runner_test.zig @@ -145,6 +145,11 @@ test "rule no_float_reduce flags bad fixtures" { try forEachZigFile(ctx.gpa, ctx.io, "tests/lint/bad/float_reduce", &ctx, &assertBadFixture); } +test "rule no_raw_named_type_read flags bad fixtures" { + var ctx: Context = .{ .gpa = std.testing.allocator, .io = std.testing.io }; + try forEachZigFile(ctx.gpa, ctx.io, "tests/lint/bad/raw_named_type_read", &ctx, &assertBadFixture); +} + test "good fixtures pass clean" { var ctx: Context = .{ .gpa = std.testing.allocator, .io = std.testing.io }; try forEachZigFile(ctx.gpa, ctx.io, "tests/lint/good", &ctx, &assertGoodFile); diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index c19ac2ed..40de1ae2 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -237,8 +237,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2413, - else => 2415, + .windows => 2434, + else => 2436, }; } diff --git a/tools/weld_lint/main.zig b/tools/weld_lint/main.zig index 809f571a..82239c05 100644 --- a/tools/weld_lint/main.zig +++ b/tools/weld_lint/main.zig @@ -29,6 +29,7 @@ const conventional_commit = @import("rules/conventional_commit.zig"); const no_device_dispatch_outside_gal = @import("rules/no_device_dispatch_outside_gal.zig"); const no_float_reduce = @import("rules/no_float_reduce.zig"); const no_precision_crossing = @import("rules/no_precision_crossing.zig"); +const no_raw_named_type_read = @import("rules/no_raw_named_type_read.zig"); const dead_tests = @import("dead_tests.zig"); const census = @import("census.zig"); const comment_identifiers = @import("rules/comment_identifiers.zig"); @@ -148,6 +149,7 @@ fn runLint(arena: std.mem.Allocator, io: std.Io, paths: []const [:0]const u8, ou try c_module_isolation.check(arena, file, source, &diags); try no_device_dispatch_outside_gal.check(arena, file, source, &diags); try no_float_reduce.check(arena, file, source, &diags); + try no_raw_named_type_read.check(arena, file, source, &diags); try no_precision_crossing.check(arena, file, source, &diags, &crossing_tally); try comment_identifiers.check(arena, file, source, &diags); try comment_tags.check(arena, file, source, &diags); @@ -584,11 +586,9 @@ fn readForAnalysis(path: []const u8) ?[]const u8 { const usage_text = \\usage: \\ weld_lint lint [path]... - \\ Walk the given paths (default `src bench tests tools`) and - \\ apply rules: no_cimport, no_usingnamespace, doc_comments, - \\ c_module_isolation, no_device_dispatch_outside_gal, - \\ no_float_reduce, no_precision_crossing. Exits 0 - \\ if clean, 1 if any rule fires. + \\ Walk the given paths (default `src bench tests tools build.zig`) + \\ and apply every rule in `rules/`. Exits 0 if clean, 1 if any + \\ rule fires. \\ \\ weld_lint dead-tests [--list] [--per-root] \\ Check that every file holding a `test` block belongs to the diff --git a/tools/weld_lint/rules/no_raw_named_type_read.zig b/tools/weld_lint/rules/no_raw_named_type_read.zig new file mode 100644 index 00000000..2ff84e60 --- /dev/null +++ b/tools/weld_lint/rules/no_raw_named_type_read.zig @@ -0,0 +1,84 @@ +//! Rule `no_raw_named_type_read` — the Etch AST's `named_types` slab is read only +//! through `AstArena.namedTypeName`, outside the file that owns it. +//! +//! A type node's `data` indexes the slab of its own kind, so indexing +//! `named_types` with the data of a `.slice`, `.optional` or `.path` node selects +//! an unrelated name, or reads out of bounds. `namedTypeName` returns null for +//! every kind but `.named`, which makes the non-named branch a decision each site +//! has to write. + +const std = @import("std"); +const diag = @import("../diagnostic.zig"); + +const name = "no_raw_named_type_read"; + +/// The owner of the slab, one form per path separator. +const owner_posix = "src/etch/ast.zig"; +const owner_win = "src\\etch\\ast.zig"; + +/// Hook called by `main.runLint` once per `.zig` file. +/// +/// Flags every field access `.named_types`. The owner file is skipped: it +/// declares, fills and frees the slab, and holds the accessor. +pub fn check( + arena: std.mem.Allocator, + file: []const u8, + source: [:0]const u8, + out: *std.ArrayList(diag.Diagnostic), +) !void { + if (std.mem.endsWith(u8, file, owner_posix)) return; + if (std.mem.endsWith(u8, file, owner_win)) return; + + var tokenizer = std.zig.Tokenizer.init(source); + var last_was_period = false; + while (true) { + const tok = tokenizer.next(); + if (tok.tag == .eof) break; + if (tok.tag == .identifier and last_was_period and + std.mem.eql(u8, source[tok.loc.start..tok.loc.end], "named_types")) + { + const pos = diag.lineColFromOffset(source, tok.loc.start); + try out.append(arena, .{ + .file = file, + .line = pos.line, + .col = pos.col, + .rule = name, + .message = "`named_types` is indexed by the data of a `.named` type node only — read it through `AstArena.namedTypeName`, which returns null for every other kind", + }); + } + last_was_period = tok.tag == .period; + } +} + +/// Runs the rule over `source` as file `file` and returns the diagnostic count. +fn countOn(file: []const u8, source: [:0]const u8) !usize { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + var diags: std.ArrayList(diag.Diagnostic) = .empty; + try check(arena_state.allocator(), file, source, &diags); + return diags.items.len; +} + +test "a raw read of the slab is flagged, each occurrence once" { + try std.testing.expectEqual(@as(usize, 2), try countOn("src/etch/interp.zig", + \\const a = ast.named_types.items[ast.typeNodeData(n)]; + \\const slab = self.ast.named_types; + \\ + )); +} + +test "the owner file is exempt on both separators" { + const src = "const a = self.named_types.items[0];\n"; + try std.testing.expectEqual(@as(usize, 0), try countOn("src/etch/ast.zig", src)); + try std.testing.expectEqual(@as(usize, 0), try countOn("src\\etch\\ast.zig", src)); + try std.testing.expectEqual(@as(usize, 1), try countOn("src/etch/ast_helpers.zig", src)); +} + +test "the name in prose or as a declaration is not a read" { + try std.testing.expectEqual(@as(usize, 0), try countOn("src/etch/types.zig", + \\// indexes the `named_types` slab + \\const named_types = 3; + \\const msg = "ast.named_types"; + \\ + )); +} diff --git a/tools/weld_lint/tests.zig b/tools/weld_lint/tests.zig index 1bca0a76..79e0e77c 100644 --- a/tools/weld_lint/tests.zig +++ b/tools/weld_lint/tests.zig @@ -30,6 +30,7 @@ comptime { _ = @import("rules/no_device_dispatch_outside_gal.zig"); _ = @import("rules/no_float_reduce.zig"); _ = @import("rules/no_precision_crossing.zig"); + _ = @import("rules/no_raw_named_type_read.zig"); _ = @import("rules/conventional_commit.zig"); _ = @import("rules/comment_identifiers.zig"); _ = @import("rules/comment_tags.zig"); From d6aced99f2de7cf6265dfb7b5b0bbd961bef52e1 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 23 Sep 2026 14:14:26 +0200 Subject: [PATCH 017/141] test(etch): witness the event-field read the resource read dominates The resource witnesses cannot tell the struct emission from the registration: both read the same fields and the struct pass runs first. An event reaches only the struct emission, unchecked. Floor 2436 -> 2437 / 2435, measured: 2418/2437 passed, 19 skipped. Co-Authored-By: Claude Opus 5.5 --- tests/etch/type_node_kind_test.zig | 4 ++++ tools/weld_lint/dead_tests.zig | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/etch/type_node_kind_test.zig b/tests/etch/type_node_kind_test.zig index 04e019c6..1a935c82 100644 --- a/tests/etch/type_node_kind_test.zig +++ b/tests/etch/type_node_kind_test.zig @@ -87,6 +87,10 @@ test "the codegen refuses a resource set field" { try std.testing.expectError(error.UnsupportedConstruct, lowerSource(std.testing.allocator, "resource R { s: Set }", true)); } +test "the codegen refuses a non-named event field, even unchecked" { + try std.testing.expectError(error.UnsupportedConstruct, lowerSource(std.testing.allocator, "event E { xs: int[] }", false)); +} + test "the same resource with a scalar field lowers" { try lowerSource(std.testing.allocator, "resource R { xs: int }", true); } diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 40de1ae2..fbd4f369 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -237,8 +237,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2434, - else => 2436, + .windows => 2435, + else => 2437, }; } From 5611bedaab806af1863871c0fc9d61e99f06321d Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 23 Sep 2026 14:23:18 +0200 Subject: [PATCH 018/141] fix(etch): refuse an unresolvable fn or method return type (S5/G8 ter) A parameter type that resolves to nothing was refused; a return type was resolved and never checked, so `fn f() -> Nope` type-checked. Both fn and method returns now carry the diagnostic their parameters do. No corpus program declares one: the suite stays green. Floor 2437 -> 2440 / 2438, measured: 2421/2440 passed, 19 skipped. Co-Authored-By: Claude Opus 5.5 --- src/etch/types.zig | 6 ++++++ tests/etch/type_node_kind_test.zig | 32 +++++++++++++++++++++++++++--- tools/weld_lint/dead_tests.zig | 4 ++-- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/etch/types.zig b/src/etch/types.zig index 91c4a9d7..86283cb2 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -4390,6 +4390,9 @@ pub const TypeChecker = struct { ResolvedType.unknown else self.namedTypeToResolved(decl.return_type); + if (!decl.return_type.isNone() and ret_t == .unknown) { + try self.emit(.undefined_symbol, .error_, self.arena.typeNodeSpan(decl.return_type), "unknown or unsupported return type on method '{s}'", .{self.arena.strings.slice(decl.name)}); + } const saved_ret = self.current_fn_return; self.current_fn_return = ret_t; defer self.current_fn_return = saved_ret; @@ -4951,6 +4954,9 @@ pub const TypeChecker = struct { ResolvedType.unknown else self.namedTypeToResolved(decl.return_type); + if (!decl.return_type.isNone() and ret_t == .unknown) { + try self.emit(.undefined_symbol, .error_, self.arena.typeNodeSpan(decl.return_type), "unknown or unsupported return type on function '{s}'", .{self.arena.strings.slice(decl.name)}); + } const saved_ret = self.current_fn_return; self.current_fn_return = ret_t; defer self.current_fn_return = saved_ret; diff --git a/tests/etch/type_node_kind_test.zig b/tests/etch/type_node_kind_test.zig index 1a935c82..47cf8c6e 100644 --- a/tests/etch/type_node_kind_test.zig +++ b/tests/etch/type_node_kind_test.zig @@ -25,17 +25,22 @@ fn freeMessages(gpa: std.mem.Allocator, list: *std.ArrayListUnmanaged([]const u8 list.deinit(gpa); } -/// Whether type-checking `src` reports the rule-parameter refusal. -fn refusesRuleParam(gpa: std.mem.Allocator, src: []const u8) !bool { +/// Whether type-checking `src` reports a diagnostic whose message holds `needle`. +fn reports(gpa: std.mem.Allocator, src: []const u8, needle: []const u8) !bool { var msgs: std.ArrayListUnmanaged([]const u8) = .empty; defer freeMessages(gpa, &msgs); try checkMessages(gpa, src, &msgs); for (msgs.items) |m| { - if (std.mem.indexOf(u8, m, "rule parameters must be scalar or Entity") != null) return true; + if (std.mem.indexOf(u8, m, needle) != null) return true; } return false; } +/// Whether type-checking `src` reports the rule-parameter refusal. +fn refusesRuleParam(gpa: std.mem.Allocator, src: []const u8) !bool { + return reports(gpa, src, "rule parameters must be scalar or Entity"); +} + /// Lowers `src`, type-checking it first unless `checked` is false. fn lowerSource(gpa: std.mem.Allocator, src: []const u8, checked: bool) !void { var pr = try weld_etch.parseSource(gpa, src); @@ -112,3 +117,24 @@ test "the codegen refuses an optional fn return" { test "the same fn with scalar types lowers" { try lowerSource(std.testing.allocator, "fn total(xs: int) -> int { 0 }", true); } + +test "an unknown fn return type is refused by the checker" { + try std.testing.expect(try reports(std.testing.allocator, "fn f() -> Nope { 0 }", "unsupported return type on function 'f'")); +} + +test "an unknown method return type is refused by the checker" { + try std.testing.expect(try reports(std.testing.allocator, + \\struct V2 { + \\ x: int = 0 + \\} + \\impl V2 { + \\ fn size(self) -> Nope { + \\ self.x + \\ } + \\} + , "unsupported return type on method 'size'")); +} + +test "a known fn return type is accepted by the checker" { + try std.testing.expect(!try reports(std.testing.allocator, "fn f() -> int { 0 }", "unsupported return type")); +} diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index fbd4f369..7a2953a2 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -237,8 +237,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2435, - else => 2437, + .windows => 2438, + else => 2440, }; } From bda4f4cd9fd73d9b8209269987106691c0f481de Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 23 Sep 2026 15:03:07 +0200 Subject: [PATCH 019/141] fix(etch): refuse an overflowing literal or fold; wrap only at run time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item 1 of S5/G8 ter. A literal that overflows its type, and a constant whose folding overflows or divides by zero, are refused when the program is checked; at run time both backends follow part1 §12.4: overflow panics in Debug and ReleaseSafe and wraps in ReleaseFast, an integer division by zero panics in every mode. The fold. `const_eval.fold` is the one folder: the checker admits a constant through it and the interpreter, the scene cook and the codegen store the value it gives, so a default the checker admits cannot fall back to zero at compile. Its arithmetic is checked in every mode. Field defaults, top-level consts and field filters fold through it (E1101 on overflow, division by zero or a non-finite float); `true or false` now folds instead of storing false, and a string concatenation default, which no backend could materialise, is refused. The literals. Every int literal fits `int` and every float and duration literal is finite, in any context, with `-9223372036854775808` judged negated. `literalTypeFits` refuses a literal outside `i32`, `u32` or `f32` (E0200), reached by all 27 of its callers, by resource instance values, params-block defaults and collection literal elements against a `let` annotation. A non-literal or negative array size or fill count is E1101 instead of an unknown type or a zero length. The run time. `value.zig` holds the mode policy: add, sub, mul, neg and `i64.min / -1` return null where overflow panics and wrap otherwise. A compound assignment reports the typed cause the binary operator does. `as i32` / `as u32` narrow, `as f32` rounds, a float whose truncation does not fit its integer target fails in every mode. A store into an `i32` / `u32` field wraps or refuses instead of an `@intCast` abort. An inclusive range to `i64` max ends, and `tick_until` converts its budget checked. The codegen routes integer `+ - * / %`, negation, compound assignment and narrowing casts through prelude helpers that choose by mode, where it emitted native operators (UB in ReleaseFast, and runtime signed `/` did not compile); float `%` lowers to `@rem`. Adjacents on the same paths: an omitted struct field takes the zero of its type, or its string or enum default, where the interpreter gave int 0; an `i32` filter accepts an int literal it falsely refused; the scene cook's `registerOne` mapped `SchemaChanged`, `FieldOutOfBounds` and `CollectionDefaultNotEmpty` to OutOfMemory through an `else`. Floor 2440 -> 2490 / 2488, measured: 2471/2490 passed, 19 skipped. Co-Authored-By: Claude Opus 5.5 --- build.zig | 3 + src/etch/const_eval.zig | 204 ++++++++ src/etch/ecs_bridge.zig | 26 +- src/etch/interp.zig | 485 +++++++++++++++--- src/etch/root.zig | 1 + src/etch/scene_cook.zig | 22 +- src/etch/test_runner.zig | 14 + src/etch/types.zig | 302 ++++++++--- src/etch/value.zig | 122 ++++- src/etch/zig_codegen/lower.zig | 249 ++++++--- tests/etch/numeric_range_test.zig | 240 +++++++++ tests/etch_interp/codegen_corpus_build.zig | 2 + tests/etch_interp/corpus_facade.zig | 3 + .../programs/88_runtime_arith.etch | 30 ++ .../programs/88_runtime_arith.expected.zig | 29 ++ tests/scene/cook_errors_test.zig | 36 ++ tools/weld_lint/dead_tests.zig | 4 +- 17 files changed, 1517 insertions(+), 255 deletions(-) create mode 100644 src/etch/const_eval.zig create mode 100644 tests/etch/numeric_range_test.zig create mode 100644 tests/etch_interp/programs/88_runtime_arith.etch create mode 100644 tests/etch_interp/programs/88_runtime_arith.expected.zig diff --git a/build.zig b/build.zig index 68111384..67bbfa50 100644 --- a/build.zig +++ b/build.zig @@ -966,6 +966,9 @@ pub fn build(b: *std.Build) void { .{ .path = "tests/etch/storage_mode_test.zig", .etch = true }, // Every reader of a type node's name decides its non-`.named` branch. .{ .path = "tests/etch/type_node_kind_test.zig", .etch = true }, + // A literal that overflows its type, and a constant whose folding + // overflows or divides by zero, are refused at check time. + .{ .path = "tests/etch/numeric_range_test.zig", .etch = true, .dedicated_step = "test-numeric-range" }, // one test per type-checker diagnostic code: each names its code and // asserts PRESENCE, so it reddens the day emission stops. .{ .path = "tests/etch/diagnostic_coverage_test.zig", .etch = true }, diff --git a/src/etch/const_eval.zig b/src/etch/const_eval.zig new file mode 100644 index 00000000..1b52d7b4 --- /dev/null +++ b/src/etch/const_eval.zig @@ -0,0 +1,204 @@ +//! Folds a constant expression: numeric and boolean literals, and arithmetic, +//! comparison and logic over constants. The checker and the interpreter fold +//! through this one function, so a constant the checker admits is the constant +//! the interpreter stores. +//! +//! Integer arithmetic is checked in every build mode. The `ReleaseFast` wrap of +//! `etch-reference-part1.md` §12.4 applies to execution; a constant that +//! overflows is refused at compilation instead (`etch-resolver-types.md` §11). + +const std = @import("std"); +const ast_mod = @import("ast.zig"); + +const AstArena = ast_mod.AstArena; +const NodeId = ast_mod.NodeId; + +/// A folded constant. +pub const Const = union(enum) { + int_: i64, + float_: f64, + bool_: bool, +}; + +/// Why an expression does not fold. +pub const FoldError = error{ + /// Not a constant this folder evaluates: an identifier, a call, a string, a + /// `.variant`, a cast, `??`, `!`. + NotConstant, + /// Operands of different kinds, which the checker reports as a type error. + KindMismatch, + /// An integer literal outside `int`, or a float literal that is not finite. + LiteralOutOfRange, + IntegerOverflow, + DivisionByZero, + /// A float result that is not finite. + FloatOverflow, +} || std.mem.Allocator.Error; + +/// The magnitude an integer literal's text denotes, or null past `u64`. The +/// lexer keeps `_` separators anywhere in the digit run. +pub fn intLiteralMagnitude(text: []const u8) ?u64 { + var v: u64 = 0; + for (text) |c| { + if (c == '_') continue; + v = std.math.mul(u64, v, 10) catch return null; + v = std.math.add(u64, v, c - '0') catch return null; + } + return v; +} + +/// The value an integer literal denotes, negated when `negated`, or null outside +/// `int`. `-9223372036854775808` is the one magnitude that fits only negated. +pub fn intLiteralValue(text: []const u8, negated: bool) ?i64 { + const mag = intLiteralMagnitude(text) orelse return null; + const min_mag: u64 = @as(u64, std.math.maxInt(i64)) + 1; + if (negated) { + if (mag == min_mag) return std.math.minInt(i64); + if (mag > std.math.maxInt(i64)) return null; + return -@as(i64, @intCast(mag)); + } + if (mag > std.math.maxInt(i64)) return null; + return @intCast(mag); +} + +/// The value a float literal's text denotes, or null when it is not finite. +pub fn floatLiteralValue(gpa: std.mem.Allocator, text: []const u8) std.mem.Allocator.Error!?f64 { + const v = if (std.mem.indexOfScalar(u8, text, '_') == null) + std.fmt.parseFloat(f64, text) catch return null + else blk: { + const digits = try gpa.alloc(u8, text.len); + defer gpa.free(digits); + var n: usize = 0; + for (text) |c| { + if (c == '_') continue; + digits[n] = c; + n += 1; + } + break :blk std.fmt.parseFloat(f64, digits[0..n]) catch return null; + }; + if (!std.math.isFinite(v)) return null; + return v; +} + +/// Folds the expression at `id`. +pub fn fold(gpa: std.mem.Allocator, ast: *const AstArena, id: NodeId) FoldError!Const { + const data = ast.exprData(id); + switch (ast.exprKind(id)) { + .int_lit => return .{ .int_ = intLiteralValue(ast.strings.slice(data), false) orelse return error.LiteralOutOfRange }, + .float_lit => return .{ .float_ = (try floatLiteralValue(gpa, ast.strings.slice(data))) orelse return error.LiteralOutOfRange }, + .bool_lit => return .{ .bool_ = std.mem.eql(u8, ast.strings.slice(data), "true") }, + .unary => { + const u = ast.unary_exprs.items[data]; + switch (u.op) { + .neg => { + if (ast.exprKind(u.operand) == .int_lit) { + const text = ast.strings.slice(ast.exprData(u.operand)); + return .{ .int_ = intLiteralValue(text, true) orelse return error.LiteralOutOfRange }; + } + return switch (try fold(gpa, ast, u.operand)) { + .int_ => |x| .{ .int_ = std.math.negate(x) catch return error.IntegerOverflow }, + .float_ => |x| .{ .float_ = -x }, + .bool_ => error.KindMismatch, + }; + }, + .logical_not => return switch (try fold(gpa, ast, u.operand)) { + .bool_ => |x| .{ .bool_ = !x }, + else => error.KindMismatch, + }, + .force_unwrap => return error.NotConstant, + } + }, + .binary => { + const b = ast.binary_exprs.items[data]; + if (b.op == .coalesce) return error.NotConstant; + const lhs = try fold(gpa, ast, b.lhs); + const rhs = try fold(gpa, ast, b.rhs); + return binary(b.op, lhs, rhs); + }, + else => return error.NotConstant, + } +} + +fn binary(op: ast_mod.BinaryOp, lhs: Const, rhs: Const) FoldError!Const { + switch (lhs) { + .int_ => |a| { + if (rhs != .int_) return error.KindMismatch; + const c = rhs.int_; + return switch (op) { + .add => .{ .int_ = std.math.add(i64, a, c) catch return error.IntegerOverflow }, + .sub => .{ .int_ = std.math.sub(i64, a, c) catch return error.IntegerOverflow }, + .mul => .{ .int_ = std.math.mul(i64, a, c) catch return error.IntegerOverflow }, + .div => .{ .int_ = std.math.divTrunc(i64, a, c) catch |e| return switch (e) { + error.DivisionByZero => error.DivisionByZero, + error.Overflow => error.IntegerOverflow, + } }, + .rem => blk: { + if (c == 0) return error.DivisionByZero; + if (c == -1) break :blk .{ .int_ = 0 }; + break :blk .{ .int_ = @rem(a, c) }; + }, + .eq => .{ .bool_ = a == c }, + .neq => .{ .bool_ = a != c }, + .lt => .{ .bool_ = a < c }, + .gt => .{ .bool_ = a > c }, + .le => .{ .bool_ = a <= c }, + .ge => .{ .bool_ = a >= c }, + .logical_and, .logical_or => error.KindMismatch, + .coalesce => unreachable, + }; + }, + .float_ => |a| { + if (rhs != .float_) return error.KindMismatch; + const c = rhs.float_; + const r: f64 = switch (op) { + .add => a + c, + .sub => a - c, + .mul => a * c, + .div => a / c, + .rem => @rem(a, c), + .eq => return .{ .bool_ = a == c }, + .neq => return .{ .bool_ = a != c }, + .lt => return .{ .bool_ = a < c }, + .gt => return .{ .bool_ = a > c }, + .le => return .{ .bool_ = a <= c }, + .ge => return .{ .bool_ = a >= c }, + .logical_and, .logical_or => return error.KindMismatch, + .coalesce => unreachable, + }; + if (!std.math.isFinite(r)) return error.FloatOverflow; + return .{ .float_ = r }; + }, + .bool_ => |a| { + if (rhs != .bool_) return error.KindMismatch; + const c = rhs.bool_; + return switch (op) { + .logical_and => .{ .bool_ = a and c }, + .logical_or => .{ .bool_ = a or c }, + .eq => .{ .bool_ = a == c }, + .neq => .{ .bool_ = a != c }, + else => error.KindMismatch, + }; + }, + } +} + +test "an integer literal's magnitude ignores its separators" { + try std.testing.expectEqual(@as(?u64, 1000000), intLiteralMagnitude("1_000_000")); + try std.testing.expectEqual(@as(?u64, 1), intLiteralMagnitude("1_")); + try std.testing.expectEqual(@as(?u64, null), intLiteralMagnitude("18446744073709551616")); +} + +test "the int range is asymmetric: the minimum fits only negated" { + try std.testing.expectEqual(@as(?i64, std.math.maxInt(i64)), intLiteralValue("9223372036854775807", false)); + try std.testing.expectEqual(@as(?i64, null), intLiteralValue("9223372036854775808", false)); + try std.testing.expectEqual(@as(?i64, std.math.minInt(i64)), intLiteralValue("9223372036854775808", true)); + try std.testing.expectEqual(@as(?i64, null), intLiteralValue("9223372036854775809", true)); +} + +test "a float literal that is not finite has no value" { + const gpa = std.testing.allocator; + try std.testing.expectEqual(@as(?f64, 1000.5), try floatLiteralValue(gpa, "1_000.5")); + try std.testing.expectEqual(@as(?f64, 1.5), try floatLiteralValue(gpa, "1_.5_")); + const huge = "1" ++ "0" ** 400 ++ ".0"; + try std.testing.expectEqual(@as(?f64, null), try floatLiteralValue(gpa, huge)); +} diff --git a/src/etch/ecs_bridge.zig b/src/etch/ecs_bridge.zig index 9b2698b7..c8a791f4 100644 --- a/src/etch/ecs_bridge.zig +++ b/src/etch/ecs_bridge.zig @@ -67,6 +67,8 @@ pub const BridgeError = error{ /// detected at DEREFERENCE (§5.3 c). Distinct from `UnknownEntity` and /// `UnknownComponent`, which ask the same two questions at CONSTRUCTION. StaleComponentRef, + /// An integer outside the range of the field it is written to. + IntegerOverflow, }; /// One bridge instance per Etch program run. Lives for the same @@ -188,7 +190,7 @@ pub const Bridge = struct { const field = registry.findField(ref.component_id, field_name) orelse return BridgeError.UnknownField; const slot_bytes = try refBytes(world, ref); const field_bytes = slot_bytes[field.offset .. field.offset + @as(u16, @intCast(field.kind.sizeBytes()))]; - try writeValueAsBytes(field.kind, field_bytes, v); + try writeValueAsBytes(field.kind, field_bytes, narrowForStore(field.kind, v)); } /// Stamp `ref`'s slot as modified at `tick` — writes the `changed_tick` @@ -250,7 +252,7 @@ pub const Bridge = struct { const field = registry.findField(resource_id, field_name) orelse return BridgeError.UnknownField; const bytes = store.getMutResource(resource_id) orelse return BridgeError.UnknownResource; const slice = bytes[field.offset .. field.offset + @as(u16, @intCast(field.kind.sizeBytes()))]; - try writeValueAsBytes(field.kind, slice, v); + try writeValueAsBytes(field.kind, slice, narrowForStore(field.kind, v)); } /// Promote `bytes` into a fresh persistent allocation and store it in a @@ -408,8 +410,22 @@ pub fn readBytesAsValue(kind: FieldKind, bytes: []const u8) Value { }; } +/// `v` as a store into a `kind` field at runtime writes it: wrapped to an `i32` / +/// `u32` field's width where overflow wraps, unchanged otherwise, so that +/// `writeValueAsBytes` refuses a value that does not fit +/// (`etch-reference-part1.md` §12.4). +pub fn narrowForStore(kind: FieldKind, v: Value) Value { + if (!value_mod.overflow_wraps or v != .int_) return v; + return switch (kind) { + .i32_ => .{ .int_ = value_mod.intNarrow(i32, v.int_).? }, + .u32_ => .{ .int_ = value_mod.intNarrow(u32, v.int_).? }, + else => v, + }; +} + /// Encode an interpreter `Value` into the on-storage byte representation of a -/// field. `bytes` must already be sized to the field's column stride. +/// field. `bytes` must already be sized to the field's column stride. An +/// integer outside an `i32` / `u32` field's range is `error.IntegerOverflow`. /// /// Returns `error.TypeMismatch` when `v`'s tag is incompatible with the /// field's `kind`: a type incoherence is a recoverable typed error propagated @@ -440,14 +456,14 @@ pub fn writeValueAsBytes(kind: FieldKind, bytes: []u8, v: Value) BridgeError!voi }, .i32_ => { const x: i32 = switch (v) { - .int_ => |a| @intCast(a), + .int_ => |a| std.math.cast(i32, a) orelse return error.IntegerOverflow, else => return error.TypeMismatch, }; @memcpy(bytes[0..@sizeOf(i32)], std.mem.asBytes(&x)); }, .u32_ => { const x: u32 = switch (v) { - .int_ => |a| @intCast(a), + .int_ => |a| std.math.cast(u32, a) orelse return error.IntegerOverflow, else => return error.TypeMismatch, }; @memcpy(bytes[0..@sizeOf(u32)], std.mem.asBytes(&x)); diff --git a/src/etch/interp.zig b/src/etch/interp.zig index 334ab0f8..8178f5c8 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -21,6 +21,7 @@ const ast_mod = @import("ast.zig"); const types_mod = @import("types.zig"); const tagset_name = types_mod.tagset_component_name; const parser_mod = @import("parser.zig"); +const const_eval = @import("const_eval.zig"); const diag_mod = @import("diagnostics.zig"); const value_mod = @import("value.zig"); const bridge_mod = @import("ecs_bridge.zig"); @@ -685,9 +686,9 @@ fn writeI64At(bytes: []u8, off: u16, v: i64) void { /// Parse the seconds of a `Duration` literal lexeme (`"1.5s"` → 1.5) — the /// minimal Duration→seconds path `await wait` needs. `null` if the /// lexeme is malformed. General `Duration` arithmetic stays out of scope. -fn durationLiteralSeconds(text: []const u8) ?f64 { +fn durationLiteralSeconds(gpa: std.mem.Allocator, text: []const u8) std.mem.Allocator.Error!?f64 { if (text.len < 2 or text[text.len - 1] != 's') return null; - return std.fmt.parseFloat(f64, text[0 .. text.len - 1]) catch null; + return const_eval.floatLiteralValue(gpa, text[0 .. text.len - 1]); } // ─── Async suspension core (`etch-reference-part1.md §9.12`) ───────── @@ -3796,7 +3797,9 @@ pub const Interpreter = struct { const more = if (r.inclusive) r.next <= r.end else r.next < r.end; if (!more) return false; try locals.put(self.gpa, f.var_name, .{ .int_ = r.next }, false); - r.next += 1; + // An inclusive range can end at `i64` max, past which `next` has + // no successor: the range closes on its last value instead. + if (r.next == r.end) r.inclusive = false else r.next += 1; return true; }, .array => { @@ -3988,7 +3991,7 @@ pub const Interpreter = struct { switch (aw.target_kind) { .wait, .wait_unscaled => { if (self.ast.exprKind(aw.arg_expr) != .duration_lit) return error.RuntimeFailure; - const secs = durationLiteralSeconds(self.ast.strings.slice(self.ast.exprData(aw.arg_expr))) orelse return error.RuntimeFailure; + const secs = (try durationLiteralSeconds(self.gpa, self.ast.strings.slice(self.ast.exprData(aw.arg_expr)))) orelse return error.RuntimeFailure; if (secs < 0) return error.RuntimeFailure; const ticks = @round(secs * async_fixed_dt_hz); return switch (aw.target_kind) { @@ -4493,7 +4496,7 @@ pub const Interpreter = struct { switch (iter) { .range => |r| { var i: i64 = r.start; - range_loop: while (if (r.inclusive) i <= r.end else i < r.end) : (i += 1) { + range_loop: while (if (r.inclusive) i <= r.end else i < r.end) { try locals.put(self.gpa, f.var_name, Value{ .int_ = i }, false); try self.execStmtRun(world, locals, f.body_start, f.body_len); if (self.thrown or self.returning) return; // throw / return unwinds out of the loop @@ -4502,6 +4505,10 @@ pub const Interpreter = struct { .stop => break :range_loop, .propagate => return, } + // An inclusive range can end at `i64` max, past which + // `i` has no successor. + if (i == r.end) break :range_loop; + i += 1; } }, .array_ref => |handle| { @@ -4721,7 +4728,7 @@ pub const Interpreter = struct { const cur = locals.get(name_id) orelse return error.RuntimeFailure; const rhs = try self.evalExpr(world, locals, assign.value); if (self.thrown) return; // see `assignRhsThrew` - const new_v = applyAssignOp(cur, assign.op, rhs) catch return error.RuntimeFailure; + const new_v = applyAssignOp(cur, assign.op, rhs) catch return self.fail(assignFailureKind(assign.op, cur, rhs), self.ast.exprSpan(assign.target)); const ptr = locals.getPtr(name_id) orelse return error.RuntimeFailure; ptr.* = new_v; return; @@ -4737,7 +4744,7 @@ pub const Interpreter = struct { return self.fail(bridgeFailureKind(e), self.ast.exprSpan(assign.target)); const rhs = try self.evalExpr(world, locals, assign.value); if (self.thrown) return; // see `assignRhsThrew` - const new_v = applyAssignOp(cur, assign.op, rhs) catch return error.RuntimeFailure; + const new_v = applyAssignOp(cur, assign.op, rhs) catch return self.fail(assignFailureKind(assign.op, cur, rhs), self.ast.exprSpan(assign.target)); Bridge.writeComponentField(&world.registry, cref, world, field_name, new_v) catch |e| return self.fail(bridgeFailureKind(e), self.ast.exprSpan(assign.target)); // Change detection: stamp `changed_tick = current_tick` @@ -4801,7 +4808,7 @@ pub const Interpreter = struct { return self.fail(bridgeFailureKind(e), self.ast.exprSpan(assign.target)); const rhs = try self.evalExpr(world, locals, assign.value); if (self.thrown) return; // see `assignRhsThrew` - const new_v = applyAssignOp(cur, assign.op, rhs) catch return error.RuntimeFailure; + const new_v = applyAssignOp(cur, assign.op, rhs) catch return self.fail(assignFailureKind(assign.op, cur, rhs), self.ast.exprSpan(assign.target)); Bridge.writeResourceField(&world.registry, &world.resources, rref.resource_id, field_name, new_v) catch |e| return self.fail(bridgeFailureKind(e), self.ast.exprSpan(assign.target)); return; @@ -4823,7 +4830,7 @@ pub const Interpreter = struct { const cur = self.structs.list.items[handle].fields.items[k].value; const rhs = try self.evalExpr(world, locals, assign.value); if (self.thrown) return; // see `assignRhsThrew` - const new_v = applyAssignOp(cur, assign.op, rhs) catch return error.RuntimeFailure; + const new_v = applyAssignOp(cur, assign.op, rhs) catch return self.fail(assignFailureKind(assign.op, cur, rhs), self.ast.exprSpan(assign.target)); self.structs.list.items[handle].fields.items[k].value = new_v; return; }, @@ -4936,6 +4943,39 @@ pub const Interpreter = struct { return sname; } + /// The value of a struct field a literal omits: its declared default, or the + /// zero of its type, which is what the codegen's `zeroDefault` emits. + fn structFieldDefault(self: *Interpreter, f: ast_mod.Field) !Value { + // A struct-typed field has no agreed default: the resolver requires it + // provided (E0208). + if (self.structFieldTypeName(f) != null) return error.RuntimeFailure; + if (!f.default_value.isNone()) { + return switch (self.ast.exprKind(f.default_value)) { + .string_lit => Value{ .string_id = self.ast.exprData(f.default_value) }, + .tag_path => self.enumFieldShorthand(f, f.default_value) orelse error.RuntimeFailure, + else => evalConst(self.gpa, self.ast, f.default_value) catch |err| switch (err) { + error.OutOfMemory => error.OutOfMemory, + else => error.RuntimeFailure, + }, + }; + } + if (self.ast.typeNodeKind(f.type_node) == .optional) { + const handle: u32 = @intCast(self.optionals.items.len); + try self.optionals.append(self.gpa, null); + return Value{ .optional = handle }; + } + const declared = self.ast.namedTypeName(f.type_node) orelse return error.RuntimeFailure; + const resolved = self.ast.resolveTypeAliasName(declared); + const tname = self.ast.strings.slice(resolved); + const eql = std.mem.eql; + if (eql(u8, tname, "int") or eql(u8, tname, "i32") or eql(u8, tname, "u32")) return Value{ .int_ = 0 }; + if (eql(u8, tname, "float") or eql(u8, tname, "f32") or eql(u8, tname, "f64")) return Value{ .float_ = 0.0 }; + if (eql(u8, tname, "bool")) return Value{ .bool_ = false }; + if (eql(u8, tname, "string")) return Value{ .string_id = 0 }; + if (self.enum_decls.get(resolved) != null) return Value{ .enum_value = .{ .type_name = resolved, .variant = 0 } }; + return error.RuntimeFailure; + } + /// Materialize a struct literal as a fresh `type_name` value in the /// rule-body struct store (split out so /// the anonymous `.{ … }` form evaluates through the same point with the @@ -4982,14 +5022,7 @@ pub const Interpreter = struct { break; } } - const fval = provided orelse blk: { - // A struct-typed field has no agreed default (the resolver - // requires literal provision, E0208) — belt against the - // zero-fill below silently standing in for one. - if (self.structFieldTypeName(f) != null) return error.RuntimeFailure; - if (f.default_value.isNone()) break :blk Value{ .int_ = 0 }; - break :blk evalConst(self.ast, f.default_value) catch Value{ .int_ = 0 }; - }; + const fval = provided orelse try self.structFieldDefault(f); try self.structs.list.items[handle].fields.append(self.gpa, .{ .name = f.name, .value = fval }); } return Value{ .struct_ref = handle }; @@ -5206,7 +5239,7 @@ pub const Interpreter = struct { const pred = try self.evalArg(world, locals, call, 0); const timeout = try self.evalArg(world, locals, call, 1); if (timeout != .duration) return error.RuntimeFailure; - const budget: i64 = @intFromFloat(@round(timeout.duration * async_fixed_dt_hz)); + const budget = value_mod.floatTrunc(i64, @round(timeout.duration * async_fixed_dt_hz)) orelse return error.RuntimeFailure; // Keep the event store clean for a later `emit; tick` in the same test. defer self.events.clear(self.gpa); if (self.events.list.items.len > 0) self.suppress_event_clear = true; @@ -5359,7 +5392,8 @@ pub const Interpreter = struct { const v = try self.evalExpr(world, locals, flit.value); const fsize: u16 = @intCast(fd.kind.sizeBytes()); const field_bytes = buf[fd.offset .. fd.offset + fsize]; - bridge_mod.writeValueAsBytes(fd.kind, field_bytes, v) catch return error.RuntimeFailure; + bridge_mod.writeValueAsBytes(fd.kind, field_bytes, bridge_mod.narrowForStore(fd.kind, v)) catch |e| + return self.fail(bridgeFailureKind(e), self.ast.exprSpan(flit.value)); } return .{ .cid = cid, .bytes = buf }; } @@ -5982,13 +6016,11 @@ pub const Interpreter = struct { const data = self.ast.exprData(id); switch (kind) { .int_lit => { - const text = self.ast.strings.slice(data); - const v = std.fmt.parseInt(i64, text, 10) catch return error.RuntimeFailure; + const v = const_eval.intLiteralValue(self.ast.strings.slice(data), false) orelse return error.RuntimeFailure; return Value{ .int_ = v }; }, .float_lit => { - const text = self.ast.strings.slice(data); - const v = std.fmt.parseFloat(f64, text) catch return error.RuntimeFailure; + const v = (try const_eval.floatLiteralValue(self.gpa, self.ast.strings.slice(data))) orelse return error.RuntimeFailure; return Value{ .float_ = v }; }, .bool_lit => { @@ -6000,7 +6032,7 @@ pub const Interpreter = struct { // carried so a timer argument can be a full expression // (`after(d)`). `await wait` keeps its own literal-only path // in `evalAwaitTarget`. - const secs = durationLiteralSeconds(self.ast.strings.slice(data)) orelse return error.RuntimeFailure; + const secs = (try durationLiteralSeconds(self.gpa, self.ast.strings.slice(data))) orelse return error.RuntimeFailure; return Value{ .duration = secs }; }, .string_lit => return Value{ .string_id = data }, @@ -6152,10 +6184,14 @@ pub const Interpreter = struct { }, .unary => { const u = self.ast.unary_exprs.items[data]; + if (u.op == .neg and self.ast.exprKind(u.operand) == .int_lit) { + const text = self.ast.strings.slice(self.ast.exprData(u.operand)); + return Value{ .int_ = const_eval.intLiteralValue(text, true) orelse return error.RuntimeFailure }; + } const v = try self.evalExpr(world, locals, u.operand); return switch (u.op) { .neg => switch (v) { - .int_ => |x| Value{ .int_ = -x }, + .int_ => |x| Value{ .int_ = value_mod.intNeg(x) orelse return self.fail(.IntegerOverflow, self.ast.exprSpan(id)) }, .float_ => |x| Value{ .float_ = -x }, else => error.RuntimeFailure, }, @@ -6235,20 +6271,40 @@ pub const Interpreter = struct { return error.RuntimeFailure; }, .cast => { - // `operand as Type`. Runtime values - // carry int as i64 and float as f64; a cast only flips the - // numeric domain (int↔float). Integer width narrowing is a - // storage concern handled on write, not in the Value. + // `operand as Type` (`etch-grammar.md` §2.6). An int is held as + // `i64` and a float as `f64`: a cast to `i32` / `u32` narrows the + // value to that width, one to `f32` rounds it to that precision. const c = self.ast.casts.items[data]; const v = try self.evalExpr(world, locals, c.operand); const target = self.ast.namedTypeName(c.type_node) orelse return error.RuntimeFailure; const tname = self.ast.strings.slice(self.ast.resolveTypeAliasName(target)); - const to_float = std.mem.eql(u8, tname, "float") or std.mem.eql(u8, tname, "f32") or std.mem.eql(u8, tname, "f64"); - return switch (v) { - .int_ => |x| if (to_float) Value{ .float_ = @floatFromInt(x) } else Value{ .int_ = x }, - .float_ => |x| if (to_float) Value{ .float_ = x } else Value{ .int_ = @intFromFloat(x) }, - else => error.RuntimeFailure, + const eql = std.mem.eql; + const span = self.ast.exprSpan(id); + if (eql(u8, tname, "float") or eql(u8, tname, "f64") or eql(u8, tname, "f32")) { + const f: f64 = switch (v) { + .int_ => |x| @floatFromInt(x), + .float_ => |x| x, + else => return error.RuntimeFailure, + }; + return Value{ .float_ = if (eql(u8, tname, "f32")) @as(f32, @floatCast(f)) else f }; + } + const narrow = eql(u8, tname, "i32") or eql(u8, tname, "u32"); + const n: i64 = switch (v) { + .int_ => |x| if (!narrow) + x + else if (eql(u8, tname, "i32")) + value_mod.intNarrow(i32, x) orelse return self.fail(.IntegerOverflow, span) + else + value_mod.intNarrow(u32, x) orelse return self.fail(.IntegerOverflow, span), + .float_ => |x| (if (!narrow) + value_mod.floatTrunc(i64, x) + else if (eql(u8, tname, "i32")) + value_mod.floatTrunc(i32, x) + else + value_mod.floatTrunc(u32, x)) orelse return self.fail(.IntegerOverflow, span), + else => return error.RuntimeFailure, }; + return Value{ .int_ = n }; }, .array_lit => { // `[a, b, c]` / `[v; n]` → materialize a fresh array in the @@ -6719,6 +6775,20 @@ fn bindParams( } } +/// Classify an `applyAssignOp` failure the way `arithFailureKind` classifies the +/// binary operator the assignment applies. +fn assignFailureKind(op: ast_mod.AssignOp, cur: Value, rhs: Value) RuntimeErrorKind { + const bin: ast_mod.BinaryOp = switch (op) { + .assign => return .UnsupportedExpr, + .add_assign => .add, + .sub_assign => .sub, + .mul_assign => .mul, + .div_assign => .div, + .rem_assign => .rem, + }; + return arithFailureKind(bin, cur, rhs); +} + fn applyAssignOp(cur: Value, op: ast_mod.AssignOp, rhs: Value) !Value { return switch (op) { .assign => rhs, @@ -6740,6 +6810,7 @@ fn bridgeFailureKind(err: anyerror) RuntimeErrorKind { return switch (err) { error.TypeMismatch => .TypeMismatch, error.StaleComponentRef => .StaleComponentRef, + error.IntegerOverflow => .IntegerOverflow, else => .UnsupportedExpr, }; } @@ -6780,9 +6851,9 @@ fn arithFailureKind(op: ast_mod.BinaryOp, a: Value, b: Value) RuntimeErrorKind { fn binaryArith(op: ast_mod.BinaryOp, a: Value, b: Value) !Value { if (a == .int_ and b == .int_) { return switch (op) { - .add => Value{ .int_ = value_mod.intAddChecked(a.int_, b.int_) orelse return error.RuntimeFailure }, - .sub => Value{ .int_ = value_mod.intSubChecked(a.int_, b.int_) orelse return error.RuntimeFailure }, - .mul => Value{ .int_ = value_mod.intMulChecked(a.int_, b.int_) orelse return error.RuntimeFailure }, + .add => Value{ .int_ = value_mod.intAdd(a.int_, b.int_) orelse return error.RuntimeFailure }, + .sub => Value{ .int_ = value_mod.intSub(a.int_, b.int_) orelse return error.RuntimeFailure }, + .mul => Value{ .int_ = value_mod.intMul(a.int_, b.int_) orelse return error.RuntimeFailure }, .div => Value{ .int_ = value_mod.intDiv(a.int_, b.int_) orelse return error.RuntimeFailure }, .rem => Value{ .int_ = value_mod.intRem(a.int_, b.int_) orelse return error.RuntimeFailure }, else => unreachable, @@ -6854,45 +6925,14 @@ fn binaryCompare(op: ast_mod.BinaryOp, a: Value, b: Value) !Value { // ── Const evaluator ── -/// Pure constant-folding evaluator over an Etch AST subtree. Used -/// by the type-checker for `const` resolution and by `codegen` to -/// pre-evaluate literal expressions during lowering. -pub fn evalConst(ast: *const AstArena, node: NodeId) !Value { - const kind = ast.exprKind(node); - const data = ast.exprData(node); - switch (kind) { - .int_lit => return Value{ .int_ = try std.fmt.parseInt(i64, ast.strings.slice(data), 10) }, - .float_lit => return Value{ .float_ = try std.fmt.parseFloat(f64, ast.strings.slice(data)) }, - .bool_lit => return Value{ .bool_ = std.mem.eql(u8, ast.strings.slice(data), "true") }, - .binary => { - const b = ast.binary_exprs.items[data]; - const a = try evalConst(ast, b.lhs); - const c = try evalConst(ast, b.rhs); - return switch (b.op) { - .add, .sub, .mul, .div, .rem => binaryArith(b.op, a, c) catch return error.NotConstEvaluable, - .eq, .neq, .lt, .gt, .le, .ge => binaryCompare(b.op, a, c) catch return error.NotConstEvaluable, - else => return error.NotConstEvaluable, - }; - }, - .unary => { - const u = ast.unary_exprs.items[data]; - const v = try evalConst(ast, u.operand); - return switch (u.op) { - .neg => switch (v) { - .int_ => |x| Value{ .int_ = -x }, - .float_ => |x| Value{ .float_ = -x }, - else => return error.NotConstEvaluable, - }, - .logical_not => switch (v) { - .bool_ => |x| Value{ .bool_ = !x }, - else => return error.NotConstEvaluable, - }, - // `expr!` needs the runtime optional store — never const. - .force_unwrap => return error.NotConstEvaluable, - }; - }, - else => return error.UnsupportedExpr, - } +/// Folds a constant expression through `const_eval.fold`, the folder the +/// checker admits constants with. +pub fn evalConst(gpa: std.mem.Allocator, ast: *const AstArena, node: NodeId) const_eval.FoldError!Value { + return switch (try const_eval.fold(gpa, ast, node)) { + .int_ => |x| .{ .int_ = x }, + .float_ => |x| .{ .float_ = x }, + .bool_ => |x| .{ .bool_ = x }, + }; } // ── Compilation passes ── @@ -7288,7 +7328,7 @@ fn dropPersistentMap(gpa: std.mem.Allocator, p: [*]u8, size: usize) void { /// const expression is `evalConst`'d (POD inline). Errors on a non-const entry. fn constCollectionValue(gpa: std.mem.Allocator, ast: *const AstArena, node: NodeId) !Value { if (ast.exprKind(node) == .string_lit) return ownBytesAsPersistentString(gpa, ast.strings.slice(ast.exprData(node))); - return evalConst(ast, node); + return evalConst(gpa, ast, node); } /// Build a `type_array` container for a resource field: empty, or a literal-array @@ -7605,8 +7645,16 @@ fn prepareTypeEntry(gpa: std.mem.Allocator, ast: *const AstArena, registry: *con continue; } if (f.default_value.isNone()) continue; - const v = evalConst(ast, f.default_value) catch continue; - try bridge_mod.writeValueAsBytes(fd.kind, slot, v); + const v = evalConst(gpa, ast, f.default_value) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.LiteralOutOfRange, error.IntegerOverflow, error.FloatOverflow => return error.ValueOutOfRange, + error.NotConstant, error.KindMismatch, error.DivisionByZero => return error.InvalidProgram, + }; + bridge_mod.writeValueAsBytes(fd.kind, slot, v) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.IntegerOverflow => return error.ValueOutOfRange, + else => return error.InvalidProgram, + }; } return registry.prepareEntry(gpa, .{ @@ -8079,7 +8127,10 @@ fn lowerWhen(ctx: *LowerWhenCtx, when_idx: u32) error{ OutOfMemory, InvalidProgr const id = ctx.bridge.componentIdOf(tname) orelse return error.InvalidProgram; const fname = ast.strings.slice(node.field_name); const fd = ctx.registry.findField(id, fname) orelse return error.InvalidProgram; - const v = evalConst(ast, node.filter_value) catch return error.InvalidProgram; + const v = evalConst(ctx.gpa, ast, node.filter_value) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return error.InvalidProgram, + }; // One filter per `has T { … }` clause — append, never // overwrite. try ctx.filters.append(ctx.gpa, .{ @@ -8232,7 +8283,7 @@ test "evalConst on int literal returns Value.int" { const lit_id = try ast.strings.intern(gpa, "42"); const node = try ast.addExpr(gpa, .int_lit, lit_id, .{ .byte_start = 0, .byte_end = 2 }); - const v = try evalConst(&ast, node); + const v = try evalConst(gpa, &ast, node); try std.testing.expectEqual(@as(i64, 42), v.int_); } @@ -8247,18 +8298,18 @@ test "evalConst on arithmetic on literals folds correctly" { const b = try ast.addExpr(gpa, .int_lit, lit_b, .{ .byte_start = 0, .byte_end = 0 }); const bin = try ast.addBinary(gpa, .add, a, b, .{ .byte_start = 0, .byte_end = 0 }); - const v = try evalConst(&ast, bin); + const v = try evalConst(gpa, &ast, bin); try std.testing.expectEqual(@as(i64, 5), v.int_); } -test "evalConst on tag_path returns UnsupportedExpr" { +test "evalConst on tag_path is not a constant it folds" { const gpa = std.testing.allocator; var ast = try AstArena.init(gpa); defer ast.deinit(gpa); const lit_id = try ast.strings.intern(gpa, "update"); const node = try ast.addExpr(gpa, .tag_path, lit_id, .{ .byte_start = 0, .byte_end = 0 }); - try std.testing.expectError(error.UnsupportedExpr, evalConst(&ast, node)); + try std.testing.expectError(error.NotConstant, evalConst(gpa, &ast, node)); } test "runProgram on minimal component + rule mutates entity" { @@ -16270,3 +16321,269 @@ test "a rule parameter typed by an alias of a scalar is bound as that scalar" { const dt = pr.ast.rule_params.items[rule.params_start + 1].name; try std.testing.expect(locals.get(dt).? == .float_); } + +/// One tick of `source` over one entity carrying `Acc`, unchecked. The caller +/// owns the returned interpreter. +fn tickOnAcc(gpa: std.mem.Allocator, world: *World, pr: *const parser_mod.ParseResult) !struct { interp: Interpreter, report: RuntimeReport, bytes: []u8 } { + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var interp = try Interpreter.compile(gpa, &pr.ast, world); + errdefer interp.deinit(); + const cid = world.registry.idOf("Acc").?; + const e = try world.spawnDynamic(gpa, &[_]ComponentId{cid}); + const report = try interp.runFor(world, 1); + const off = world.registry.findField(cid, "out").?.offset; + return .{ .interp = interp, .report = report, .bytes = world.componentBytes(e, cid).?[off..] }; +} + +/// Asserts `report` holds exactly one runtime error, of `kind`. +fn expectRuntimeError(report: RuntimeReport, kind: RuntimeErrorKind) !void { + try std.testing.expectEqual(@as(u64, 1), report.runtime_errors); + try std.testing.expectEqual(kind, (report.last_error orelse return error.TestExpectedTypedError).kind); +} + +test "an overflowing addition panics or wraps by build mode" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, + \\component Acc { out: int = 0 } + \\rule r(entity: Entity) when entity has Acc { + \\ let big = 9223372036854775807 + \\ entity.get_mut(Acc).out = big + 1 + \\} + ); + defer pr.deinit(gpa); + var run = try tickOnAcc(gpa, &world, &pr); + defer run.interp.deinit(); + if (value_mod.overflow_wraps) { + try std.testing.expectEqual(@as(u64, 0), run.report.runtime_errors); + try std.testing.expectEqual(std.math.minInt(i64), std.mem.readInt(i64, run.bytes[0..8], .little)); + } else try expectRuntimeError(run.report, .IntegerOverflow); +} + +test "an overflowing compound assignment reports IntegerOverflow" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, + \\component Acc { out: int = 0 } + \\rule r(entity: Entity) when entity has Acc { + \\ let mut x = 9223372036854775807 + \\ x += 1 + \\ entity.get_mut(Acc).out = x + \\} + ); + defer pr.deinit(gpa); + var run = try tickOnAcc(gpa, &world, &pr); + defer run.interp.deinit(); + if (value_mod.overflow_wraps) { + try std.testing.expectEqual(std.math.minInt(i64), std.mem.readInt(i64, run.bytes[0..8], .little)); + } else try expectRuntimeError(run.report, .IntegerOverflow); +} + +test "negating the int minimum panics or wraps by build mode" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, + \\component Acc { out: int = 0 } + \\rule r(entity: Entity) when entity has Acc { + \\ let m = -9223372036854775808 + \\ entity.get_mut(Acc).out = -m + \\} + ); + defer pr.deinit(gpa); + var run = try tickOnAcc(gpa, &world, &pr); + defer run.interp.deinit(); + if (value_mod.overflow_wraps) { + try std.testing.expectEqual(std.math.minInt(i64), std.mem.readInt(i64, run.bytes[0..8], .little)); + } else try expectRuntimeError(run.report, .IntegerOverflow); +} + +test "the int minimum literal evaluates" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, + \\component Acc { out: int = 0 } + \\rule r(entity: Entity) when entity has Acc { entity.get_mut(Acc).out = -9223372036854775808 } + ); + defer pr.deinit(gpa); + var run = try tickOnAcc(gpa, &world, &pr); + defer run.interp.deinit(); + try std.testing.expectEqual(@as(u64, 0), run.report.runtime_errors); + try std.testing.expectEqual(std.math.minInt(i64), std.mem.readInt(i64, run.bytes[0..8], .little)); +} + +test "a literal with a trailing separator evaluates" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, + \\component Acc { out: int = 0 } + \\rule r(entity: Entity) when entity has Acc { entity.get_mut(Acc).out = 1_000_ } + ); + defer pr.deinit(gpa); + var run = try tickOnAcc(gpa, &world, &pr); + defer run.interp.deinit(); + try std.testing.expectEqual(@as(u64, 0), run.report.runtime_errors); + try std.testing.expectEqual(@as(i64, 1000), std.mem.readInt(i64, run.bytes[0..8], .little)); +} + +test "a narrowing cast panics or wraps by build mode" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, + \\component Acc { out: i32 = 0 } + \\rule r(entity: Entity) when entity has Acc { + \\ let big = 3000000000 + \\ entity.get_mut(Acc).out = big as i32 + \\} + ); + defer pr.deinit(gpa); + var run = try tickOnAcc(gpa, &world, &pr); + defer run.interp.deinit(); + if (value_mod.overflow_wraps) { + try std.testing.expectEqual(@as(i32, -1294967296), std.mem.readInt(i32, run.bytes[0..4], .little)); + } else try expectRuntimeError(run.report, .IntegerOverflow); +} + +test "a float beyond the integer range fails its cast in every mode" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, + \\component Acc { out: int = 0 } + \\rule r(entity: Entity) when entity has Acc { + \\ let f = 100000000000000000000.0 + \\ entity.get_mut(Acc).out = f as int + \\} + ); + defer pr.deinit(gpa); + var run = try tickOnAcc(gpa, &world, &pr); + defer run.interp.deinit(); + try expectRuntimeError(run.report, .IntegerOverflow); +} + +test "a cast to f32 rounds to f32" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, + \\component Acc { out: float = 0.0 } + \\rule r(entity: Entity) when entity has Acc { + \\ let x = 0.1 + \\ entity.get_mut(Acc).out = (x as f32) as float + \\} + ); + defer pr.deinit(gpa); + var run = try tickOnAcc(gpa, &world, &pr); + defer run.interp.deinit(); + try std.testing.expectEqual(@as(u64, 0), run.report.runtime_errors); + const want: f64 = @as(f32, 0.1); + try std.testing.expectEqual(want, @as(f64, @bitCast(std.mem.readInt(u64, run.bytes[0..8], .little)))); +} + +test "a store outside an i32 field panics or wraps by build mode" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, + \\component Acc { out: i32 = 2147483647 } + \\rule r(entity: Entity) when entity has Acc { + \\ let a = entity.get(Acc).out + \\ entity.get_mut(Acc).out = a + a + \\} + ); + defer pr.deinit(gpa); + var run = try tickOnAcc(gpa, &world, &pr); + defer run.interp.deinit(); + if (value_mod.overflow_wraps) { + try std.testing.expectEqual(@as(i32, -2), std.mem.readInt(i32, run.bytes[0..4], .little)); + } else try expectRuntimeError(run.report, .IntegerOverflow); +} + +test "an inclusive range ending at the int maximum terminates" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, + \\component Acc { out: int = 0 } + \\rule r(entity: Entity) when entity has Acc { + \\ let mut n = 0 + \\ for i in 9223372036854775806..=9223372036854775807 { n += 1 } + \\ entity.get_mut(Acc).out = n + \\} + ); + defer pr.deinit(gpa); + var run = try tickOnAcc(gpa, &world, &pr); + defer run.interp.deinit(); + try std.testing.expectEqual(@as(u64, 0), run.report.runtime_errors); + try std.testing.expectEqual(@as(i64, 2), std.mem.readInt(i64, run.bytes[0..8], .little)); +} + +test "an omitted struct field takes the zero of its type" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, + \\component Acc { out: float = 0.0 } + \\struct S { + \\ x: float + \\ n: int = 1 + \\} + \\rule r(entity: Entity) when entity has Acc { + \\ let s = S { n: 2 } + \\ entity.get_mut(Acc).out = s.x + 1.5 + \\} + ); + defer pr.deinit(gpa); + var run = try tickOnAcc(gpa, &world, &pr); + defer run.interp.deinit(); + try std.testing.expectEqual(@as(u64, 0), run.report.runtime_errors); + try std.testing.expectEqual(@as(f64, 1.5), @as(f64, @bitCast(std.mem.readInt(u64, run.bytes[0..8], .little)))); +} + +test "an omitted struct field takes its string default" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, + \\component Acc { out: int = 0 } + \\struct S { + \\ name: string = "hi" + \\ n: int = 1 + \\} + \\rule r(entity: Entity) when entity has Acc { + \\ let s = S { n: 2 } + \\ entity.get_mut(Acc).out = s.name.len() + \\} + ); + defer pr.deinit(gpa); + var run = try tickOnAcc(gpa, &world, &pr); + defer run.interp.deinit(); + try std.testing.expectEqual(@as(u64, 0), run.report.runtime_errors); + try std.testing.expectEqual(@as(i64, 2), std.mem.readInt(i64, run.bytes[0..8], .little)); +} + +test "a default of logic over constants is stored" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, "component C { flag: bool = true or false }"); + defer pr.deinit(gpa); + var interp = try compileUnchecked(gpa, &pr, &world); + defer interp.deinit(); + const cid = world.registry.idOf("C").?; + try std.testing.expectEqual(@as(u8, 1), world.registry.componentDefaultBytes(cid)[0]); +} + +test "a default that overflows its i32 field is refused at compile" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, "component C { v: i32 = 3000000000 }"); + defer pr.deinit(gpa); + try std.testing.expectError(error.ValueOutOfRange, compileUnchecked(gpa, &pr, &world)); +} diff --git a/src/etch/root.zig b/src/etch/root.zig index 1ecdb869..5e43837c 100644 --- a/src/etch/root.zig +++ b/src/etch/root.zig @@ -65,6 +65,7 @@ comptime { _ = @import("interp.zig"); _ = @import("value.zig"); _ = @import("ecs_bridge.zig"); + _ = @import("const_eval.zig"); // `persistent.zig` lives in Tier 0 (`src/core/memory`) and is pinned by // `src/core/memory/root.zig`, reached here via `weld_core.memory` — so it // needs no entry of its own below. diff --git a/src/etch/scene_cook.zig b/src/etch/scene_cook.zig index 766cc065..08b107d4 100644 --- a/src/etch/scene_cook.zig +++ b/src/etch/scene_cook.zig @@ -83,6 +83,8 @@ pub const CookError = error{ SpreadUnsupported, /// A field value expression is not constant-evaluable at cook time. NonConstValue, + /// A constant field value overflows, or does not fit its field's type. + ValueOutOfRange, /// A value's type does not match the field's kind. TypeMismatch, /// A `uuid:`/`parent:` enum value referenced an unknown enum variant. @@ -427,8 +429,10 @@ const Builder = struct { return interp.compileTypeDecl(self.gpa, self.ast, self.registry, &self.bridge, name, fields_start, fields_len, reg_kind, requires, storage) catch |e| switch (e) { error.InvalidProgram => fail(diag_out, error.UnsupportedFieldKind, "component/resource field has an unsupported type (only scalars, plus resource string/enum, are cookable)"), error.LayoutTooLarge => fail(diag_out, error.UnsupportedFieldKind, "component/resource declaration exceeds the registry's 64 KiB"), - error.DuplicateComponent => fail(diag_out, error.DuplicateType, "component/resource type declared more than once"), - else => error.OutOfMemory, + error.DuplicateComponent, error.SchemaChanged => fail(diag_out, error.DuplicateType, "component/resource type declared more than once"), + error.FieldOutOfBounds, error.CollectionDefaultNotEmpty => fail(diag_out, error.UnsupportedFieldKind, "the registry refused the declaration's field layout or defaults"), + error.ValueOutOfRange => fail(diag_out, error.ValueOutOfRange, "a field default overflows its type"), + error.OutOfMemory => error.OutOfMemory, }; } @@ -950,7 +954,7 @@ const Builder = struct { /// version rides through to `SceneHeader.content_version` unchanged. fn versionFromNode(self: *Builder, version: NodeId, diag_out: ?*[]const u8) CookError!u16 { if (version.isNone()) return 0; - const v = interp.evalConst(self.ast, version) catch return fail(diag_out, error.NonConstValue, "version must be a constant int"); + const v = interp.evalConst(self.gpa, self.ast, version) catch return fail(diag_out, error.NonConstValue, "version must be a constant int"); const x: i64 = switch (v) { .int_ => |n| n, else => return fail(diag_out, error.NonConstValue, "version must be an int"), @@ -1122,8 +1126,16 @@ const Builder = struct { /// POD scalar kinds shared by components and resources. fn encodeScalar(self: *Builder, blob: []u8, fd: FieldDesc, value: NodeId, diag_out: ?*[]const u8) CookError!void { const slot = blob[fd.offset .. fd.offset + @as(u16, @intCast(fd.kind.sizeBytes()))]; - const v = interp.evalConst(self.ast, value) catch return fail(diag_out, error.NonConstValue, "field value is not constant at cook time"); - bridge_mod.writeValueAsBytes(fd.kind, slot, v) catch return fail(diag_out, error.TypeMismatch, "field value type does not match the field kind"); + const v = interp.evalConst(self.gpa, self.ast, value) catch |err| return switch (err) { + error.OutOfMemory => error.OutOfMemory, + error.LiteralOutOfRange, error.IntegerOverflow, error.FloatOverflow => fail(diag_out, error.ValueOutOfRange, "field value overflows its type"), + error.DivisionByZero => fail(diag_out, error.NonConstValue, "field value divides by zero"), + error.NotConstant, error.KindMismatch => fail(diag_out, error.NonConstValue, "field value is not constant at cook time"), + }; + bridge_mod.writeValueAsBytes(fd.kind, slot, v) catch |err| return switch (err) { + error.IntegerOverflow => fail(diag_out, error.ValueOutOfRange, "field value does not fit the field's type"), + else => fail(diag_out, error.TypeMismatch, "field value type does not match the field kind"), + }; } /// Group built entities by their FULL declared component set (sorted ids) diff --git a/src/etch/test_runner.zig b/src/etch/test_runner.zig index b80413ec..9676bb67 100644 --- a/src/etch/test_runner.zig +++ b/src/etch/test_runner.zig @@ -573,6 +573,20 @@ test "tick_until stops on the predicate and on timeout" { try std.testing.expectEqual(@as(u32, 0), report.failed); } +test "a tick_until budget beyond the int range fails the test" { + const gpa = std.testing.allocator; + var report = try runSource(gpa, + \\resource Counter { n: int = 0 } + \\test "huge timeout" { + \\ let world = test_world() + \\ let hit = tick_until(|| false, 1000000000000000000000.0s) + \\ assert(not hit) + \\} + ); + defer report.deinit(); + try std.testing.expectEqual(@as(u32, 1), report.failed); +} + // ─── regressions: stale assert message, string-aware compare, throwing pred ─ test "a rule-body assert failure during tick does not leak into the test's message" { diff --git a/src/etch/types.zig b/src/etch/types.zig index 86283cb2..1ad385c7 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -8,6 +8,7 @@ const std = @import("std"); const ast_mod = @import("ast.zig"); +const const_eval = @import("const_eval.zig"); const diag_mod = @import("diagnostics.zig"); const tags_mod = @import("tags.zig"); const token_mod = @import("token.zig"); @@ -419,19 +420,22 @@ fn isKnownTrackType(name: []const u8) bool { /// FLOAT_LITERAL "s"`, §1.4 — single `s` suffix). Distinct from the deferred /// RUNTIME duration eval (fail-loud both backends). `null` if not a duration /// literal. Backs E1744 KeyframeOutOfRange + E1745 KeyframesUnordered. -fn durationLitSeconds(arena: *const AstArena, expr_id: NodeId) ?f64 { +fn durationLitSeconds(gpa: std.mem.Allocator, arena: *const AstArena, expr_id: NodeId) !?f64 { if (arena.exprKind(expr_id) != .duration_lit) return null; const lex = arena.strings.slice(arena.exprData(expr_id)); if (lex.len < 2 or lex[lex.len - 1] != 's') return null; - return std.fmt.parseFloat(f64, lex[0 .. lex.len - 1]) catch null; + return const_eval.floatLiteralValue(gpa, lex[0 .. lex.len - 1]); } /// Parse an int / float literal expr's numeric value at validation time. `null` /// if not a numeric literal. Backs E1749 FPSInvalid + E1750 DurationInvalid. -fn numericLitValue(arena: *const AstArena, expr_id: NodeId) ?f64 { - const k = arena.exprKind(expr_id); - if (k != .int_lit and k != .float_lit) return null; - return std.fmt.parseFloat(f64, arena.strings.slice(arena.exprData(expr_id))) catch null; +fn numericLitValue(gpa: std.mem.Allocator, arena: *const AstArena, expr_id: NodeId) !?f64 { + const text = arena.strings.slice(arena.exprData(expr_id)); + return switch (arena.exprKind(expr_id)) { + .int_lit => @floatFromInt(const_eval.intLiteralValue(text, false) orelse return null), + .float_lit => const_eval.floatLiteralValue(gpa, text), + else => null, + }; } /// Target categories the annotation-applicability check distinguishes. The first set held @@ -508,6 +512,9 @@ pub const TypeChecker = struct { /// to type a `return expr` body statement against. `null` outside /// a body; `.unit` for a void fn (no `-> type`). current_fn_return: ?ResolvedType = null, + /// Literals already reported out of range, so a literal checked in two + /// contexts is reported once. + range_reported: std.AutoHashMapUnmanaged(u32, void) = .empty, /// The `await_expr` node that is the statement-head `await` of the statement /// currently being checked, or `NodeId.none`. Set at the top of /// `checkStmt` for the allowed positions (expr-stmt / `let` init / simple @@ -735,6 +742,7 @@ pub const TypeChecker = struct { self.imported_aliases.deinit(self.gpa); self.services.deinit(self.gpa); self.foreign_events.deinit(self.gpa); + self.range_reported.deinit(self.gpa); if (self.tag_table) |*t| t.deinit(self.gpa); } @@ -778,6 +786,7 @@ pub const TypeChecker = struct { // no business being parsed. Cheap either way — one walk of the item // column, and an immediate return in `.standard` mode. try tc.checkDeclarationFileConstructs(); + try tc.checkLiteralRanges(); try tc.collectServices(); try tc.collectDeclaredEvents(); try tc.pass1Collect(); @@ -1548,6 +1557,7 @@ pub const TypeChecker = struct { /// structural checks; the Ember-semantic ones (E1602/E1603/E1605/E1606/ /// W1600/W1601) are DEFERRED-no-variant (catalogue not attached). fn validateEffect(self: *TypeChecker, decl: ast_mod.EffectDecl) !void { + try self.checkParamDefaults(decl.params_start, decl.params_len); // E1601 — emitter names unique within the effect; the set also backs E1604. var emitters: std.AutoHashMapUnmanaged(StringId, void) = .empty; defer emitters.deinit(self.gpa); @@ -1680,12 +1690,12 @@ pub const TypeChecker = struct { const prop = self.arena.struct_lit_fields.items[decl.props_start + p]; const pname = self.arena.strings.slice(prop.name); if (std.mem.eql(u8, pname, "duration")) { - const v = numericLitValue(self.arena, prop.value); + const v = try numericLitValue(self.gpa, self.arena, prop.value); if (v == null or v.? <= 0) { try self.emit(.sequence_duration_invalid, .error_, decl.name_span, "sequence duration must be a positive number", .{}); } else duration_secs = v; } else if (std.mem.eql(u8, pname, "fps")) { - const v = numericLitValue(self.arena, prop.value); + const v = try numericLitValue(self.gpa, self.arena, prop.value); if (v == null or v.? <= 0) { try self.emit(.fps_invalid, .error_, decl.name_span, "sequence fps must be a positive number", .{}); } @@ -1720,7 +1730,7 @@ pub const TypeChecker = struct { var k: u32 = 0; while (k < track.keyframes_len) : (k += 1) { const kf = self.arena.sequence_keyframes.items[track.keyframes_start + k]; - if (durationLitSeconds(self.arena, kf.time)) |secs| { + if (try durationLitSeconds(self.gpa, self.arena, kf.time)) |secs| { if (duration_secs) |d| { if (secs > d) try self.emit(.keyframe_out_of_range, .error_, kf.span, "keyframe time exceeds the sequence duration", .{}); } @@ -1776,6 +1786,7 @@ pub const TypeChecker = struct { /// DEFERRED-no-variant: E1684/E1685/E1686/E1687 (clip/db/clip/bone assets), /// E1693 LayerMaskInvalid (Kinesis bone mask). fn validateAnimGraph(self: *TypeChecker, decl: ast_mod.AnimGraphDecl) !void { + try self.checkParamDefaults(decl.params_start, decl.params_len); // E1695 + a params scope for the transition when-clause typing (E1690). var ctx: RuleCtx = .{ .unrestricted_ecs_access = true }; defer ctx.deinit(self.gpa); @@ -1875,6 +1886,7 @@ pub const TypeChecker = struct { /// emission is deferred. SPIR-V/MSL/DXIL emission is out of scope; eval of a /// shader body is fail-loud both backends (the descriptor is the Level-B output). fn validateShader(self: *TypeChecker, decl: ast_mod.ShaderDecl) !void { + try self.checkParamDefaults(decl.params_start, decl.params_len); if (decl.has_vertex) try self.checkShaderBody(decl.vertex.body_start, decl.vertex.body_len); try self.checkShaderBody(decl.fragment.body_start, decl.fragment.body_len); } @@ -1973,7 +1985,13 @@ pub const TypeChecker = struct { try self.emit(code_unknown, .error_, self.arena.exprSpan(field.value), "'{s}' has no field '{s}'", .{ owner, self.arena.strings.slice(field.name) }); return; }; - const tcode = code_type orelse return; // field-name-only check (resources) + const tcode = code_type orelse { + // A resource instance value is checked by field name, and for range. + if (d != .builtin) return; + const actual = try self.synthExprE(field.value, null); + if (actual == .builtin) _ = try self.literalTypeFits(d.builtin, field.value, actual.builtin); + return; + }; const actual = blk: { if (d == .enum_t and self.arena.exprKind(field.value) == .tag_path) { break :blk try self.checkEnumShorthand(field.value, d.enum_t); @@ -1987,7 +2005,7 @@ pub const TypeChecker = struct { break :blk try self.synthExprE(field.value, null); }; const mismatch = switch (d) { - .builtin => |db| actual == .builtin and !self.literalTypeFits(db, field.value, actual.builtin), + .builtin => |db| actual == .builtin and !try self.literalTypeFits(db, field.value, actual.builtin), .struct_t => |dn| actual == .struct_t and actual.struct_t != dn, .enum_t => |dn| switch (actual) { .enum_t => |an| an != dn, @@ -2071,7 +2089,7 @@ pub const TypeChecker = struct { // 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)) { + if (actual == .builtin and !try 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}); } } @@ -2469,7 +2487,7 @@ pub const TypeChecker = struct { break :blk try self.synthExprE(field.value, null); }; const mismatch = switch (d) { - .builtin => |db| actual == .builtin and !self.literalTypeFits(db, field.value, actual.builtin), + .builtin => |db| actual == .builtin and !try self.literalTypeFits(db, field.value, actual.builtin), .struct_t => |dn| actual == .struct_t and actual.struct_t != dn, .enum_t => |dn| switch (actual) { .enum_t => |an| an != dn, @@ -3584,6 +3602,7 @@ pub const TypeChecker = struct { const decl = self.arena.audio_graph_decls.items[data]; try self.registerSymbol(.audio_graph_, decl.name, item_id, span); try self.validateAnnotations(decl.annotations_extra, decl.annotations_len, .audio_graph); + try self.checkParamDefaults(decl.params_start, decl.params_len); }, else => {}, // forward-compatible: unknown items ignored } @@ -4082,29 +4101,22 @@ pub const TypeChecker = struct { /// `namedTypeToResolved` + `synthExpr` + `literalTypeFits`); the only /// difference is the const-specific diagnostic wording. fn checkConstValue(self: *TypeChecker, value: NodeId, type_node: NodeId) !void { - if (!isConstEvaluable(self.arena, value)) { - try self.emit(.not_const_evaluable, .error_, self.arena.exprSpan(value), "const value must be a constant expression (literal, arithmetic on literals, or parenthesized)", .{}); - return; - } + if (!try self.foldsAsConstant(value, "const value must be a constant expression (literal, arithmetic on literals, or parenthesized)")) return; const declared = self.namedTypeToResolved(type_node); const actual = self.synthExpr(value, null); if (declared == .builtin and actual == .builtin) { - if (!self.literalTypeFits(declared.builtin, value, actual.builtin)) { + if (!try self.literalTypeFits(declared.builtin, value, actual.builtin)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(value), "const value type does not match the declared type", .{}); } } } fn checkFieldDefault(self: *TypeChecker, value: NodeId, type_node: NodeId) !void { - // Const-evaluability check. - if (!isConstEvaluable(self.arena, value)) { - try self.emit(.not_const_evaluable, .error_, self.arena.exprSpan(value), "field default value must be a constant expression (literal, arithmetic on literals, or parenthesized)", .{}); - return; - } + if (!try self.foldsAsConstant(value, "field default value must be a constant expression (literal, arithmetic on literals, or parenthesized)")) return; const declared = self.namedTypeToResolved(type_node); const actual = self.synthExpr(value, null); if (declared == .builtin and actual == .builtin) { - if (!self.literalTypeFits(declared.builtin, value, actual.builtin)) { + if (!try self.literalTypeFits(declared.builtin, value, actual.builtin)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(value), "default value type does not match declared field type", .{}); } } @@ -4112,27 +4124,177 @@ pub const TypeChecker = struct { // diagnostic during field-type resolution — skip cascade. } - /// Polymorphic int / float literal rule (cf. `etch-reference-part1.md` - /// §4.3). When the declared context type is given and the value is a - /// literal of the same numeric family (int family → any integer - /// builtin, float family → any float builtin), the literal fits. All - /// other forms require exact equality (no implicit numeric coercion). - fn literalTypeFits(self: *TypeChecker, declared: BuiltinType, actual_expr: NodeId, actual: BuiltinType) bool { + /// A collection literal against the element types a `let` annotation + /// declares. The literal's own type follows its first element, so a numeric + /// literal element is judged against the declared type directly, and any + /// other element carries the literal's type. + fn checkCollectionLitAgainst(self: *TypeChecker, value: NodeId, declared: ResolvedType, inferred: ResolvedType) !void { + switch (self.arena.exprKind(value)) { + .array_lit => { + const elem: BuiltinType = switch (declared) { + .array_dyn => |e| e, + .array_fixed => |info| info.elem, + else => return, + }; + const lit_elem: ?BuiltinType = switch (inferred) { + .array_dyn => |e| e, + .array_fixed => |info| info.elem, + else => null, + }; + const al = self.arena.array_lits.items[self.arena.exprData(value)]; + var i: u32 = 0; + while (i < al.elements_len) : (i += 1) { + try self.checkElementAgainst(elem, @bitCast(self.arena.extra.items[al.elements_start + i]), lit_elem); + } + }, + .map_lit => { + if (declared != .map_t) return; + const lit: ?MapInfo = if (inferred == .map_t) inferred.map_t else null; + const ml = self.arena.map_lits.items[self.arena.exprData(value)]; + var i: u32 = 0; + while (i < ml.entries_len) : (i += 1) { + const entry = self.arena.map_entries.items[ml.entries_start + i]; + try self.checkElementAgainst(declared.map_t.key, entry.key, if (lit) |m| m.key else null); + try self.checkElementAgainst(declared.map_t.value, entry.value, if (lit) |m| m.value else null); + } + }, + else => {}, + } + } + + fn checkElementAgainst(self: *TypeChecker, declared: BuiltinType, e: NodeId, lit_type: ?BuiltinType) !void { + var lit = e; + if (self.arena.exprKind(lit) == .unary and self.arena.unary_exprs.items[self.arena.exprData(lit)].op == .neg) { + lit = self.arena.unary_exprs.items[self.arena.exprData(lit)].operand; + } + const fits = switch (self.arena.exprKind(lit)) { + .int_lit => try self.literalTypeFits(declared, e, .int_), + .float_lit => try self.literalTypeFits(declared, e, .float_), + else => (lit_type orelse return) == declared, + }; + if (!fits) try self.emit(.type_mismatch, .error_, self.arena.exprSpan(e), "collection element type does not match the declared element type", .{}); + } + + /// A params-block default renders into its descriptor as written, so a + /// literal default is range-checked against its declared type. + fn checkParamDefaults(self: *TypeChecker, start: u32, len: u32) !void { + var i: u32 = 0; + while (i < len) : (i += 1) { + const f = self.arena.fields.items[start + i]; + if (f.default_value.isNone()) continue; + const declared = self.namedTypeToResolved(f.type_node); + if (declared != .builtin) continue; + const actual = self.synthExpr(f.default_value, null); + if (actual == .builtin) _ = try self.literalTypeFits(declared.builtin, f.default_value, actual.builtin); + } + } + + /// Whether `value` folds as a constant, emitting E1101 when it does not or + /// when its folding overflows, divides by zero or leaves the finite floats + /// (`etch-resolver-types.md` §11). A string literal or `.variant` is a + /// constant with nothing to fold. A kind mismatch or an out-of-range literal + /// folds `true`: the type check and the literal pass report those. + fn foldsAsConstant(self: *TypeChecker, value: NodeId, not_constant: []const u8) !bool { + switch (self.arena.exprKind(value)) { + .string_lit, .tag_path => return true, + else => {}, + } + _ = const_eval.fold(self.gpa, self.arena, value) catch |err| { + const span = self.arena.exprSpan(value); + switch (err) { + error.NotConstant => try self.emit(.not_const_evaluable, .error_, span, "{s}", .{not_constant}), + error.IntegerOverflow => try self.emit(.not_const_evaluable, .error_, span, "the constant overflows int", .{}), + error.DivisionByZero => try self.emit(.not_const_evaluable, .error_, span, "the constant divides by zero", .{}), + error.FloatOverflow => try self.emit(.not_const_evaluable, .error_, span, "the constant is not a finite float", .{}), + error.KindMismatch, error.LiteralOutOfRange => return true, + error.OutOfMemory => return error.OutOfMemory, + } + return false; + }; + return true; + } + + /// Every integer literal fits `int` and every float or duration literal is + /// finite, whatever context it sits in (`etch-resolver-types.md` §4.3). The + /// lexer never includes the sign, so a literal under a unary minus is judged + /// negated: `-9223372036854775808` fits. + fn checkLiteralRanges(self: *TypeChecker) !void { + const kinds = self.arena.exprs.items(.kind); + const datas = self.arena.exprs.items(.data); + var negated: std.AutoHashMapUnmanaged(u32, void) = .empty; + defer negated.deinit(self.gpa); + for (kinds, datas) |k, d| { + if (k != .unary) continue; + const u = self.arena.unary_exprs.items[d]; + if (u.op == .neg and self.arena.exprKind(u.operand) == .int_lit) try negated.put(self.gpa, u.operand.index, {}); + } + for (kinds, datas, 0..) |k, d, i| { + const id: NodeId = .{ .category = .expr, .index = @intCast(i) }; + const text = self.arena.strings.slice(d); + const fits = switch (k) { + .int_lit => const_eval.intLiteralValue(text, negated.contains(id.index)) != null, + .float_lit => try const_eval.floatLiteralValue(self.gpa, text) != null, + .duration_lit => text.len < 2 or try const_eval.floatLiteralValue(self.gpa, text[0 .. text.len - 1]) != null, + else => continue, + }; + if (fits) continue; + try self.range_reported.put(self.gpa, id.raw(), {}); + const family = if (k == .int_lit) "int" else "float"; + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(id), "literal {s} does not fit in {s}", .{ text, family }); + } + const tkinds = self.arena.type_nodes.items(.kind); + const tdatas = self.arena.type_nodes.items(.data); + for (tkinds, tdatas) |k, d| { + if (k != .array) continue; + const size = self.arena.array_types.items[d].size; + if (self.constArrayLen(size) != null) continue; + try self.emit(.not_const_evaluable, .error_, self.arena.exprSpan(size), "array size must be a non-negative integer literal", .{}); + } + } + + /// Polymorphic int / float literal rule (`etch-resolver-types.md` §4.3). + /// When the declared context type is given and the value is a literal of + /// the same numeric family (int family → any integer builtin, float family + /// → any float builtin), the literal fits its family, and a literal outside + /// the declared type's range is refused here. All other forms require exact + /// equality (no implicit numeric coercion). + fn literalTypeFits(self: *TypeChecker, declared: BuiltinType, actual_expr: NodeId, actual: BuiltinType) !bool { if (declared == actual) return true; - const kind = self.arena.exprKind(actual_expr); - if (kind == .int_lit and actual == .int_ and declared.isInteger()) return true; - if (kind == .float_lit and actual == .float_ and declared.isFloat()) return true; - // Negative literals via unary minus on a literal also fit when the - // operand is a matching numeric literal. - if (kind == .unary) { + var lit = actual_expr; + var negated = false; + if (self.arena.exprKind(actual_expr) == .unary) { const un = self.arena.unary_exprs.items[self.arena.exprData(actual_expr)]; - if (un.op == .neg) { - const inner_kind = self.arena.exprKind(un.operand); - if (inner_kind == .int_lit and actual == .int_ and declared.isInteger()) return true; - if (inner_kind == .float_lit and actual == .float_ and declared.isFloat()) return true; - } + if (un.op != .neg) return false; + lit = un.operand; + negated = true; + } + const kind = self.arena.exprKind(lit); + const int_family = kind == .int_lit and actual == .int_ and declared.isInteger(); + const float_family = kind == .float_lit and actual == .float_ and declared.isFloat(); + if (!int_family and !float_family) return false; + if (self.range_reported.contains(lit.raw())) return true; + const text = self.arena.strings.slice(self.arena.exprData(lit)); + const fits = if (int_family) blk: { + const v = const_eval.intLiteralValue(text, negated) orelse return true; + break :blk switch (declared) { + .i32_ => std.math.cast(i32, v) != null, + .u32_ => std.math.cast(u32, v) != null, + else => true, + }; + } else blk: { + const v = (try const_eval.floatLiteralValue(self.gpa, text)) orelse return true; + break :blk declared != .f32_ or std.math.isFinite(@as(f32, @floatCast(v))); + }; + if (!fits) { + try self.range_reported.put(self.gpa, lit.raw(), {}); + const type_name: []const u8 = switch (declared) { + .i32_ => "i32", + .u32_ => "u32", + else => "f32", + }; + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(actual_expr), "literal {s}{s} does not fit in {s}", .{ if (negated) "-" else "", text, type_name }); } - return false; + return true; } /// Resolve a type node to a `ResolvedType`. Despite the historical name it @@ -4295,8 +4457,7 @@ pub const TypeChecker = struct { fn constArrayLen(self: *TypeChecker, size_node: NodeId) ?u64 { if (size_node.isNone()) return null; if (self.arena.exprKind(size_node) != .int_lit) return null; - const text = self.arena.strings.slice(self.arena.exprData(size_node)); - return std.fmt.parseInt(u64, text, 10) catch null; + return const_eval.intLiteralMagnitude(self.arena.strings.slice(self.arena.exprData(size_node))); } // ─── Pass 2 ────────────────────────────────────────────────────────── @@ -4418,7 +4579,7 @@ pub const TypeChecker = struct { if (!decl.value.isNone()) { const vt = self.synthExpr(decl.value, &ctx); - if (!decl.return_type.isNone() and ret_t == .builtin and vt == .builtin and !self.literalTypeFits(ret_t.builtin, decl.value, vt.builtin)) { + if (!decl.return_type.isNone() and ret_t == .builtin and vt == .builtin and !try self.literalTypeFits(ret_t.builtin, decl.value, vt.builtin)) { try self.emit(.return_type_mismatch, .error_, self.arena.exprSpan(decl.value), "method '{s}' body value type does not match its declared return type", .{self.arena.strings.slice(decl.name)}); } } @@ -4986,7 +5147,7 @@ pub const TypeChecker = struct { // declared return type (E0200, consistent with the closure-call path). if (!decl.value.isNone()) { const vt = self.synthExpr(decl.value, &ctx); - if (!decl.return_type.isNone() and ret_t == .builtin and vt == .builtin and !self.literalTypeFits(ret_t.builtin, decl.value, vt.builtin)) { + if (!decl.return_type.isNone() and ret_t == .builtin and vt == .builtin and !try self.literalTypeFits(ret_t.builtin, decl.value, vt.builtin)) { try self.emit(.return_type_mismatch, .error_, self.arena.exprSpan(decl.value), "function '{s}' body value type does not match its declared return type", .{self.arena.strings.slice(decl.name)}); } } @@ -5161,9 +5322,10 @@ pub const TypeChecker = struct { try self.emit(.invalid_field_filter, .error_, node.span, "component '{s}' has no field '{s}'", .{ tname, fname }); return; } + if (!try self.foldsAsConstant(node.filter_value, "field filter value must be a constant expression")) return; const declared = self.namedTypeToResolved(found.?.type_node); const actual = self.synthExpr(node.filter_value, null); - if (declared == .builtin and actual == .builtin and !declared.eql(actual)) { + if (declared == .builtin and actual == .builtin and !try self.literalTypeFits(declared.builtin, node.filter_value, actual.builtin)) { try self.emit(.invalid_field_filter, .error_, node.span, "field filter type does not match field declared type", .{}); } } @@ -5190,7 +5352,7 @@ pub const TypeChecker = struct { } const actual = self.synthExpr(flit.value, ctx_opt); if (declared) |d| { - if (d == .builtin and actual == .builtin and !self.literalTypeFits(d.builtin, flit.value, actual.builtin)) { + if (d == .builtin and actual == .builtin and !try self.literalTypeFits(d.builtin, flit.value, actual.builtin)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(flit.value), "event field '{s}' value type does not match its declared type", .{self.arena.strings.slice(flit.name)}); } } else { @@ -5279,9 +5441,10 @@ pub const TypeChecker = struct { break :blk self.synthHeadValue(let.value, ctx); }; const final = if (declared) |d| blk: { - if (d == .builtin and inferred == .builtin and !self.literalTypeFits(d.builtin, let.value, inferred.builtin)) { + if (d == .builtin and inferred == .builtin and !try self.literalTypeFits(d.builtin, let.value, inferred.builtin)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(let.value), "let initializer type does not match declared type", .{}); } + try self.checkCollectionLitAgainst(let.value, d, inferred); break :blk d; } else inferred; // A binding to `entity.get_mut(T)` aliases the mutable @@ -5315,7 +5478,7 @@ pub const TypeChecker = struct { try self.emit(.type_mismatch, .error_, span, "cannot assign to immutable binding (use 'let mut')", .{}); } const rhs_type = self.synthHeadValue(assign.value, ctx); - if (local.type_ == .builtin and rhs_type == .builtin and !self.literalTypeFits(local.type_.builtin, assign.value, rhs_type.builtin)) { + if (local.type_ == .builtin and rhs_type == .builtin and !try self.literalTypeFits(local.type_.builtin, assign.value, rhs_type.builtin)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(assign.value), "assignment value type does not match binding type", .{}); } // Compound assignment on strings (`s += t`) is not in @@ -5342,7 +5505,7 @@ pub const TypeChecker = struct { // Synthesize the field type and check the value matches it. const lhs_type = self.synthExpr(assign.target, ctx); const rhs_type = self.synthExpr(assign.value, ctx); - if (lhs_type == .builtin and rhs_type == .builtin and !self.literalTypeFits(lhs_type.builtin, assign.value, rhs_type.builtin)) { + if (lhs_type == .builtin and rhs_type == .builtin and !try self.literalTypeFits(lhs_type.builtin, assign.value, rhs_type.builtin)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(assign.value), "assignment value type does not match field type", .{}); } } else { @@ -5531,7 +5694,7 @@ pub const TypeChecker = struct { } } if (self.current_fn_return) |ret| { - if (ret == .builtin and vt == .builtin and !self.literalTypeFits(ret.builtin, value, vt.builtin)) { + if (ret == .builtin and vt == .builtin and !try self.literalTypeFits(ret.builtin, value, vt.builtin)) { try self.emit(.return_type_mismatch, .error_, self.arena.exprSpan(value), "return value type does not match the declared return type", .{}); } } @@ -6391,7 +6554,10 @@ pub const TypeChecker = struct { if (elem_t != .unknown) try self.emit(.type_mismatch, .error_, self.arena.exprSpan(id), "array elements must be a builtin primitive in E1", .{}); return ResolvedType.unknown; } - const len = self.constArrayLen(al.fill_count) orelse 0; + const len = self.constArrayLen(al.fill_count) orelse { + try self.emit(.not_const_evaluable, .error_, self.arena.exprSpan(al.fill_count), "array fill count must be a non-negative integer literal", .{}); + return ResolvedType.unknown; + }; return .{ .array_fixed = .{ .elem = elem_t.builtin, .len = len } }; } if (al.elements_len == 0) return ResolvedType.unknown; // empty: type from annotation @@ -6405,7 +6571,7 @@ pub const TypeChecker = struct { return ResolvedType.unknown; } if (elem_bt) |bt| { - if (!self.literalTypeFits(bt, e, et.builtin)) { + if (!try self.literalTypeFits(bt, e, et.builtin)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(e), "array elements must all have the same type", .{}); } } else elem_bt = et.builtin; @@ -6448,13 +6614,13 @@ pub const TypeChecker = struct { continue; } if (key_bt) |kb| { - if (!self.literalTypeFits(kb, entry.key, kt.builtin)) try self.emit(.type_mismatch, .error_, self.arena.exprSpan(entry.key), "map keys must all have the same type", .{}); + if (!try self.literalTypeFits(kb, entry.key, kt.builtin)) try self.emit(.type_mismatch, .error_, self.arena.exprSpan(entry.key), "map keys must all have the same type", .{}); } else { key_bt = kt.builtin; try self.checkHashBound(kt.builtin, "map key type", "K: Hash", self.arena.exprSpan(entry.key)); } if (val_bt) |vb| { - if (!self.literalTypeFits(vb, entry.value, vt.builtin)) try self.emit(.type_mismatch, .error_, self.arena.exprSpan(entry.value), "map values must all have the same type", .{}); + if (!try self.literalTypeFits(vb, entry.value, vt.builtin)) try self.emit(.type_mismatch, .error_, self.arena.exprSpan(entry.value), "map values must all have the same type", .{}); } else val_bt = vt.builtin; } if (!all_builtin or key_bt == null or val_bt == null) return ResolvedType.unknown; @@ -6779,7 +6945,7 @@ pub const TypeChecker = struct { var ptype = arg_t; if (!p.type_node.isNone()) { ptype = self.namedTypeToResolved(p.type_node); - if (ptype == .builtin and arg_t == .builtin and !self.literalTypeFits(ptype.builtin, arg, arg_t.builtin)) { + if (ptype == .builtin and arg_t == .builtin and !try self.literalTypeFits(ptype.builtin, arg, arg_t.builtin)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(arg), "closure argument type does not match the parameter type", .{}); } } @@ -6896,7 +7062,7 @@ pub const TypeChecker = struct { const ptype = self.namedTypeToResolved(p.type_node); const arg = self.arena.callArgForParam(call.args_start, call.args_len, call.names_start, i, p.name) orelse continue; const arg_t = try self.synthExprE(arg, ctx_opt); - if (ptype == .builtin and arg_t == .builtin and !self.literalTypeFits(ptype.builtin, arg, arg_t.builtin)) { + if (ptype == .builtin and arg_t == .builtin and !try self.literalTypeFits(ptype.builtin, arg, arg_t.builtin)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(arg), "argument type does not match the parameter type of function '{s}'", .{self.arena.strings.slice(decl.name)}); } } @@ -7113,7 +7279,7 @@ pub const TypeChecker = struct { break :blk try self.synthExprE(flit.value, ctx_opt); }; if (declared) |d| { - if (d == .builtin and actual == .builtin and !self.literalTypeFits(d.builtin, flit.value, actual.builtin)) { + if (d == .builtin and actual == .builtin and !try self.literalTypeFits(d.builtin, flit.value, actual.builtin)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(flit.value), "struct-literal field '{s}' value type does not match its declared type", .{self.arena.strings.slice(flit.name)}); } if (d == .struct_t and actual == .struct_t and d.struct_t != actual.struct_t) { @@ -7507,7 +7673,7 @@ pub const TypeChecker = struct { } else { const arg: NodeId = @bitCast(self.arena.extra.items[mc.args_start]); const arg_t = try self.synthExprE(arg, ctx_opt); - if (arg_t == .builtin and !self.literalTypeFits(recv_t.array_dyn, arg, arg_t.builtin)) { + if (arg_t == .builtin and !try self.literalTypeFits(recv_t.array_dyn, arg, arg_t.builtin)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(arg), "pushed value type does not match the array element type", .{}); } } @@ -7547,10 +7713,10 @@ pub const TypeChecker = struct { const varg: NodeId = @bitCast(self.arena.extra.items[mc.args_start + 1]); const k_t = try self.synthExprE(karg, ctx_opt); const v_t = try self.synthExprE(varg, ctx_opt); - if (k_t == .builtin and !self.literalTypeFits(recv_t.map_t.key, karg, k_t.builtin)) { + if (k_t == .builtin and !try self.literalTypeFits(recv_t.map_t.key, karg, k_t.builtin)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(karg), "inserted key type does not match the map key type", .{}); } - if (v_t == .builtin and !self.literalTypeFits(recv_t.map_t.value, varg, v_t.builtin)) { + if (v_t == .builtin and !try self.literalTypeFits(recv_t.map_t.value, varg, v_t.builtin)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(varg), "inserted value type does not match the map value type", .{}); } } @@ -7580,7 +7746,7 @@ pub const TypeChecker = struct { } else { const arg: NodeId = @bitCast(self.arena.extra.items[mc.args_start]); const arg_t = try self.synthExprE(arg, ctx_opt); - if (arg_t == .builtin and !self.literalTypeFits(recv_t.set_t, arg, arg_t.builtin)) { + if (arg_t == .builtin and !try self.literalTypeFits(recv_t.set_t, arg, arg_t.builtin)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(arg), "inserted item type does not match the set element type", .{}); } } @@ -7593,7 +7759,7 @@ pub const TypeChecker = struct { } else { const arg: NodeId = @bitCast(self.arena.extra.items[mc.args_start]); const arg_t = try self.synthExprE(arg, ctx_opt); - if (arg_t == .builtin and !self.literalTypeFits(recv_t.set_t, arg, arg_t.builtin)) { + if (arg_t == .builtin and !try self.literalTypeFits(recv_t.set_t, arg, arg_t.builtin)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(arg), "item type does not match the set element type", .{}); } } @@ -7886,7 +8052,7 @@ pub const TypeChecker = struct { const ptype = self.namedTypeToResolved(p.type_node); const arg = self.arena.callArgForParam(mc.args_start, mc.args_len, mc.names_start, i, p.name) orelse continue; const arg_t = try self.synthExprE(arg, ctx_opt); - if (ptype == .builtin and arg_t == .builtin and !self.literalTypeFits(ptype.builtin, arg, arg_t.builtin)) { + if (ptype == .builtin and arg_t == .builtin and !try self.literalTypeFits(ptype.builtin, arg, arg_t.builtin)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(arg), "argument type does not match the parameter type of method '{s}'", .{self.arena.strings.slice(mc.method_name)}); } } @@ -7922,7 +8088,7 @@ pub const TypeChecker = struct { // the optional-returning accessor unlocked by the Optional // ops — lifts the earlier rejection. The key must fit the // map's key type. - if (idx_t == .builtin and !self.literalTypeFits(mi.key, ix.index, idx_t.builtin)) { + if (idx_t == .builtin and !try self.literalTypeFits(mi.key, ix.index, idx_t.builtin)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(ix.index), "map index key type does not match the map key type", .{}); } return .{ .optional = mi.value }; @@ -7980,7 +8146,7 @@ pub const TypeChecker = struct { .literal => { const lit: NodeId = @bitCast(arm.pattern_payload); const lit_t = try self.synthExprE(lit, ctx_opt); - if (scrut_t == .builtin and lit_t == .builtin and !self.literalTypeFits(scrut_t.builtin, lit, lit_t.builtin)) { + if (scrut_t == .builtin and lit_t == .builtin and !try self.literalTypeFits(scrut_t.builtin, lit, lit_t.builtin)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(lit), "match pattern literal type does not match the scrutinee type", .{}); } if (scrut_t == .builtin and scrut_t.builtin == .bool_ and self.arena.exprKind(lit) == .bool_lit) { @@ -8115,7 +8281,7 @@ pub const TypeChecker = struct { // the default must fit the payload type; the result is the // unwrapped payload. if (lhs_t == .optional) { - if (rhs_t == .builtin and !self.literalTypeFits(lhs_t.optional, bin.rhs, rhs_t.builtin)) { + if (rhs_t == .builtin and !try self.literalTypeFits(lhs_t.optional, bin.rhs, rhs_t.builtin)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(bin.rhs), "'??' default type does not match the optional payload type", .{}); } return .{ .builtin = lhs_t.optional }; diff --git a/src/etch/value.zig b/src/etch/value.zig index fc936e4b..af224a14 100644 --- a/src/etch/value.zig +++ b/src/etch/value.zig @@ -10,6 +10,7 @@ //! Compile-time diagnostics live in `etch/diagnostics.zig`. const std = @import("std"); +const builtin = @import("builtin"); const token = @import("token.zig"); const SourceSpan = token.SourceSpan; @@ -278,40 +279,76 @@ pub const RuntimeErrorKind = enum { StaleComponentRef, }; -/// Integer division with division-by-zero check. Returns `null` on divide -/// by zero — the caller turns the error into a `RuntimeError`. +/// Whether integer overflow wraps rather than panics: `ReleaseFast` and +/// `ReleaseSmall`, the modes without runtime safety, wrap; `Debug` and +/// `ReleaseSafe` panic (`etch-reference-part1.md` §12.4). An integer division by +/// zero panics in every mode. +pub const overflow_wraps = switch (builtin.mode) { + .Debug, .ReleaseSafe => false, + .ReleaseFast, .ReleaseSmall => true, +}; + +/// Integer division. `null` on a zero divisor, and on `i64.min / -1` where +/// overflow panics; that quotient wraps to `i64.min` otherwise. pub fn intDiv(lhs: i64, rhs: i64) ?i64 { if (rhs == 0) return null; - // `i64.min / -1` overflows; let the caller surface as IntegerOverflow. - if (lhs == std.math.minInt(i64) and rhs == -1) return null; + if (lhs == std.math.minInt(i64) and rhs == -1) return if (overflow_wraps) lhs else null; return @divTrunc(lhs, rhs); } -/// Integer remainder with division-by-zero and `i64.min % -1` -/// guards. Returns `null` on divide-by-zero; the caller turns the -/// `null` into a `RuntimeError`. +/// Integer remainder. `null` on a zero divisor; `i64.min % -1` is 0. pub fn intRem(lhs: i64, rhs: i64) ?i64 { if (rhs == 0) return null; - if (lhs == std.math.minInt(i64) and rhs == -1) return 0; + if (rhs == -1) return 0; return @rem(lhs, rhs); } -/// Wrapping-checked integer addition — returns `null` on overflow so -/// the interpreter can surface `IntegerOverflow` cleanly. -pub fn intAddChecked(lhs: i64, rhs: i64) ?i64 { +/// Integer addition; `null` on overflow where overflow panics. +pub fn intAdd(lhs: i64, rhs: i64) ?i64 { + if (overflow_wraps) return lhs +% rhs; return std.math.add(i64, lhs, rhs) catch null; } -/// Wrapping-checked integer subtraction — returns `null` on overflow. -pub fn intSubChecked(lhs: i64, rhs: i64) ?i64 { +/// Integer subtraction; `null` on overflow where overflow panics. +pub fn intSub(lhs: i64, rhs: i64) ?i64 { + if (overflow_wraps) return lhs -% rhs; return std.math.sub(i64, lhs, rhs) catch null; } -/// Wrapping-checked integer multiplication — returns `null` on overflow. -pub fn intMulChecked(lhs: i64, rhs: i64) ?i64 { +/// Integer multiplication; `null` on overflow where overflow panics. +pub fn intMul(lhs: i64, rhs: i64) ?i64 { + if (overflow_wraps) return lhs *% rhs; return std.math.mul(i64, lhs, rhs) catch null; } +/// Integer negation; `null` on `-i64.min` where overflow panics. +pub fn intNeg(x: i64) ?i64 { + if (overflow_wraps) return 0 -% x; + return std.math.negate(x) catch null; +} + +/// `x` truncated toward zero, when that integer fits `T`; `null` otherwise, NaN +/// and the infinities included. A float has no wrap to fall back on, so this +/// refuses in every mode. +pub fn floatTrunc(comptime T: type, x: f64) ?i64 { + if (!std.math.isFinite(x)) return null; + const t = @trunc(x); + const lo: f64 = @floatFromInt(std.math.minInt(T)); + const hi: f64 = @floatFromInt(@as(i128, std.math.maxInt(T)) + 1); + if (t < lo or t >= hi) return null; + return @intFromFloat(t); +} + +/// `x` narrowed to the integer type `T`, held as an `i64`: `null` when it does +/// not fit and overflow panics, the two's-complement truncation otherwise +/// (`etch-grammar.md` §2.6). +pub fn intNarrow(comptime T: type, x: i64) ?i64 { + if (std.math.cast(T, x)) |n| return n; + if (!overflow_wraps) return null; + const bits: std.meta.Int(.unsigned, @bitSizeOf(T)) = @truncate(@as(u64, @bitCast(x))); + return @as(T, @bitCast(bits)); +} + test "Value arithmetic int + int yields int" { const a = Value.fromInt(2); const b = Value.fromInt(3); @@ -327,10 +364,6 @@ test "Value arithmetic int + float forbidden (no implicit coercion)" { try std.testing.expect(!a.eql(b)); } -test "DivisionByZero on int" { - try std.testing.expectEqual(@as(?i64, null), intDiv(10, 0)); -} - test "DivisionByZero on float yields NaN/Inf per IEEE 754" { const inf = @as(f64, 1.0) / @as(f64, 0.0); try std.testing.expect(std.math.isInf(inf)); @@ -338,10 +371,47 @@ test "DivisionByZero on float yields NaN/Inf per IEEE 754" { try std.testing.expect(std.math.isNan(nan)); } -test "IntegerOverflow detected in ReleaseSafe" { - try std.testing.expectEqual(@as(?i64, null), intAddChecked(std.math.maxInt(i64), 1)); - try std.testing.expectEqual(@as(?i64, null), intMulChecked(std.math.maxInt(i64), 2)); - try std.testing.expectEqual(@as(?i64, null), intDiv(std.math.minInt(i64), -1)); +test "integer overflow panics where the mode has runtime safety and wraps where it does not" { + const max = std.math.maxInt(i64); + const min = std.math.minInt(i64); + if (overflow_wraps) { + try std.testing.expectEqual(@as(?i64, min), intAdd(max, 1)); + try std.testing.expectEqual(@as(?i64, max), intSub(min, 1)); + try std.testing.expectEqual(@as(?i64, -2), intMul(max, 2)); + try std.testing.expectEqual(@as(?i64, min), intNeg(min)); + try std.testing.expectEqual(@as(?i64, min), intDiv(min, -1)); + try std.testing.expectEqual(@as(?i64, -2147483648), intNarrow(i32, 2147483648)); + try std.testing.expectEqual(@as(?i64, 4294967295), intNarrow(u32, -1)); + } else { + try std.testing.expectEqual(@as(?i64, null), intAdd(max, 1)); + try std.testing.expectEqual(@as(?i64, null), intSub(min, 1)); + try std.testing.expectEqual(@as(?i64, null), intMul(max, 2)); + try std.testing.expectEqual(@as(?i64, null), intNeg(min)); + try std.testing.expectEqual(@as(?i64, null), intDiv(min, -1)); + try std.testing.expectEqual(@as(?i64, null), intNarrow(i32, 2147483648)); + try std.testing.expectEqual(@as(?i64, null), intNarrow(u32, -1)); + } +} + +test "integer division by zero is refused in every mode" { + try std.testing.expectEqual(@as(?i64, null), intDiv(1, 0)); + try std.testing.expectEqual(@as(?i64, null), intRem(1, 0)); + try std.testing.expectEqual(@as(?i64, 0), intRem(std.math.minInt(i64), -1)); +} + +test "a float converts to an integer only when its truncation fits" { + try std.testing.expectEqual(@as(?i64, -3), floatTrunc(i64, -3.9)); + try std.testing.expectEqual(@as(?i64, 2147483647), floatTrunc(i32, 2147483647.5)); + try std.testing.expectEqual(@as(?i64, null), floatTrunc(i32, 2147483648.0)); + try std.testing.expectEqual(@as(?i64, null), floatTrunc(u32, -1.0)); + try std.testing.expectEqual(@as(?i64, null), floatTrunc(i64, 9223372036854775808.0)); + try std.testing.expectEqual(@as(?i64, null), floatTrunc(i64, std.math.nan(f64))); + try std.testing.expectEqual(@as(?i64, null), floatTrunc(i64, std.math.inf(f64))); +} + +test "a value that fits is narrowed unchanged" { + try std.testing.expectEqual(@as(?i64, -5), intNarrow(i32, -5)); + try std.testing.expectEqual(@as(?i64, 4294967295), intNarrow(u32, 4294967295)); } test "comparison between incompatible Values is a compile-time impossibility (asserts)" { @@ -354,9 +424,9 @@ test "comparison between incompatible Values is a compile-time impossibility (as test "compound assignment +=, -=, *=, /=, %= behave per spec" { // The interpreter de-sugars these into "load + op + store" before this // module is involved; what is checked here are the underlying helpers. - try std.testing.expectEqual(@as(?i64, 7), intAddChecked(5, 2)); - try std.testing.expectEqual(@as(?i64, 3), intSubChecked(5, 2)); - try std.testing.expectEqual(@as(?i64, 10), intMulChecked(5, 2)); + try std.testing.expectEqual(@as(?i64, 7), intAdd(5, 2)); + try std.testing.expectEqual(@as(?i64, 3), intSub(5, 2)); + try std.testing.expectEqual(@as(?i64, 10), intMul(5, 2)); try std.testing.expectEqual(@as(?i64, 2), intDiv(5, 2)); try std.testing.expectEqual(@as(?i64, 1), intRem(5, 2)); } diff --git a/src/etch/zig_codegen/lower.zig b/src/etch/zig_codegen/lower.zig index fe7895a2..205254aa 100644 --- a/src/etch/zig_codegen/lower.zig +++ b/src/etch/zig_codegen/lower.zig @@ -33,6 +33,7 @@ const std = @import("std"); const ast_mod = @import("../ast.zig"); +const const_eval = @import("../const_eval.zig"); const types_mod = @import("../types.zig"); const tagset_name = types_mod.tagset_component_name; const tags_mod = @import("../tags.zig"); @@ -77,6 +78,7 @@ pub fn generateFile( try emitHeader(&w, source_path); try emitImports(&w); + try emitArithmeticPrelude(&w); var stats: GenerateStats = .{}; @@ -442,6 +444,73 @@ fn emitMapGetPrelude(w: *Writer) CodegenError!void { try w.blankLine(); } +/// Emit the integer arithmetic helpers: overflow wraps in `ReleaseFast` and +/// `ReleaseSmall` and panics in `Debug` and `ReleaseSafe`, and an integer +/// division by zero panics in every mode (`etch-reference-part1.md` §12.4). +/// Plain functions, so a literal operand is still checked when the program runs. +fn emitArithmeticPrelude(w: *Writer) CodegenError!void { + const lines = [_][]const u8{ + "const __etch_wraps = switch (@import(\"builtin\").mode) {", + " .Debug, .ReleaseSafe => false,", + " .ReleaseFast, .ReleaseSmall => true,", + "};", + "fn __etchAdd(comptime T: type, a: T, b: T) T {", + " return if (__etch_wraps) a +% b else a + b;", + "}", + "fn __etchSub(comptime T: type, a: T, b: T) T {", + " return if (__etch_wraps) a -% b else a - b;", + "}", + "fn __etchMul(comptime T: type, a: T, b: T) T {", + " return if (__etch_wraps) a *% b else a * b;", + "}", + "fn __etchNeg(comptime T: type, x: T) T {", + " return if (__etch_wraps) 0 -% x else -x;", + "}", + "fn __etchDiv(comptime T: type, a: T, b: T) T {", + " if (b == 0) @panic(\"division by zero\");", + " if (@typeInfo(T).int.signedness == .signed and a == std.math.minInt(T) and b == -1) {", + " if (__etch_wraps) return a;", + " @panic(\"integer overflow\");", + " }", + " return @divTrunc(a, b);", + "}", + "fn __etchRem(comptime T: type, a: T, b: T) T {", + " if (b == 0) @panic(\"division by zero\");", + " if (@typeInfo(T).int.signedness == .signed and b == -1) return 0;", + " return @rem(a, b);", + "}", + "fn __etchNarrow(comptime T: type, x: anytype) T {", + " if (std.math.cast(T, x)) |n| return n;", + " if (!__etch_wraps) @panic(\"integer overflow\");", + " const Src = std.meta.Int(.unsigned, @bitSizeOf(@TypeOf(x)));", + " const Dst = std.meta.Int(.unsigned, @bitSizeOf(T));", + " return @bitCast(@as(Dst, @truncate(@as(Src, @bitCast(x)))));", + "}", + "fn __etchIntFromFloat(comptime T: type, x: anytype) T {", + " const F = @TypeOf(x);", + " if (!std.math.isFinite(x)) @panic(\"integer overflow\");", + " const t = @trunc(x);", + " if (t < @as(F, @floatFromInt(std.math.minInt(T))) or t >= @as(F, @floatFromInt(@as(i128, std.math.maxInt(T)) + 1))) @panic(\"integer overflow\");", + " return @intFromFloat(t);", + "}", + }; + for (lines) |l| try w.line(l); + try w.blankLine(); +} + +/// The prelude helper that lowers integer operator `op`, or null for an operator +/// Zig's own spelling serves. +fn intArithHelper(op: ast_mod.BinaryOp) ?[]const u8 { + return switch (op) { + .add => "__etchAdd", + .sub => "__etchSub", + .mul => "__etchMul", + .div => "__etchDiv", + .rem => "__etchRem", + else => null, + }; +} + /// Emit the set-insert helper (stdlib §15.2): one /// duck-typed fn shared by `s.insert(item)` and the `Set.from` seeding — /// scan-skip-or-append, the exact mechanics of the interpreter's set store, @@ -3259,25 +3328,16 @@ fn emitAssign(w: *Writer, ast: *const AstArena, ctx: *LocalCtx, assign: ast_mod. // co-located with the write — the same logical point as the // interpreter's `writeResourceField` (a pure read never dirties), so // `when resource R changed` gating is byte-exact by construction. - if (assignTargetResource(ast, ctx, assign.target)) |rname| { - const fa = ast.field_accesses.items[ast.exprData(assign.target)]; + const resource = assignTargetResource(ast, ctx, assign.target); + if (resource != null) { try w.writeIndent(); - try w.print("@as(*{s}, @ptrCast(@alignCast(world.resources.getMutResource({s}_id).?.ptr))).", .{ rname, rname }); - try w.ident(ast.strings.slice(fa.field_name)); - try w.write(" "); - try w.write(assignOpText(assign.op)); - try w.write(" "); - try emitExpr(w, ast, ctx, assign.value); - try w.write(";\n"); + try emitAssignTarget(w, ast, ctx, assign.target, resource); + try emitAssignOperator(w, ast, ctx, assign, resource); return; } try w.writeIndent(); - try emitExpr(w, ast, ctx, assign.target); - try w.write(" "); - try w.write(assignOpText(assign.op)); - try w.write(" "); - try emitExpr(w, ast, ctx, assign.value); - try w.write(";\n"); + try emitAssignTarget(w, ast, ctx, assign.target, null); + try emitAssignOperator(w, ast, ctx, assign, null); // Change detection: right after a component-field write, stamp // the slot's `changed_tick` so an `entity has T changed` rule sees it. The // marking is co-located with the assignment (so it executes exactly when @@ -3340,6 +3400,46 @@ fn assignTargetComponent(ast: *const AstArena, ctx: *const LocalCtx, target: Nod } } +/// The place an assignment writes: a resource field through `getMutResource` +/// when `resource` names one, the target expression otherwise. +fn emitAssignTarget(w: *Writer, ast: *const AstArena, ctx: *LocalCtx, target: NodeId, resource: ?[]const u8) CodegenError!void { + const rname = resource orelse return emitExpr(w, ast, ctx, target); + const fa = ast.field_accesses.items[ast.exprData(target)]; + try w.print("@as(*{s}, @ptrCast(@alignCast(world.resources.getMutResource({s}_id).?.ptr))).", .{ rname, rname }); + try w.ident(ast.strings.slice(fa.field_name)); +} + +/// The operator and value of an assignment, ending the statement. An integer +/// compound assignment goes through the prelude helper of its operator, and a +/// float `%=` through `@rem`, as the binary operators do. +fn emitAssignOperator(w: *Writer, ast: *const AstArena, ctx: *LocalCtx, assign: ast_mod.AssignStmt, resource: ?[]const u8) CodegenError!void { + const bin: ?ast_mod.BinaryOp = switch (assign.op) { + .assign => null, + .add_assign => .add, + .sub_assign => .sub, + .mul_assign => .mul, + .div_assign => .div, + .rem_assign => .rem, + }; + const target_zig = inferExprZigType(ast, ctx, assign.target); + if (bin) |op| { + const is_int = type_map.isIntLikeZigType(target_zig); + if (is_int or op == .rem) { + if (is_int) try w.print(" = {s}({s}, ", .{ intArithHelper(op).?, target_zig }) else try w.write(" = @rem("); + try emitAssignTarget(w, ast, ctx, assign.target, resource); + try w.write(", "); + try emitExpr(w, ast, ctx, assign.value); + try w.write(");\n"); + return; + } + } + try w.write(" "); + try w.write(assignOpText(assign.op)); + try w.write(" "); + try emitExpr(w, ast, ctx, assign.value); + try w.write(";\n"); +} + fn assignOpText(op: ast_mod.AssignOp) []const u8 { return switch (op) { .assign => "=", @@ -3355,8 +3455,7 @@ fn emitExpr(w: *Writer, ast: *const AstArena, ctx: *LocalCtx, id: NodeId) Codege const kind = ast.exprKind(id); const data = ast.exprData(id); switch (kind) { - .int_lit => try w.write(ast.strings.slice(data)), - .float_lit => try w.write(ast.strings.slice(data)), + .int_lit, .float_lit => try writeLiteralDigits(w, ast.strings.slice(data)), .bool_lit => try w.write(ast.strings.slice(data)), .string_lit => { // String literal → a Zig `[]const u8` @@ -3945,15 +4044,13 @@ fn emitExpr(w: *Writer, ast: *const AstArena, ctx: *LocalCtx, id: NodeId) Codege const target_is_float = std.mem.eql(u8, zig_t, "f32") or std.mem.eql(u8, zig_t, "f64"); const src_zig = inferExprZigType(ast, ctx, c.operand); const src_is_float = std.mem.eql(u8, src_zig, "f32") or std.mem.eql(u8, src_zig, "f64"); - const conv: []const u8 = if (target_is_float and !src_is_float) - "@floatFromInt" - else if (!target_is_float and src_is_float) - "@intFromFloat" - else if (target_is_float) - "@floatCast" - else - "@intCast"; - try w.print("@as({s}, {s}(", .{ zig_t, conv }); + if (!target_is_float) { + try w.print("{s}({s}, ", .{ if (src_is_float) "__etchIntFromFloat" else "__etchNarrow", zig_t }); + try emitExpr(w, ast, ctx, c.operand); + try w.write(")"); + return; + } + try w.print("@as({s}, {s}(", .{ zig_t, if (src_is_float) "@floatCast" else "@floatFromInt" }); try emitExpr(w, ast, ctx, c.operand); try w.write("))"); }, @@ -3979,6 +4076,25 @@ fn emitExpr(w: *Writer, ast: *const AstArena, ctx: *LocalCtx, id: NodeId) Codege try w.write(" }) catch unreachable)"); return; } + const operand_zig = inferExprZigType(ast, ctx, b.lhs); + if (intArithHelper(b.op)) |helper| { + if (type_map.isIntLikeZigType(operand_zig)) { + try w.print("{s}({s}, ", .{ helper, operand_zig }); + try emitExpr(w, ast, ctx, b.lhs); + try w.write(", "); + try emitExpr(w, ast, ctx, b.rhs); + try w.write(")"); + return; + } + if (b.op == .rem) { + try w.write("@rem("); + try emitExpr(w, ast, ctx, b.lhs); + try w.write(", "); + try emitExpr(w, ast, ctx, b.rhs); + try w.write(")"); + return; + } + } try w.write("("); try emitExpr(w, ast, ctx, b.lhs); try w.print(" {s} ", .{binaryOpText(b.op)}); @@ -3989,7 +4105,12 @@ fn emitExpr(w: *Writer, ast: *const AstArena, ctx: *LocalCtx, id: NodeId) Codege const u = ast.unary_exprs.items[data]; switch (u.op) { .neg => { - try w.write("-("); + const operand_zig = inferExprZigType(ast, ctx, u.operand); + if (type_map.isIntLikeZigType(operand_zig)) { + try w.print("__etchNeg({s}, ", .{operand_zig}); + } else { + try w.write("-("); + } try emitExpr(w, ast, ctx, u.operand); try w.write(")"); }, @@ -6480,49 +6601,47 @@ fn fieldZigTypeOnComponent(ast: *const AstArena, comp_name: []const u8, field_na // ─── Const expressions (field defaults / filter values) ───────────────────── +/// Emits a constant as the value `const_eval.fold` gives it, the value the +/// interpreter stores. A float literal keeps its own digits, so Zig rounds it +/// once to the slot's type as the source does. fn emitConstExpr(w: *Writer, ast: *const AstArena, expr: NodeId, target_zig_type: []const u8) CodegenError!void { - const kind = ast.exprKind(expr); - const data = ast.exprData(expr); - switch (kind) { - .int_lit => { - const text = ast.strings.slice(data); - // Coerce int literal to a float-typed slot by emitting - // `.0` so Zig is happy with the field type. - if (type_map.isFloatLikeZigType(target_zig_type)) { - try w.print("@as({s}, {s})", .{ target_zig_type, text }); - } else { - try w.write(text); + const folded = const_eval.fold(w.gpa, ast, expr) catch |err| switch (err) { + error.OutOfMemory => return CodegenError.OutOfMemory, + else => return CodegenError.UnsupportedConstruct, + }; + switch (folded) { + .int_ => |v| if (type_map.isFloatLikeZigType(target_zig_type)) + try w.print("@as({s}, {d})", .{ target_zig_type, v }) + else + try w.print("{d}", .{v}), + .float_ => |v| { + var lit = expr; + var negated = false; + if (ast.exprKind(lit) == .unary) { + lit = ast.unary_exprs.items[ast.exprData(lit)].operand; + negated = true; } - }, - .float_lit => try w.write(ast.strings.slice(data)), - .bool_lit => try w.write(ast.strings.slice(data)), - .binary => { - const b = ast.binary_exprs.items[data]; - try w.write("("); - try emitConstExpr(w, ast, b.lhs, target_zig_type); - try w.print(" {s} ", .{binaryOpText(b.op)}); - try emitConstExpr(w, ast, b.rhs, target_zig_type); - try w.write(")"); - }, - .unary => { - const u = ast.unary_exprs.items[data]; - switch (u.op) { - .neg => { - try w.write("-("); - try emitConstExpr(w, ast, u.operand, target_zig_type); - try w.write(")"); - }, - .logical_not => { - try w.write("!("); - try emitConstExpr(w, ast, u.operand, target_zig_type); - try w.write(")"); - }, - // `expr!` needs a runtime optional — never const-evaluable. - .force_unwrap => return CodegenError.UnsupportedConstruct, + if (ast.exprKind(lit) == .float_lit) { + if (negated) try w.write("-"); + try writeLiteralDigits(w, ast.strings.slice(ast.exprData(lit))); + } else { + try w.print("{e}", .{v}); } }, - else => return CodegenError.UnsupportedConstruct, + .bool_ => |v| try w.write(if (v) "true" else "false"), + } +} + +/// Writes a numeric literal's text without its `_` separators, which the lexer +/// admits anywhere in the digit run and Zig does not. +fn writeLiteralDigits(w: *Writer, text: []const u8) CodegenError!void { + var start: usize = 0; + for (text, 0..) |c, i| { + if (c != '_') continue; + try w.write(text[start..i]); + start = i + 1; } + try w.write(text[start..]); } // ─── `tick` ───────────────────────────────────────────────────────────────── diff --git a/tests/etch/numeric_range_test.zig b/tests/etch/numeric_range_test.zig new file mode 100644 index 00000000..409b84ec --- /dev/null +++ b/tests/etch/numeric_range_test.zig @@ -0,0 +1,240 @@ +//! A literal that overflows its type, and a constant whose folding overflows or +//! divides by zero, are refused when the program is checked. + +const std = @import("std"); +const weld_etch = @import("weld_etch"); + +/// Whether checking `src` reports a diagnostic whose message holds `needle`. +fn reports(src: []const u8, needle: []const u8) !bool { + const gpa = std.testing.allocator; + var pr = try weld_etch.parseSource(gpa, src); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(weld_etch.Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try weld_etch.typeCheck(gpa, &pr.ast, &diags); + for (diags.items) |d| { + if (std.mem.indexOf(u8, d.primary_message, needle) != null) return true; + } + return false; +} + +/// The diagnostic count of checking `src`. +fn diagnosticCount(src: []const u8) !usize { + const gpa = std.testing.allocator; + var pr = try weld_etch.parseSource(gpa, src); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(weld_etch.Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try weld_etch.typeCheck(gpa, &pr.ast, &diags); + for (diags.items) |d| std.debug.print("diagnostic: {s}\n", .{d.primary_message}); + return diags.items.len; +} + +test "an int literal above int is refused wherever it sits" { + try std.testing.expect(try reports( + \\resource R { v: int = 0 } + \\rule r() when resource R { let x = 9223372036854775808 } + , "literal 9223372036854775808 does not fit in int")); +} + +test "the int minimum fits written negated" { + try std.testing.expectEqual(@as(usize, 0), try diagnosticCount( + \\resource R { v: int = -9223372036854775808 } + )); +} + +test "an i32 default above i32 is refused" { + try std.testing.expect(try reports("component C { v: i32 = 3000000000 }", "literal 3000000000 does not fit in i32")); +} + +test "a u32 default below zero is refused" { + try std.testing.expect(try reports("component C { v: u32 = -1 }", "literal -1 does not fit in u32")); +} + +test "an f32 default above f32 is refused" { + try std.testing.expect(try reports("component C { v: f32 = 1" ++ "0" ** 39 ++ ".0 }", "does not fit in f32")); +} + +test "a float literal that is not finite is refused" { + try std.testing.expect(try reports("component C { v: float = 1" ++ "0" ** 400 ++ ".0 }", "does not fit in float")); +} + +test "each narrow type's bounds are accepted" { + try std.testing.expectEqual(@as(usize, 0), try diagnosticCount( + \\component C { + \\ hi: i32 = 2147483647 + \\ lo: i32 = -2147483648 + \\ top: u32 = 4294967295 + \\ zero: u32 = 0 + \\ big: f32 = 340282346638528859811704183484516925440.0 + \\} + )); +} + +test "a default whose folding overflows is refused" { + try std.testing.expect(try reports("component C { v: int = 9223372036854775807 + 1 }", "the constant overflows int")); +} + +test "a default that divides by zero is refused" { + try std.testing.expect(try reports("component C { v: int = 1 / 0 }", "the constant divides by zero")); +} + +test "a const whose folding overflows is refused" { + try std.testing.expect(try reports("const X: int = 9223372036854775807 * 2", "the constant overflows int")); +} + +test "a float default whose folding is not finite is refused" { + try std.testing.expect(try reports("component C { v: float = 1.0 / 0.0 }", "the constant is not a finite float")); +} + +test "a default of logic over constants folds" { + try std.testing.expectEqual(@as(usize, 0), try diagnosticCount("component C { flag: bool = true or false }")); +} + +test "a default concatenating strings is not a constant" { + try std.testing.expect(try reports("resource R { s: string = \"a\" + \"b\" }", "field default value must be a constant expression")); +} + +test "a filter value outside its i32 field is refused" { + try std.testing.expect(try reports( + \\component C { v: i32 = 0 } + \\rule r(entity: Entity) when entity has C { v == 3000000000 } {} + , "literal 3000000000 does not fit in i32")); +} + +test "an int literal filter on an i32 field is accepted" { + try std.testing.expectEqual(@as(usize, 0), try diagnosticCount( + \\component C { v: i32 = 0 } + \\rule r(entity: Entity) when entity has C { v == 5 } {} + )); +} + +test "a filter value that is not a constant is refused" { + try std.testing.expect(try reports( + \\component C { v: int = 0 } + \\rule r(entity: Entity) when entity has C { v == 1 / 0 } {} + , "the constant divides by zero")); +} + +test "a resource instance value outside its i32 field is refused" { + try std.testing.expect(try reports( + \\resource R { v: i32 = 0 } + \\scene "S" { + \\ resources { R { v: 3000000000 } } + \\} + , "literal 3000000000 does not fit in i32")); +} + +test "an effect parameter default outside i32 is refused" { + try std.testing.expect(try reports( + \\effect Burst { + \\ params { count: i32 = 3000000000 } + \\ emitter spark { rate: 10.0 } + \\} + , "literal 3000000000 does not fit in i32")); +} + +test "a negative array size is refused" { + try std.testing.expect(try reports( + \\resource R { v: int = 0 } + \\rule r() when resource R { let xs: int[-1] = [0; 1] } + , "array size must be a non-negative integer literal")); +} + +test "an array fill count that is not a literal is refused" { + try std.testing.expect(try reports( + \\resource R { v: int = 0 } + \\rule r() when resource R { + \\ let n = 3 + \\ let xs = [0; n] + \\} + , "array fill count must be a non-negative integer literal")); +} + +/// The Zig source the codegen emits for `src`, which must check clean. +fn lowered(gpa: std.mem.Allocator, src: []const u8, out: *std.ArrayListUnmanaged(u8)) !void { + try std.testing.expectEqual(@as(usize, 0), try diagnosticCount(src)); + var pr = try weld_etch.parseSource(gpa, src); + defer pr.deinit(gpa); + var diags: std.ArrayListUnmanaged(weld_etch.Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try weld_etch.typeCheck(gpa, &pr.ast, &diags); + _ = try weld_etch.codegen_zig.lower.generateFile(gpa, &pr.ast, "numeric_range_test.etch", out); +} + +test "integer arithmetic lowers through the overflow helpers, float arithmetic does not" { + const gpa = std.testing.allocator; + var out: std.ArrayListUnmanaged(u8) = .empty; + defer out.deinit(gpa); + try lowered(gpa, + \\component Acc { n: int = 0 x: float = 0.0 } + \\rule r(entity: Entity) when entity has Acc { + \\ let n = entity.get(Acc).n + \\ let x = entity.get(Acc).x + \\ entity.get_mut(Acc).n = n * 3 + \\ entity.get_mut(Acc).x = x * 3.0 + \\} + , &out); + try std.testing.expect(std.mem.indexOf(u8, out.items, "__etchMul(i64, n, 3)") != null); + try std.testing.expect(std.mem.indexOf(u8, out.items, "(x * 3.0)") != null); +} + +test "narrowing and float-to-int casts lower through their helpers" { + const gpa = std.testing.allocator; + var out: std.ArrayListUnmanaged(u8) = .empty; + defer out.deinit(gpa); + try lowered(gpa, + \\component Acc { n: int = 0 x: float = 0.0 a: i32 = 0 b: i32 = 0 } + \\rule r(entity: Entity) when entity has Acc { + \\ let n = entity.get(Acc).n + \\ let x = entity.get(Acc).x + \\ entity.get_mut(Acc).a = n as i32 + \\ entity.get_mut(Acc).b = x as i32 + \\} + , &out); + try std.testing.expect(std.mem.indexOf(u8, out.items, "__etchNarrow(i32, n)") != null); + try std.testing.expect(std.mem.indexOf(u8, out.items, "__etchIntFromFloat(i32, x)") != null); +} + +test "an array element outside its declared i32 is refused" { + try std.testing.expect(try reports( + \\resource R { v: int = 0 } + \\rule r() when resource R { let xs: i32[] = [1, 3000000000] } + , "literal 3000000000 does not fit in i32")); +} + +test "a map value outside its declared u32 is refused" { + try std.testing.expect(try reports( + \\resource R { v: int = 0 } + \\rule r() when resource R { let m: [int: u32] = [1: -1] } + , "literal -1 does not fit in u32")); +} + +test "an array element of another type than declared is refused" { + try std.testing.expect(try reports( + \\resource R { v: int = 0 } + \\rule r() when resource R { let xs: i32[] = [1.5] } + , "collection element type does not match the declared element type")); +} + +test "literal elements within their declared types are accepted" { + try std.testing.expectEqual(@as(usize, 0), try diagnosticCount( + \\resource R { v: int = 0 } + \\rule r() when resource R { + \\ let xs: i32[] = [1, -2147483648] + \\ let ys: u32[2] = [0; 2] + \\ let m: [int: f32] = [1: 2.5] + \\} + )); +} diff --git a/tests/etch_interp/codegen_corpus_build.zig b/tests/etch_interp/codegen_corpus_build.zig index ab59b9d4..15b694aa 100644 --- a/tests/etch_interp/codegen_corpus_build.zig +++ b/tests/etch_interp/codegen_corpus_build.zig @@ -119,4 +119,6 @@ pub const programs = [_]CodegenProgram{ // Triple-quote with a multi-line interpolation: the dedent must touch the // literal segments and never the interpolation's own bytes. .{ .name = "p87_triple_quote_multiline_interp", .etch_path = "tests/etch_interp/programs/87_triple_quote_multiline_interp.etch" }, + // Runtime integer division and remainder, float remainder, narrowing casts. + .{ .name = "p88_runtime_arith", .etch_path = "tests/etch_interp/programs/88_runtime_arith.etch" }, }; diff --git a/tests/etch_interp/corpus_facade.zig b/tests/etch_interp/corpus_facade.zig index b977fad2..ebaf0ab0 100644 --- a/tests/etch_interp/corpus_facade.zig +++ b/tests/etch_interp/corpus_facade.zig @@ -95,6 +95,8 @@ const p86 = @import("programs/86_triple_quote_multiline.expected.zig"); // the literal segments and never the interpolation's inner bytes. Byte-exact // across interp ↔ codegen. const p87 = @import("programs/87_triple_quote_multiline_interp.expected.zig"); +// Runtime integer division and remainder, float remainder, narrowing casts. +const p88 = @import("programs/88_runtime_arith.expected.zig"); /// Embedded list of the differential corpus programs, consumed by the /// interpreter test and by the codegen parity test. @@ -167,4 +169,5 @@ pub const programs = [_]Program{ .{ .name = "85_match_binding", .source = @embedFile("programs/85_match_binding.etch"), .config = p85.config, .initial = p85.initial, .expected = p85.expected }, .{ .name = "86_triple_quote_multiline", .source = @embedFile("programs/86_triple_quote_multiline.etch"), .config = p86.config, .initial = p86.initial, .expected = p86.expected }, .{ .name = "87_triple_quote_multiline_interp", .source = @embedFile("programs/87_triple_quote_multiline_interp.etch"), .config = p87.config, .initial = p87.initial, .expected = p87.expected }, + .{ .name = "88_runtime_arith", .source = @embedFile("programs/88_runtime_arith.etch"), .config = p88.config, .initial = p88.initial, .expected = p88.expected }, }; diff --git a/tests/etch_interp/programs/88_runtime_arith.etch b/tests/etch_interp/programs/88_runtime_arith.etch new file mode 100644 index 00000000..31e6822d --- /dev/null +++ b/tests/etch_interp/programs/88_runtime_arith.etch @@ -0,0 +1,30 @@ +// Integer division and remainder on runtime operands, a float remainder, an +// `i32` compound assignment and narrowing casts that fit: each lowers through +// the codegen's arithmetic helpers, and both backends reach the same state. +component Pair { + a: int = 17 + b: int = -5 + quot: int = 0 + rem: int = 0 + x: float = 7.5 + frem: float = 0.0 + acc: i32 = 100 + step: i32 = 7 + narrow: i32 = 0 + whole: u32 = 0 +} + +rule update(entity: Entity) + when entity has Pair +{ + let a = entity.get(Pair).a + let b = entity.get(Pair).b + let x = entity.get(Pair).x + let step = entity.get(Pair).step + entity.get_mut(Pair).quot = a / b + entity.get_mut(Pair).rem = a % b + entity.get_mut(Pair).frem = x % 2.0 + entity.get_mut(Pair).acc -= step + entity.get_mut(Pair).narrow = (a * b) as i32 + entity.get_mut(Pair).whole = x as u32 +} diff --git a/tests/etch_interp/programs/88_runtime_arith.expected.zig b/tests/etch_interp/programs/88_runtime_arith.expected.zig new file mode 100644 index 00000000..c4718dc8 --- /dev/null +++ b/tests/etch_interp/programs/88_runtime_arith.expected.zig @@ -0,0 +1,29 @@ +const driver = @import("diff_runner"); + +/// Diff-runner fixture: tick budget for this scenario. +pub const config: driver.Config = .{ .ticks = 1 }; + +/// Diff-runner fixture: world snapshot at tick 0. +pub const initial: driver.WorldSpec = .{ + .entities = &[_]driver.EntitySpec{ + .{ .components = &[_]driver.ComponentSpec{ + .{ .name = "Pair" }, + } }, + }, +}; + +/// Diff-runner fixture: expected world snapshot after the run. +pub const expected: driver.ExpectedWorld = .{ + .entities = &[_]driver.EntitySpec{ + .{ .components = &[_]driver.ComponentSpec{ + .{ .name = "Pair", .fields = &[_]driver.FieldSpec{ + .{ .name = "quot", .value = .{ .int_ = -3 } }, + .{ .name = "rem", .value = .{ .int_ = 2 } }, + .{ .name = "frem", .value = .{ .float_ = 1.5 } }, + .{ .name = "acc", .value = .{ .int_ = 93 } }, + .{ .name = "narrow", .value = .{ .int_ = -85 } }, + .{ .name = "whole", .value = .{ .int_ = 7 } }, + } }, + } }, + }, +}; diff --git a/tests/scene/cook_errors_test.zig b/tests/scene/cook_errors_test.zig index 0a284ba4..3bf7e441 100644 --- a/tests/scene/cook_errors_test.zig +++ b/tests/scene/cook_errors_test.zig @@ -81,3 +81,39 @@ test "malformed uuid is rejected" { \\} ); } + +test "an instance value outside its i32 field is rejected" { + try expectCookError(error.ValueOutOfRange, + \\component C { v: i32 = 0 } + \\scene "S" { + \\ entity "E" { uuid: "7b3e2f1a-42a3-4f2b-8c9d-a3f2b1c98d4e" C { v: 3000000000 } } + \\} + ); +} + +test "an instance value whose folding overflows is rejected" { + try expectCookError(error.ValueOutOfRange, + \\component C { v: int = 0 } + \\scene "S" { + \\ entity "E" { uuid: "7b3e2f1a-42a3-4f2b-8c9d-a3f2b1c98d4e" C { v: 9223372036854775807 + 1 } } + \\} + ); +} + +test "a component default outside its i32 field is rejected" { + try expectCookError(error.ValueOutOfRange, + \\component C { v: i32 = 3000000000 } + \\scene "S" { + \\ entity "E" { uuid: "7b3e2f1a-42a3-4f2b-8c9d-a3f2b1c98d4e" C { } } + \\} + ); +} + +test "a component collection field is rejected" { + try expectCookError(error.UnsupportedFieldKind, + \\component C { xs: int[] } + \\scene "S" { + \\ entity "E" { uuid: "7b3e2f1a-42a3-4f2b-8c9d-a3f2b1c98d4e" C { } } + \\} + ); +} diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 7a2953a2..3b274732 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -237,8 +237,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2438, - else => 2440, + .windows => 2488, + else => 2490, }; } From 2e29c78ea6c2fb57c5376b0835d8491aec86e04b Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 23 Sep 2026 15:05:19 +0200 Subject: [PATCH 020/141] test(etch): witness the compile refusal and the codegen constants The interpreter refuses at compile a default whose folding fails, where it left the slot zero; the codegen emits a constant default as its folded value and a runtime literal without its separators, and a compound integer assignment through its helper. None of these had a test that tells the form from the one it replaced. Floor 2490 -> 2495 / 2493, measured: 2476/2495 passed, 19 skipped. Co-Authored-By: Claude Opus 5.5 --- src/etch/interp.zig | 18 +++++++++++++ tests/etch/numeric_range_test.zig | 42 +++++++++++++++++++++++++++++++ tools/weld_lint/dead_tests.zig | 4 +-- 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/etch/interp.zig b/src/etch/interp.zig index 8178f5c8..a0a45cc0 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -16587,3 +16587,21 @@ test "a default that overflows its i32 field is refused at compile" { defer pr.deinit(gpa); try std.testing.expectError(error.ValueOutOfRange, compileUnchecked(gpa, &pr, &world)); } + +test "a default that divides by zero is refused at compile" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, "component C { v: int = 1 / 0 }"); + defer pr.deinit(gpa); + try std.testing.expectError(error.InvalidProgram, compileUnchecked(gpa, &pr, &world)); +} + +test "a default whose folding overflows is refused at compile" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, "component C { v: int = 9223372036854775807 + 1 }"); + defer pr.deinit(gpa); + try std.testing.expectError(error.ValueOutOfRange, compileUnchecked(gpa, &pr, &world)); +} diff --git a/tests/etch/numeric_range_test.zig b/tests/etch/numeric_range_test.zig index 409b84ec..f5591945 100644 --- a/tests/etch/numeric_range_test.zig +++ b/tests/etch/numeric_range_test.zig @@ -238,3 +238,45 @@ test "literal elements within their declared types are accepted" { \\} )); } + +test "a compound integer assignment lowers through its helper" { + const gpa = std.testing.allocator; + var out: std.ArrayListUnmanaged(u8) = .empty; + defer out.deinit(gpa); + try lowered(gpa, + \\component Acc { n: int = 0 } + \\rule r(entity: Entity) when entity has Acc { + \\ let mut t = 1 + \\ t += 2 + \\ entity.get_mut(Acc).n = t + \\} + , &out); + try std.testing.expect(std.mem.indexOf(u8, out.items, "t = __etchAdd(i64, t, 2);") != null); +} + +test "a constant default lowers as its folded value" { + const gpa = std.testing.allocator; + var out: std.ArrayListUnmanaged(u8) = .empty; + defer out.deinit(gpa); + try lowered(gpa, + \\component Acc { + \\ n: int = 1_000_ + \\ m: int = 2 * 3 + \\ lo: int = -9223372036854775808 + \\} + , &out); + try std.testing.expect(std.mem.indexOf(u8, out.items, "n: i64 = 1000,") != null); + try std.testing.expect(std.mem.indexOf(u8, out.items, "m: i64 = 6,") != null); + try std.testing.expect(std.mem.indexOf(u8, out.items, "lo: i64 = -9223372036854775808,") != null); +} + +test "a runtime literal lowers without its separators" { + const gpa = std.testing.allocator; + var out: std.ArrayListUnmanaged(u8) = .empty; + defer out.deinit(gpa); + try lowered(gpa, + \\component Acc { n: int = 0 } + \\rule r(entity: Entity) when entity has Acc { entity.get_mut(Acc).n = 1_000_ } + , &out); + try std.testing.expect(std.mem.indexOf(u8, out.items, "= 1000;") != null); +} diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 3b274732..3f84b91b 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -237,8 +237,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2488, - else => 2490, + .windows => 2493, + else => 2495, }; } From 31d78c9436d3be3c7413842df196712c4a60495a Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 23 Sep 2026 15:20:24 +0200 Subject: [PATCH 021/141] fix(etch): check a resource collection default against its elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item 3 of S5/G8 ter. The checker's resource collection branch validated the element type and `continue`d past every default check, so `xs: int[] = 5` passed, and the interpreter kept only what it could read: an array or map literal, element by element, dropping any element that failed to fold and ignoring every other form. The checker: a `T[]` default is an array literal, a `[K: V]` default a map literal, a `Set` default `Set.new()` (stdlib §4.5), each element a constant of the declared element type (E1101 otherwise), a variant of it for an enum element (E0105), and a literal within its range; a wrong shape is E0200 for a constant and E1101 for anything else. A default on an element type already refused (E0222) is not checked a second time. The runtime: a fill default `[v; n]` materialises `n` copies where it kept one, an enum element is stored as its variant where it was dropped, a repeated map key keeps its last value as the runtime literal does where the default kept both, and any form the checker refuses is `InvalidProgram` where it gave an empty container or dropped the element. The scene cook refuses a resource with a collection field, which it wrote into a `.scene.bin` the loader always refuses. Adjacents: a scalar enum default is checked (E0105, E0200), where `m: Mode = .nope` stored the first variant; a float map key or set element is refused by the Hash bound (E0601) as it is on a `let`; a Vec3, Color or Duration element is E0222, which the element set's own text already limits to scalar POD, string and enum. Floor 2495 -> 2519 / 2517, measured: 2500/2519 passed, 19 skipped. Co-Authored-By: Claude Opus 5.5 --- build.zig | 3 + src/etch/interp.zig | 228 ++++++++++++++++++------- src/etch/scene_cook.zig | 6 + src/etch/types.zig | 142 +++++++++++++-- tests/etch/collection_default_test.zig | 132 ++++++++++++++ tests/scene/cook_errors_test.zig | 9 + tools/weld_lint/dead_tests.zig | 4 +- 7 files changed, 443 insertions(+), 81 deletions(-) create mode 100644 tests/etch/collection_default_test.zig diff --git a/build.zig b/build.zig index 67bbfa50..9e860140 100644 --- a/build.zig +++ b/build.zig @@ -969,6 +969,9 @@ pub fn build(b: *std.Build) void { // A literal that overflows its type, and a constant whose folding // overflows or divides by zero, are refused at check time. .{ .path = "tests/etch/numeric_range_test.zig", .etch = true, .dedicated_step = "test-numeric-range" }, + // A resource collection field's default is checked against its + // element type. + .{ .path = "tests/etch/collection_default_test.zig", .etch = true, .dedicated_step = "test-collection-default" }, // one test per type-checker diagnostic code: each names its code and // asserts PRESENCE, so it reddens the day emission stops. .{ .path = "tests/etch/diagnostic_coverage_test.zig", .etch = true }, diff --git a/src/etch/interp.zig b/src/etch/interp.zig index a0a45cc0..1884f3b5 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -7323,63 +7323,106 @@ fn dropPersistentMap(gpa: std.mem.Allocator, p: [*]u8, size: usize) void { list.deinit(gpa); } -/// Const-evaluate a literal-default collection entry for storage: a -/// string literal is deep-copied into an owned persistent string; any other -/// const expression is `evalConst`'d (POD inline). Errors on a non-const entry. -fn constCollectionValue(gpa: std.mem.Allocator, ast: *const AstArena, node: NodeId) !Value { - if (ast.exprKind(node) == .string_lit) return ownBytesAsPersistentString(gpa, ast.strings.slice(ast.exprData(node))); - return evalConst(gpa, ast, node); +/// One element of a collection default, stored: a string literal as an owned +/// persistent string, a `.variant` as its value in the element enum +/// `elem_enum`, any other constant as `evalConst` folds it. The checker admits +/// nothing else, so anything else is `error.InvalidProgram`. +fn constCollectionValue(gpa: std.mem.Allocator, ast: *const AstArena, node: NodeId, elem_enum: ?StringId) !Value { + switch (ast.exprKind(node)) { + .string_lit => return ownBytesAsPersistentString(gpa, ast.strings.slice(ast.exprData(node))), + .tag_path => { + const ename = elem_enum orelse return error.InvalidProgram; + const edecl = findEnumDecl(ast, ename) orelse return error.InvalidProgram; + const vidx = enumVariantIndex(ast, edecl, ast.exprData(node)) orelse return error.InvalidProgram; + return Value{ .enum_value = .{ .type_name = ename, .variant = vidx } }; + }, + else => return evalConst(gpa, ast, node) catch |err| switch (err) { + error.OutOfMemory => error.OutOfMemory, + else => error.InvalidProgram, + }, + } +} + +/// The declared enum a collection element type node names, or null. +fn collectionElemEnum(ast: *const AstArena, elem: NodeId) ?StringId { + const name = ast.resolveTypeAliasName(ast.namedTypeName(elem) orelse return null); + return if (findEnumDecl(ast, name) != null) name else null; +} + +/// Bytes of a `.string_persistent` value (empty otherwise). +fn persistentStrBytes(v: Value) []const u8 { + return switch (v) { + .string_persistent => |s| if (s.len == 0) "" else @as([*]const u8, @ptrFromInt(s.ptr))[0..s.len], + else => "", + }; +} + +/// Whether two keys of a default map are one key: strings by their bytes. +fn defaultKeyEql(a: Value, b: Value) bool { + if (a == .string_persistent and b == .string_persistent) return std.mem.eql(u8, persistentStrBytes(a), persistentStrBytes(b)); + return a.eql(b); +} + +fn dropDefaultValue(gpa: std.mem.Allocator, v: Value) void { + if (v == .string_persistent and v.string_persistent.ptr != 0) persistent.decref(gpa, @ptrFromInt(v.string_persistent.ptr)); } -/// Build a `type_array` container for a resource field: empty, or a literal-array -/// default (`= [...]`) with elements deep-copied. -fn initArrayBlock(gpa: std.mem.Allocator, ast: *const AstArena, f: ast_mod.Field) std.mem.Allocator.Error![*]u8 { +/// Build a `type_array` container for a resource field: empty, or its array +/// literal default (`[a, b]`, or `[v; n]` as `n` copies) with elements +/// deep-copied. +fn initArrayBlock(gpa: std.mem.Allocator, ast: *const AstArena, f: ast_mod.Field) ![*]u8 { const block = try allocEmptyArrayBlock(gpa); errdefer persistent.decref(gpa, block); - if (!f.default_value.isNone() and ast.exprKind(f.default_value) == .array_lit) { - const al = ast.array_lits.items[ast.exprData(f.default_value)]; - const list: *PersistentArray = @ptrCast(@alignCast(block)); - var e_i: u32 = 0; - while (e_i < al.elements_len) : (e_i += 1) { - const en: NodeId = @bitCast(ast.extra.items[al.elements_start + e_i]); - // Reserve before promoting so a string allocation never dangles on an - // append-time OOM (the block's errdefer drops appended elements). - try list.ensureUnusedCapacity(gpa, 1); - const ev = constCollectionValue(gpa, ast, en) catch |e| switch (e) { - error.OutOfMemory => return error.OutOfMemory, - else => continue, - }; - list.appendAssumeCapacity(ev); - } + if (f.default_value.isNone()) return block; + if (ast.exprKind(f.default_value) != .array_lit) return error.InvalidProgram; + const al = ast.array_lits.items[ast.exprData(f.default_value)]; + const elem_enum = collectionElemEnum(ast, ast.array_types.items[ast.typeNodeData(f.type_node)].elem); + const count: usize = if (!al.is_fill) al.elements_len else blk: { + if (ast.exprKind(al.fill_count) != .int_lit) return error.InvalidProgram; + break :blk const_eval.intLiteralMagnitude(ast.strings.slice(ast.exprData(al.fill_count))) orelse return error.InvalidProgram; + }; + const list: *PersistentArray = @ptrCast(@alignCast(block)); + var e_i: usize = 0; + while (e_i < count) : (e_i += 1) { + const en: NodeId = @bitCast(ast.extra.items[al.elements_start + if (al.is_fill) 0 else e_i]); + // Reserve before promoting so a string allocation never dangles on an + // append-time OOM (the block's errdefer drops appended elements). + try list.ensureUnusedCapacity(gpa, 1); + list.appendAssumeCapacity(try constCollectionValue(gpa, ast, en, elem_enum)); } return block; } -/// Build a `type_map` container for a resource field: empty, or a literal-map -/// default (`= [k: v, …]`) with keys+values deep-copied. Entries are -/// appended in order; a degenerate duplicate key in the literal yields duplicate -/// pairs (runtime `insert` is the last-write-wins path). -fn initMapBlock(gpa: std.mem.Allocator, ast: *const AstArena, f: ast_mod.Field) std.mem.Allocator.Error![*]u8 { +/// Build a `type_map` container for a resource field: empty, or its map +/// literal default with keys and values deep-copied. A repeated key keeps its +/// last value, as the runtime map literal does. +fn initMapBlock(gpa: std.mem.Allocator, ast: *const AstArena, f: ast_mod.Field) ![*]u8 { const block = try allocEmptyMapBlock(gpa); errdefer persistent.decref(gpa, block); - if (!f.default_value.isNone() and ast.exprKind(f.default_value) == .map_lit) { - const ml = ast.map_lits.items[ast.exprData(f.default_value)]; - const list: *PersistentMap = @ptrCast(@alignCast(block)); - var e_i: u32 = 0; - while (e_i < ml.entries_len) : (e_i += 1) { - const entry = ast.map_entries.items[ml.entries_start + e_i]; - try list.ensureUnusedCapacity(gpa, 1); - const kv = constCollectionValue(gpa, ast, entry.key) catch |e| switch (e) { - error.OutOfMemory => return error.OutOfMemory, - else => continue, - }; - const vv = constCollectionValue(gpa, ast, entry.value) catch |e| { - if (kv == .string_persistent and kv.string_persistent.ptr != 0) persistent.decref(gpa, @ptrFromInt(kv.string_persistent.ptr)); - if (e == error.OutOfMemory) return error.OutOfMemory; - continue; - }; - list.appendAssumeCapacity(.{ .key = kv, .value = vv }); + if (f.default_value.isNone()) return block; + if (ast.exprKind(f.default_value) != .map_lit) return error.InvalidProgram; + const mt = ast.map_types.items[ast.typeNodeData(f.type_node)]; + const key_enum = collectionElemEnum(ast, mt.key); + const value_enum = collectionElemEnum(ast, mt.value); + const ml = ast.map_lits.items[ast.exprData(f.default_value)]; + const list: *PersistentMap = @ptrCast(@alignCast(block)); + var e_i: u32 = 0; + entries: while (e_i < ml.entries_len) : (e_i += 1) { + const entry = ast.map_entries.items[ml.entries_start + e_i]; + try list.ensureUnusedCapacity(gpa, 1); + const kv = try constCollectionValue(gpa, ast, entry.key, key_enum); + const vv = constCollectionValue(gpa, ast, entry.value, value_enum) catch |e| { + dropDefaultValue(gpa, kv); + return e; + }; + for (list.items) |*pair| { + if (!defaultKeyEql(pair.key, kv)) continue; + dropDefaultValue(gpa, kv); + dropDefaultValue(gpa, pair.value); + pair.value = vv; + continue :entries; } + list.appendAssumeCapacity(.{ .key = kv, .value = vv }); } return block; } @@ -7407,8 +7450,10 @@ fn prepareResourceBuffer( const block: [*]u8 = switch (fd.kind) { .array_ => try initArrayBlock(gpa, ast, f), .map_ => try initMapBlock(gpa, ast, f), - // A set has no literal form, so it always starts empty. - else => try allocEmptySetBlock(gpa), + else => blk: { + if (!f.default_value.isNone() and !types_mod.isEmptySetConstructor(ast, f.default_value)) return error.InvalidProgram; + break :blk try allocEmptySetBlock(gpa); + }, }; blocks.appendAssumeCapacity(block); const cs = persistent.CollectionSlot{ .ptr = @intFromPtr(block) }; @@ -7623,14 +7668,11 @@ fn prepareTypeEntry(gpa: std.mem.Allocator, ast: *const AstArena, registry: *con // Enum default = a bare `.variant` shorthand → its declaration-order // discriminant (consistent with `EnumValue.variant`). No default ⇒ // discriminant 0, the first variant (`default_buf` is zeroed). - if (!f.default_value.isNone() and ast.exprKind(f.default_value) == .tag_path) { - const variant = ast.exprData(f.default_value); - if (findEnumDecl(ast, fd.enum_type_name_id)) |edecl| { - if (enumVariantIndex(ast, edecl, variant)) |vidx| { - const disc: u32 = vidx; - @memcpy(slot[0..@sizeOf(u32)], std.mem.asBytes(&disc)); - } - } + if (!f.default_value.isNone()) { + if (ast.exprKind(f.default_value) != .tag_path) return error.InvalidProgram; + const edecl = findEnumDecl(ast, fd.enum_type_name_id) orelse return error.InvalidProgram; + const disc: u32 = enumVariantIndex(ast, edecl, ast.exprData(f.default_value)) orelse return error.InvalidProgram; + @memcpy(slot[0..@sizeOf(u32)], std.mem.asBytes(&disc)); } continue; } @@ -8946,14 +8988,6 @@ fn resourceCollectionPtr(world: *World, res_name: []const u8, field_name: []cons return cs.ptr; } -/// Bytes of a `.string_persistent` value (empty otherwise). Test helper. -fn persistentStrBytes(v: Value) []const u8 { - return switch (v) { - .string_persistent => |s| if (s.len == 0) "" else @as([*]const u8, @ptrFromInt(s.ptr))[0..s.len], - else => "", - }; -} - fn expectResourceMapLen(world: *World, res_name: []const u8, field_name: []const u8, expected: usize) !void { const ptr = resourceCollectionPtr(world, res_name, field_name); try std.testing.expect(ptr != 0); @@ -16605,3 +16639,69 @@ test "a default whose folding overflows is refused at compile" { defer pr.deinit(gpa); try std.testing.expectError(error.ValueOutOfRange, compileUnchecked(gpa, &pr, &world)); } + +test "a fill default materialises its count" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, "resource R { xs: int[] = [7; 3] }"); + defer pr.deinit(gpa); + var interp = try compileUnchecked(gpa, &pr, &world); + defer interp.deinit(); + const list = persistentArrayOf(resourceCollectionPtr(&world, "R", "xs")); + try std.testing.expectEqual(@as(usize, 3), list.items.len); + for (list.items) |v| try std.testing.expectEqual(@as(i64, 7), v.int_); +} + +test "an enum element default is stored as its variant" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, + \\enum Mode { a, b } + \\resource R { xs: Mode[] = [.b] } + ); + defer pr.deinit(gpa); + var interp = try compileUnchecked(gpa, &pr, &world); + defer interp.deinit(); + const list = persistentArrayOf(resourceCollectionPtr(&world, "R", "xs")); + try std.testing.expectEqual(@as(usize, 1), list.items.len); + try std.testing.expectEqual(@as(u32, 1), list.items[0].enum_value.variant); +} + +test "a map default keeps the last value of a repeated key" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, "resource R { m: [string: int] = [\"a\": 1, \"a\": 2] }"); + defer pr.deinit(gpa); + var interp = try compileUnchecked(gpa, &pr, &world); + defer interp.deinit(); + const map = persistentMapOf(resourceCollectionPtr(&world, "R", "m")); + try std.testing.expectEqual(@as(usize, 1), map.items.len); + try std.testing.expectEqual(@as(i64, 2), map.items[0].value.int_); +} + +test "a collection default of the wrong shape is refused at compile" { + const gpa = std.testing.allocator; + const sources = [_][]const u8{ + "resource R { xs: int[] = 5 }", + "resource R { m: [string: int] = [] }", + "resource R { s: Set = [1] }", + "resource R { xs: int[] = [1 / 0] }", + "enum Mode { a }\nresource R { m: Mode = .nope }", + }; + for (sources) |src| { + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, src); + defer pr.deinit(gpa); + var interp = compileUnchecked(gpa, &pr, &world) catch |err| { + try std.testing.expectEqual(error.InvalidProgram, err); + continue; + }; + interp.deinit(); + std.debug.print("compiled: {s}\n", .{src}); + return error.TestExpectedError; + } +} diff --git a/src/etch/scene_cook.zig b/src/etch/scene_cook.zig index 08b107d4..06756f42 100644 --- a/src/etch/scene_cook.zig +++ b/src/etch/scene_cook.zig @@ -1220,6 +1220,12 @@ const Builder = struct { } fn buildResourceEntry(self: *Builder, id: ComponentId, ci: ast_mod.ComponentInstance, diag_out: ?*[]const u8) CookError!format.ResourceEntry { + // The loader refuses a collection field (`CollectionResourceFieldUnsupported`), + // so a scene carrying one would cook and never load. + for (self.registry.componentFields(id)) |fd| switch (fd.kind) { + .array_, .map_, .set_ => return fail(diag_out, error.UnsupportedFieldKind, "a resource with a collection field cannot be cooked into a scene: the loader refuses it"), + else => {}, + }; const size = self.registry.componentSize(id); const blob = try self.a().alloc(u8, size); @memcpy(blob, self.registry.componentDefaultBytes(id)); diff --git a/src/etch/types.zig b/src/etch/types.zig index 1ad385c7..13def5d1 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -3916,25 +3916,35 @@ pub const TypeChecker = struct { // part1.md` §5.5 (`recent_servers: string[]`), the persistent-heap // `CollectionSlot`. Fixed `T[N]` (`.array`) stays out of scope // (falls through to rejection). Element ∈ {POD scalar, string, - // enum}; nested collection / unsupported element → E0222. The - // collection default (`= []`) is unwired, so the accepted - // field skips the scalar-default check below. + // enum}; nested collection / unsupported element → E0222. A + // default is checked against the element type once the element + // is valid. if (origin == .resource) { + const default = field.default_value; switch (self.arena.typeNodeKind(field.type_node)) { .slice => { const at = self.arena.array_types.items[self.arena.typeNodeData(field.type_node)]; - try self.checkResourceCollectionElement(at.elem); + const elem = try self.checkResourceCollectionElement(at.elem); + if (elem != null and !default.isNone()) try self.checkArrayDefault(default, elem.?); continue; }, .set_type => { const st = self.arena.set_types.items[self.arena.typeNodeData(field.type_node)]; - try self.checkResourceCollectionElement(st.elem); + const elem = try self.checkResourceCollectionElement(st.elem); + if (elem) |e| { + if (e == .builtin) try self.checkHashBound(e.builtin, "set element type", "T: Hash", self.arena.typeNodeSpan(st.elem)); + if (!default.isNone()) try self.checkSetDefault(default); + } continue; }, .map_type => { const mt = self.arena.map_types.items[self.arena.typeNodeData(field.type_node)]; - try self.checkResourceCollectionElement(mt.key); - try self.checkResourceCollectionElement(mt.value); + const key = try self.checkResourceCollectionElement(mt.key); + const value = try self.checkResourceCollectionElement(mt.value); + if (key) |k| { + if (k == .builtin) try self.checkHashBound(k.builtin, "map key type", "K: Hash", self.arena.typeNodeSpan(mt.key)); + } + if (key != null and value != null and !default.isNone()) try self.checkMapDefault(default, key.?, value.?); continue; }, else => {}, @@ -4024,14 +4034,14 @@ pub const TypeChecker = struct { /// element (component/resource/struct/unknown/optional/function/…), emits /// `E0222 CollectionFieldElementInvalid`. This is the type-check gate only; /// element STORAGE and promotion wiring live in the interpreter. - fn checkResourceCollectionElement(self: *TypeChecker, elem: NodeId) !void { + fn checkResourceCollectionElement(self: *TypeChecker, elem: NodeId) !?CollectionElem { const espan = self.arena.typeNodeSpan(elem); const elem_name = self.arena.namedTypeName(elem) orelse { switch (self.arena.typeNodeKind(elem)) { .slice, .array, .map_type, .set_type => try self.emit(.collection_field_element_invalid, .error_, espan, "nested collections are not supported as a resource collection element (Phase 1)", .{}), else => try self.emit(.collection_field_element_invalid, .error_, espan, "resource collection element must be a scalar POD, string, or enum", .{}), } - return; + return null; }; const resolved = self.arena.resolveTypeAliasName(elem_name); const tname = self.arena.strings.slice(resolved); @@ -4045,15 +4055,102 @@ pub const TypeChecker = struct { // collection element is out of scope (rejected here rather than // becoming a silent load-time gap). if (BuiltinType.fromName(tname)) |bt| { - if (bt == .entity) { - try self.emit(.collection_field_element_invalid, .error_, espan, "'Entity' is not supported as a resource collection element (Phase 1) — an entity reference carries cross-reference-table remap semantics a persistent collection does not wire", .{}); - return; + switch (bt) { + .int_, .float_, .bool_, .i32_, .u32_, .f32_, .f64_ => return .{ .builtin = bt }, + .entity => { + try self.emit(.collection_field_element_invalid, .error_, espan, "'Entity' is not supported as a resource collection element (Phase 1) — an entity reference carries cross-reference-table remap semantics a persistent collection does not wire", .{}); + return null; + }, + else => {}, } - return; // value-POD builtin, stored inline + } else { + if (std.mem.eql(u8, tname, "string")) return .string; + if (self.declaredEnumName(resolved)) return .{ .enum_ = resolved }; } - if (std.mem.eql(u8, tname, "string")) return; - if (self.declaredEnumName(resolved)) return; try self.emit(.collection_field_element_invalid, .error_, espan, "resource collection element type '{s}' is not supported — must be a scalar POD, string, or enum", .{tname}); + return null; + } + + /// The element type a resource collection field holds. + const CollectionElem = union(enum) { + builtin: BuiltinType, + string, + enum_: StringId, + }; + + /// A `T[]` field default: an array literal whose every element is a + /// constant of `T`. + fn checkArrayDefault(self: *TypeChecker, value: NodeId, elem: CollectionElem) !void { + if (self.arena.exprKind(value) != .array_lit) return self.refuseCollectionShape(value, "an array field default must be an array literal"); + const al = self.arena.array_lits.items[self.arena.exprData(value)]; + var i: u32 = 0; + while (i < al.elements_len) : (i += 1) { + try self.checkCollectionDefaultElement(@bitCast(self.arena.extra.items[al.elements_start + i]), elem); + } + if (al.is_fill and self.constArrayLen(al.fill_count) == null) { + try self.emit(.not_const_evaluable, .error_, self.arena.exprSpan(al.fill_count), "array fill count must be a non-negative integer literal", .{}); + } + } + + /// A `[K: V]` field default: a map literal whose every key and value is a + /// constant of `K` and `V`. + fn checkMapDefault(self: *TypeChecker, value: NodeId, key: CollectionElem, val: CollectionElem) !void { + if (self.arena.exprKind(value) != .map_lit) return self.refuseCollectionShape(value, "a map field default must be a map literal"); + const ml = self.arena.map_lits.items[self.arena.exprData(value)]; + var i: u32 = 0; + while (i < ml.entries_len) : (i += 1) { + const entry = self.arena.map_entries.items[ml.entries_start + i]; + try self.checkCollectionDefaultElement(entry.key, key); + try self.checkCollectionDefaultElement(entry.value, val); + } + } + + /// A `Set` field default: a set has no literal, and the one default is + /// the empty `Set.new()` (`etch-stdlib.md` §4.5). + fn checkSetDefault(self: *TypeChecker, value: NodeId) !void { + if (isEmptySetConstructor(self.arena, value)) return; + return self.refuseCollectionShape(value, "a set field default must be `Set.new()`"); + } + + /// Refuses a collection default of the wrong shape: E0200 for a constant, + /// E1101 for anything that is not one, as for a scalar field. + fn refuseCollectionShape(self: *TypeChecker, value: NodeId, comptime message: []const u8) !void { + const span = self.arena.exprSpan(value); + const constant = switch (self.arena.exprKind(value)) { + .string_lit, .tag_path, .array_lit, .map_lit => true, + else => if (const_eval.fold(self.gpa, self.arena, value)) |_| true else |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.NotConstant => false, + else => true, + }, + }; + if (constant) { + try self.emit(.type_mismatch, .error_, span, message, .{}); + } else { + try self.emit(.not_const_evaluable, .error_, span, "field default value must be a constant expression (literal, arithmetic on literals, or parenthesized)", .{}); + } + } + + /// One element of a collection default against its declared type: a + /// constant (E1101 otherwise) of that type, or a known variant of that enum. + fn checkCollectionDefaultElement(self: *TypeChecker, e: NodeId, elem: CollectionElem) !void { + if (!try self.foldsAsConstant(e, "a collection default element must be a constant expression")) return; + const span = self.arena.exprSpan(e); + switch (elem) { + .enum_ => |ename| { + if (self.arena.exprKind(e) == .tag_path) { + _ = try self.checkEnumShorthand(e, ename); + } else try self.emit(.type_mismatch, .error_, span, "a '{s}' element must be a variant of that enum", .{self.arena.strings.slice(ename)}); + }, + .string => if (self.arena.exprKind(e) != .string_lit) { + try self.emit(.type_mismatch, .error_, span, "a string element must be a string literal", .{}); + }, + .builtin => |bt| { + const et = self.synthExpr(e, null); + const fits = et == .builtin and try self.literalTypeFits(bt, e, et.builtin); + if (!fits) try self.emit(.type_mismatch, .error_, span, "collection element type does not match the declared element type", .{}); + }, + } } /// `true` if `name` is a declared `enum`, checked against the AST slab @@ -4114,6 +4211,12 @@ pub const TypeChecker = struct { fn checkFieldDefault(self: *TypeChecker, value: NodeId, type_node: NodeId) !void { if (!try self.foldsAsConstant(value, "field default value must be a constant expression (literal, arithmetic on literals, or parenthesized)")) return; const declared = self.namedTypeToResolved(type_node); + if (declared == .enum_t) { + if (self.arena.exprKind(value) == .tag_path) { + _ = try self.checkEnumShorthand(value, declared.enum_t); + } else try self.emit(.type_mismatch, .error_, self.arena.exprSpan(value), "an enum field default must be a variant of that enum", .{}); + return; + } const actual = self.synthExpr(value, null); if (declared == .builtin and actual == .builtin) { if (!try self.literalTypeFits(declared.builtin, value, actual.builtin)) { @@ -8416,6 +8519,15 @@ pub const TypeChecker = struct { // ─── Helpers reachable from tests ─────────────────────────────────────── +/// Whether the expression at `id` is `Set.new()`, the empty set. +pub fn isEmptySetConstructor(arena: *const AstArena, id: NodeId) bool { + if (arena.exprKind(id) != .method_call) return false; + const mc = arena.method_calls.items[arena.exprData(id)]; + return arena.exprKind(mc.receiver) == .path and + std.mem.eql(u8, arena.strings.slice(arena.exprData(mc.receiver)), "Set") and + std.mem.eql(u8, arena.strings.slice(mc.method_name), "new") and mc.args_len == 0; +} + /// Return `true` if the expression at `id` can be folded to a value /// at type-check time (literals + arithmetic/comparison/logic on /// const-evaluable operands). Drives the `component` default-value diff --git a/tests/etch/collection_default_test.zig b/tests/etch/collection_default_test.zig new file mode 100644 index 00000000..2390fc75 --- /dev/null +++ b/tests/etch/collection_default_test.zig @@ -0,0 +1,132 @@ +//! A resource collection field's default is checked against the field's +//! element type, as a scalar field's default is against its type. + +const std = @import("std"); +const weld_etch = @import("weld_etch"); + +/// The primary messages of checking `src`. +fn messages(gpa: std.mem.Allocator, src: []const u8, out: *std.ArrayListUnmanaged([]const u8)) !void { + var pr = try weld_etch.parseSource(gpa, src); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(weld_etch.Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try weld_etch.typeCheck(gpa, &pr.ast, &diags); + for (diags.items) |d| try out.append(gpa, try gpa.dupe(u8, d.primary_message)); +} + +/// Whether checking `src` reports exactly one diagnostic, and it holds `needle`. +fn reportsOnly(src: []const u8, needle: []const u8) !bool { + const gpa = std.testing.allocator; + var msgs: std.ArrayListUnmanaged([]const u8) = .empty; + defer { + for (msgs.items) |m| gpa.free(m); + msgs.deinit(gpa); + } + try messages(gpa, src, &msgs); + for (msgs.items) |m| std.debug.print("diagnostic: {s}\n", .{m}); + return msgs.items.len == 1 and std.mem.indexOf(u8, msgs.items[0], needle) != null; +} + +const mode_enum = "enum Mode { a, b }\n"; + +test "a scalar default on an array field is refused" { + try std.testing.expect(try reportsOnly("resource R { xs: int[] = 5 }", "an array field default must be an array literal")); +} + +test "a string default on an array field is refused" { + try std.testing.expect(try reportsOnly("resource R { xs: int[] = \"no\" }", "an array field default must be an array literal")); +} + +test "a map literal default on an array field is refused" { + try std.testing.expect(try reportsOnly("resource R { xs: int[] = [:] }", "an array field default must be an array literal")); +} + +test "an array literal default on a map field is refused" { + try std.testing.expect(try reportsOnly("resource R { m: [string: int] = [] }", "a map field default must be a map literal")); +} + +test "an array literal default on a set field is refused" { + try std.testing.expect(try reportsOnly("resource R { s: Set = [] }", "a set field default must be `Set.new()`")); +} + +test "a set built from an array is not a constant default" { + try std.testing.expect(try reportsOnly("resource R { s: Set = Set.from([1]) }", "field default value must be a constant expression")); +} + +test "an array element of another type is refused" { + try std.testing.expect(try reportsOnly("resource R { xs: int[] = [\"a\"] }", "collection element type does not match the declared element type")); +} + +test "a string array element that is not a string literal is refused" { + try std.testing.expect(try reportsOnly("resource R { xs: string[] = [1] }", "a string element must be a string literal")); +} + +test "an array element outside its i32 element is refused" { + try std.testing.expect(try reportsOnly("resource R { xs: i32[] = [3000000000] }", "literal 3000000000 does not fit in i32")); +} + +test "a map value of another type is refused" { + try std.testing.expect(try reportsOnly("resource R { m: [string: int] = [\"a\": \"b\"] }", "collection element type does not match the declared element type")); +} + +test "an unknown variant in an enum array default is refused" { + try std.testing.expect(try reportsOnly(mode_enum ++ "resource R { xs: Mode[] = [.nope] }", "enum 'Mode' has no variant 'nope'")); +} + +test "an element that is not a constant is refused" { + try std.testing.expect(try reportsOnly("resource R { xs: int[] = [1 / 0] }", "the constant divides by zero")); +} + +test "a default on an invalid element type is not checked a second time" { + try std.testing.expect(try reportsOnly( + \\component Health { hp: int = 0 } + \\resource R { xs: Health[] = [1] } + , "resource collection element type 'Health' is not supported")); +} + +test "a Vec3 element is refused" { + try std.testing.expect(try reportsOnly("resource R { xs: Vec3[] }", "resource collection element type 'Vec3' is not supported")); +} + +test "a float map key is refused by the Hash bound" { + try std.testing.expect(try reportsOnly("resource R { m: [float: int] }", "map key type does not satisfy the 'K: Hash' bound")); +} + +test "a float set element is refused by the Hash bound" { + try std.testing.expect(try reportsOnly("resource R { s: Set }", "set element type does not satisfy the 'T: Hash' bound")); +} + +test "an unknown variant as an enum field default is refused" { + try std.testing.expect(try reportsOnly(mode_enum ++ "resource R { m: Mode = .nope }", "enum 'Mode' has no variant 'nope'")); +} + +test "a number as an enum field default is refused" { + try std.testing.expect(try reportsOnly(mode_enum ++ "resource R { m: Mode = 5 }", "an enum field default must be a variant of that enum")); +} + +test "well-typed collection defaults are accepted" { + const gpa = std.testing.allocator; + var msgs: std.ArrayListUnmanaged([]const u8) = .empty; + defer { + for (msgs.items) |m| gpa.free(m); + msgs.deinit(gpa); + } + try messages(gpa, mode_enum ++ + \\resource R { + \\ xs: int[] = [1, -2] + \\ fill: int[] = [7; 3] + \\ names: string[] = ["a"] + \\ modes: Mode[] = [.a, .b] + \\ m: [string: int] = ["a": 1] + \\ empty: [string: int] = [:] + \\ s: Set = Set.new() + \\ mode: Mode = .b + \\} + , &msgs); + for (msgs.items) |m| std.debug.print("diagnostic: {s}\n", .{m}); + try std.testing.expectEqual(@as(usize, 0), msgs.items.len); +} diff --git a/tests/scene/cook_errors_test.zig b/tests/scene/cook_errors_test.zig index 3bf7e441..d8feb5fc 100644 --- a/tests/scene/cook_errors_test.zig +++ b/tests/scene/cook_errors_test.zig @@ -117,3 +117,12 @@ test "a component collection field is rejected" { \\} ); } + +test "a resource with a collection field is rejected" { + try expectCookError(error.UnsupportedFieldKind, + \\resource R { xs: int[] = [1] } + \\scene "S" { + \\ resources { R { } } + \\} + ); +} diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 3f84b91b..4d359ca3 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -237,8 +237,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2493, - else => 2495, + .windows => 2517, + else => 2519, }; } From 397b094306da958a4411be28bd66745d65234e94 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 23 Sep 2026 15:22:51 +0200 Subject: [PATCH 022/141] test(etch): one verdict per collection default refusal The five unchecked refusals shared one test, so a mutation of one form was hidden behind the first source that still refused. The no-cascade witness now uses a default the element check would refuse if it ran. Floor 2519 -> 2523 / 2521, measured: 2504/2523 passed, 19 skipped. Co-Authored-By: Claude Opus 5.5 --- src/etch/interp.zig | 48 +++++++++++++++----------- tests/etch/collection_default_test.zig | 2 +- tools/weld_lint/dead_tests.zig | 4 +-- 3 files changed, 30 insertions(+), 24 deletions(-) diff --git a/src/etch/interp.zig b/src/etch/interp.zig index 1884f3b5..2456b469 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -16682,26 +16682,32 @@ test "a map default keeps the last value of a repeated key" { try std.testing.expectEqual(@as(i64, 2), map.items[0].value.int_); } -test "a collection default of the wrong shape is refused at compile" { +/// Asserts compiling `src`, unchecked, is refused as an invalid program. +fn expectInvalidProgram(src: []const u8) !void { const gpa = std.testing.allocator; - const sources = [_][]const u8{ - "resource R { xs: int[] = 5 }", - "resource R { m: [string: int] = [] }", - "resource R { s: Set = [1] }", - "resource R { xs: int[] = [1 / 0] }", - "enum Mode { a }\nresource R { m: Mode = .nope }", - }; - for (sources) |src| { - var world = World.init(); - defer world.deinit(gpa); - var pr = try parser_mod.parse(gpa, src); - defer pr.deinit(gpa); - var interp = compileUnchecked(gpa, &pr, &world) catch |err| { - try std.testing.expectEqual(error.InvalidProgram, err); - continue; - }; - interp.deinit(); - std.debug.print("compiled: {s}\n", .{src}); - return error.TestExpectedError; - } + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, src); + defer pr.deinit(gpa); + try std.testing.expectError(error.InvalidProgram, compileUnchecked(gpa, &pr, &world)); +} + +test "an array field default that is not an array literal is refused at compile" { + try expectInvalidProgram("resource R { xs: int[] = 5 }"); +} + +test "a map field default that is not a map literal is refused at compile" { + try expectInvalidProgram("resource R { m: [string: int] = [] }"); +} + +test "a set field default other than Set.new() is refused at compile" { + try expectInvalidProgram("resource R { s: Set = [1] }"); +} + +test "a collection element that does not fold is refused at compile" { + try expectInvalidProgram("resource R { xs: int[] = [1 / 0] }"); +} + +test "an enum field default that names no variant is refused at compile" { + try expectInvalidProgram("enum Mode { a }\nresource R { m: Mode = .nope }"); } diff --git a/tests/etch/collection_default_test.zig b/tests/etch/collection_default_test.zig index 2390fc75..6e1f471e 100644 --- a/tests/etch/collection_default_test.zig +++ b/tests/etch/collection_default_test.zig @@ -84,7 +84,7 @@ test "an element that is not a constant is refused" { test "a default on an invalid element type is not checked a second time" { try std.testing.expect(try reportsOnly( \\component Health { hp: int = 0 } - \\resource R { xs: Health[] = [1] } + \\resource R { xs: Health[] = ["x"] } , "resource collection element type 'Health' is not supported")); } diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 4d359ca3..ea547303 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -237,8 +237,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2517, - else => 2519, + .windows => 2521, + else => 2523, }; } From f877cfb05e4ab893212208f920f422a2c011f6ef Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 23 Sep 2026 15:43:35 +0200 Subject: [PATCH 023/141] fix(ecs): refuse a resource as a requisite, on every path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item 7 of S5/G8 ter. A resource is no entity column, so `@requires` naming one is refused (engine-ecs-internals §3). The registry had no notion of the difference: a Zig component requiring `GameTime` resolved at the first compile, and the lock that pinned it now pins the refusal. The registry records a kind (`ComponentDesc.kind`, `.component` by default, an addition that moves no protocol version). A closure whose requisite is a resource is `RequisiteIsResource`, and a resource carrying requisites `RequiresOnResource`, both from `closeOne`, which every closure computation reaches. `World.addResource` records the kind and refuses an id a closure already requires, so a host making a type a resource after registering it is refused in either order. The interpreter declares the kind of every type it registers, builtin time resources included. Adjacents on the same paths: - The scene cook maps the two new refusals, its registry being its only `@requires` guard. - The scene loader refuses an archetype naming a resource and a resource section naming a component, which a crafted scene could do: the component was installed as a resource. - The codegen refuses `@requires` (`RequiresUnsupported`), which it dropped: its `register()` carries no requisite and resolves no closure. - A reload that changes a type's kind or its `@requires` is refused as a schema change, where it was accepted and never applied; a changed storage mode stays accepted and unmigrated, per §13. - A builtin resource name held by another type is refused, where the interpreter mapped any holder as the resource. - E0506 names a resource as one. Floor 2523 -> 2542 / 2540, measured: 2523/2542 passed, 19 skipped. Co-Authored-By: Claude Opus 5.5 --- build.zig | 2 + src/core/ecs/registry.zig | 53 ++++++++++++- src/core/ecs/root.zig | 2 + src/core/ecs/world.zig | 2 + src/core/scene/loader.zig | 57 +++++++++++++- src/etch/interp.zig | 70 ++++++++++++++--- src/etch/scene_cook.zig | 10 ++- src/etch/types.zig | 14 ++-- src/etch/zig_codegen/errors.zig | 8 +- src/etch/zig_codegen/lower.zig | 3 + tests/ecs/hybrid_query_test.zig | 26 +++---- tests/ecs/requires_test.zig | 106 ++++++++++++++++++++++++++ tests/etch/hot_reload_test.zig | 74 +++++++++++++++++- tests/etch/requires_resource_test.zig | 89 +++++++++++++++++++++ tests/scene/load_resources_test.zig | 2 + tools/weld_lint/dead_tests.zig | 4 +- 16 files changed, 480 insertions(+), 42 deletions(-) create mode 100644 tests/etch/requires_resource_test.zig diff --git a/build.zig b/build.zig index 9e860140..49c50ffc 100644 --- a/build.zig +++ b/build.zig @@ -972,6 +972,8 @@ pub fn build(b: *std.Build) void { // A resource collection field's default is checked against its // element type. .{ .path = "tests/etch/collection_default_test.zig", .etch = true, .dedicated_step = "test-collection-default" }, + // `@requires` naming a resource is refused on every path. + .{ .path = "tests/etch/requires_resource_test.zig", .etch = true, .dedicated_step = "test-requires-resource" }, // one test per type-checker diagnostic code: each names its code and // asserts PRESENCE, so it reddens the day emission stops. .{ .path = "tests/etch/diagnostic_coverage_test.zig", .etch = true }, diff --git a/src/core/ecs/registry.zig b/src/core/ecs/registry.zig index cadc8e7c..a65a8c95 100644 --- a/src/core/ecs/registry.zig +++ b/src/core/ecs/registry.zig @@ -55,6 +55,11 @@ pub const StorageKind = enum { } }; +/// Whether a registered type is attached to entities or held once by the world. +/// A resource is no entity column, so it is neither a requisite nor a requirer +/// (`engine-ecs-internals.md` §3). +pub const TypeKind = enum { component, resource }; + /// Coarse-grained tag for primitive fields, telling the interpreter how to read /// or write raw bytes. The Etch subset exercises only `int_`, `float_`, `bool_`. /// @@ -183,6 +188,9 @@ pub const ComponentDesc = struct { /// Identity the other inputs of `schemaDigestOf` cannot express: /// `TagTable.contentDigest` on the builtin `TagSet`, `0` everywhere else. content_digest: u64 = 0, + /// Component or resource. A host that makes an id a resource through + /// `World.addResource` records the kind there. + kind: TypeKind = .component, }; /// The 64-bit schema identity of `desc` (`engine-ecs-internals.md` §13), over @@ -333,6 +341,14 @@ pub const PreparedEntry = struct { return self.entry.desc.size; } + pub fn kind(self: *const PreparedEntry) TypeKind { + return self.entry.desc.kind; + } + + pub fn requires(self: *const PreparedEntry) []const []const u8 { + return self.entry.desc.requires; + } + pub fn alignment(self: *const PreparedEntry) u16 { return self.entry.desc.alignment; } @@ -455,6 +471,7 @@ pub const Registry = struct { .storage = desc.storage, .requires = requires_owned, .content_digest = desc.content_digest, + .kind = desc.kind, }, .schema_digest = schemaDigestOf(desc), .owned_blocks = blocks_owned, @@ -548,6 +565,9 @@ pub const Registry = struct { /// diamond (`A requires B, C`; `B requires D`; `C requires D`), and a /// diamond is legal. /// + /// A requisite that is a resource, and a resource with requisites, are + /// errors too: a resource is no entity column. + /// /// On error every closure keeps its previous value. pub fn finalizeRequires(self: *Registry, gpa: std.mem.Allocator) !void { const staged = try self.stageClosures(gpa, &.{}); @@ -562,7 +582,7 @@ pub const Registry = struct { self: *const Registry, gpa: std.mem.Allocator, pending: []const PreparedEntry, - ) error{ OutOfMemory, RequiresCycle, UnknownRequisite }!StagedClosures { + ) error{ OutOfMemory, RequiresCycle, UnknownRequisite, RequisiteIsResource, RequiresOnResource }!StagedClosures { const graph: Graph = .{ .registry = self, .pending = pending }; const n = graph.count(); const out = try gpa.alloc([]const ComponentId, n); @@ -607,6 +627,12 @@ pub const Registry = struct { return g.pending[id - base].entry.desc.requires; } + fn kindOf(g: Graph, id: ComponentId) TypeKind { + const base = g.registry.entries.items.len; + if (id < base) return g.registry.entries.items[id].desc.kind; + return g.pending[id - base].entry.desc.kind; + } + fn idOf(g: Graph, name: []const u8) ?ComponentId { if (g.registry.by_name.get(name)) |id| return id; for (g.pending, 0..) |p, i| { @@ -622,12 +648,14 @@ pub const Registry = struct { if (colour[id] == .black) return; if (colour[id] == .grey) return error.RequiresCycle; colour[id] = .grey; + if (graph.kindOf(id) == .resource and graph.requiresOf(id).len != 0) return error.RequiresOnResource; var acc: std.ArrayListUnmanaged(ComponentId) = .empty; errdefer acc.deinit(gpa); for (graph.requiresOf(id)) |req_name| { const req = graph.idOf(req_name) orelse return error.UnknownRequisite; if (req == id) return error.RequiresCycle; + if (graph.kindOf(req) == .resource) return error.RequisiteIsResource; try closeOne(gpa, graph, req, colour, out); try appendUnique(gpa, &acc, req); for (out[req]) |t| try appendUnique(gpa, &acc, t); @@ -694,6 +722,29 @@ pub const Registry = struct { return self.entries.items[id].desc.storage; } + /// The direct `@requires` names `id` was registered with. + pub fn componentRequires(self: *const Registry, id: ComponentId) []const []const u8 { + return self.entries.items[id].desc.requires; + } + + /// Component or resource, as registered or as `markResource` set it. + pub fn componentKind(self: *const Registry, id: ComponentId) TypeKind { + return self.entries.items[id].desc.kind; + } + + /// Refuses making `id` a resource when a closure requires it or it has + /// requisites. Mutates nothing, so `markResource` may follow a later + /// fallible step. + pub fn checkResource(self: *const Registry, id: ComponentId) error{ RequisiteIsResource, RequiresOnResource }!void { + if (self.entries.items[id].desc.requires.len != 0) return error.RequiresOnResource; + for (self.entries.items) |e| for (e.closure) |t| if (t == id) return error.RequisiteIsResource; + } + + /// Records `id` as a resource. `checkResource` must have admitted it. + pub fn markResource(self: *Registry, id: ComponentId) void { + self.entries.items[id].desc.kind = .resource; + } + /// Lookup a field on a component by name. Returns `null` if the name /// is not declared. pub fn findField(self: *const Registry, id: ComponentId, field_name: []const u8) ?FieldDesc { diff --git a/src/core/ecs/root.zig b/src/core/ecs/root.zig index 1da89476..a7b188e1 100644 --- a/src/core/ecs/root.zig +++ b/src/core/ecs/root.zig @@ -98,6 +98,8 @@ pub const ComponentId = registry.ComponentId; /// validate `@storage`'s argument against the domain's single declaration /// instead of re-listing its spellings (`etch-resolver-types.md` §13.3.1). pub const StorageKind = registry.StorageKind; +/// Component or resource, as the registry records each type. +pub const TypeKind = registry.TypeKind; /// Stable archetype handle (index into `World.archetypes`). pub const ArchetypeId = world.ArchetypeId; diff --git a/src/core/ecs/world.zig b/src/core/ecs/world.zig index 639379e8..0533bc12 100644 --- a/src/core/ecs/world.zig +++ b/src/core/ecs/world.zig @@ -2395,7 +2395,9 @@ pub const World = struct { /// Add a resource. `init_bytes` is duplicated by the store. pub fn addResource(self: *World, gpa: std.mem.Allocator, id: ComponentId, init_bytes: []const u8) !void { + try self.registry.checkResource(id); try self.resources.addResource(gpa, id, init_bytes); + self.registry.markResource(id); } /// Tick boundary — reset resource dirty bits. Called once per tick diff --git a/src/core/scene/loader.zig b/src/core/scene/loader.zig index 0f7ac49c..9ba2bbc8 100644 --- a/src/core/scene/loader.zig +++ b/src/core/scene/loader.zig @@ -423,7 +423,11 @@ fn instantiate( // Per-block component ids (constant across the block's entities). const ids = try gpa.alloc(ComponentId, cc); defer gpa.free(ids); - for (0..cc) |c| ids[c] = remap[block.schemaIndex(c)]; + for (0..cc) |c| { + ids[c] = remap[block.schemaIndex(c)]; + // A resource is no entity column, whatever the file says. + if (world.registry.componentKind(ids[c]) == .resource) return error.SchemaMismatch; + } // Per-slot payload views, reused each slot. const payloads = try gpa.alloc([]const u8, cc); @@ -722,6 +726,9 @@ fn loadResources( while (i < count) : (i += 1) { const r = acc.resource(i); const cid = remap[r.schema_index]; + // Only a type registered as a resource is installed as one: a component + // named in the resource section is refused rather than turned into one. + if (world.registry.componentKind(cid) != .resource) return error.SchemaMismatch; // The loader reconstructs POD + interned `string` resource fields // only. A collection field (`.array_`/`.map_`/`.set_`) on disk is a zeroed @@ -871,6 +878,7 @@ fn registerStringResource(gpa: std.mem.Allocator, reg: *Registry, name: []const .fields = &[_]registry_mod.FieldDesc{ .{ .name = "v", .offset = 0, .kind = .string_ }, }, + .kind = .resource, }); } @@ -885,6 +893,7 @@ fn registerArrayResource(gpa: std.mem.Allocator, reg: *Registry, name: []const u .fields = &[_]registry_mod.FieldDesc{ .{ .name = "xs", .offset = 0, .kind = .array_ }, }, + .kind = .resource, }); } @@ -1660,3 +1669,49 @@ test "activateExtension rejects re-activation, including a hook-only extension ( try testing.expect(world.hasEntityExtension(e2, "HookOnly")); try testing.expectError(error.ExtensionAlreadyActive, activateExtension(&world, backing, e2, "HookOnly", hook_only)); } + +test "an archetype naming a type registered as a resource is refused" { + const gpa = testing.allocator; + var cook_reg = Registry.init(); + defer cook_reg.deinit(gpa); + const pos_cook = try registerRaw(gpa, &cook_reg, "Pos", 8, 4); + const bytes = try buildOneCompScene(gpa, &cook_reg, pos_cook); + defer gpa.free(bytes); + + var world = World.init(); + defer world.deinit(gpa); + _ = try world.registry.registerComponentRaw(gpa, .{ + .name = "Pos", + .size = 8, + .alignment = 4, + .default_bytes = &[_]u8{0} ** 8, + .fields = &.{}, + .kind = .resource, + }); + try testing.expectError(error.SchemaMismatch, loadFromBytes(&world, gpa, bytes, null)); + try testing.expectEqual(@as(usize, 0), world.entityCount()); +} + +test "a resource section naming a type registered as a component is refused" { + const gpa = testing.allocator; + var cook_reg = Registry.init(); + defer cook_reg.deinit(gpa); + const res = try registerStringResource(gpa, &cook_reg, "Settings"); + const bytes = try buildStringResourceScene(gpa, &cook_reg, res, "x"); + defer gpa.free(bytes); + + var world = World.init(); + defer world.deinit(gpa); + const cid = try world.registry.registerComponentRaw(gpa, .{ + .name = "Settings", + .size = 16, + .alignment = 8, + .default_bytes = &[_]u8{0} ** 16, + .fields = &[_]registry_mod.FieldDesc{ + .{ .name = "v", .offset = 0, .kind = .string_ }, + }, + }); + try testing.expectError(error.SchemaMismatch, loadFromBytes(&world, gpa, bytes, null)); + try testing.expect(!world.resources.contains(cid)); + try testing.expectEqual(registry_mod.TypeKind.component, world.registry.componentKind(cid)); +} diff --git a/src/etch/interp.zig b/src/etch/interp.zig index 2456b469..df27f490 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -42,6 +42,7 @@ const ComponentId = weld_core.ecs.registry.ComponentId; /// Storage backend recorded per component at registration — `table | sparse`, /// default `table` (`engine-ecs-internals.md` §2). const StorageKind = weld_core.ecs.registry.StorageKind; +const TypeKind = weld_core.ecs.registry.TypeKind; const ResourceStore = weld_core.ecs.resources.ResourceStore; const PreparedEntry = weld_core.ecs.registry.PreparedEntry; const StagedClosures = weld_core.ecs.registry.StagedClosures; @@ -7110,6 +7111,8 @@ fn stageDecl( .size = e.size(), .alignment = e.alignment(), .fields_len = e.fields().len, + .kind = e.kind(), + .requires = e.requires(), }); return mapName(gpa, bridge, shape.reg_kind, shape.name, staged); } @@ -7141,12 +7144,19 @@ fn stageTagSet( if (holder) |id| { const live: LiveLayout = if (id < pending.base_id) liveLayoutOf(&world.registry, id) else blk: { const e = pending.entryOf(id); - break :blk .{ .digest = e.schemaDigest(), .size = e.size(), .alignment = e.alignment(), .fields_len = e.fields().len }; + break :blk .{ + .digest = e.schemaDigest(), + .size = e.size(), + .alignment = e.alignment(), + .fields_len = e.fields().len, + .kind = e.kind(), + .requires = e.requires(), + }; }; // An absent digest refuses: an unknown layout is not a matching one. - if ((live.digest orelse ~candidate) != candidate) { + if (live.kind != .component or (live.digest orelse ~candidate) != candidate) { std.log.warn( - "etch/hot-reload: '" ++ tagset_name ++ "' changed layout or tag identity — reload REFUSED, " ++ + "etch/hot-reload: '" ++ tagset_name ++ "' changed layout, kind or tag identity — reload REFUSED, " ++ "previous image kept. live: size={d}; new: size={d} ({d} tag(s)). " ++ "An unchanged size means the tags themselves were renamed or reordered.", .{ live.size, size, tag_table.leaf_count }, @@ -7176,10 +7186,6 @@ fn stageBuiltinResource( pending: *PendingTypes, br: *const types_mod.BuiltinResource, ) !void { - if (world.registry.idOf(br.name) orelse pending.idOf(br.name)) |id| { - return bridge.mapResource(gpa, br.name, id); - } - if (world.resources.contains(pending.nextId())) return error.DuplicateResource; var fields_buf: [8]FieldDesc = undefined; var default_buf: [64]u8 = @splat(0); var size: usize = 0; @@ -7204,13 +7210,26 @@ fn stageBuiltinResource( try bridge_mod.writeValueAsBytes(kind, default_buf[off..], v); } size = std.mem.alignForward(usize, size, max_align); - var entry = try world.registry.prepareEntry(gpa, .{ + const desc: weld_core.ecs.registry.ComponentDesc = .{ .name = br.name, .size = @intCast(size), .alignment = @intCast(max_align), .default_bytes = default_buf[0..size], .fields = fields_buf[0..br.fields.len], - }); + .kind = .resource, + }; + // The name may already be held; the holder must be this resource. + if (world.registry.idOf(br.name)) |id| { + if (world.registry.componentKind(id) != .resource or world.registry.schemaDigest(id) != weld_core.ecs.registry.schemaDigestOf(desc)) return error.SchemaChanged; + return bridge.mapResource(gpa, br.name, id); + } + if (pending.idOf(br.name)) |id| { + const e = pending.entryOf(id); + if (e.kind() != .resource or e.schemaDigest() != weld_core.ecs.registry.schemaDigestOf(desc)) return error.SchemaChanged; + return bridge.mapResource(gpa, br.name, id); + } + if (world.resources.contains(pending.nextId())) return error.DuplicateResource; + var entry = try world.registry.prepareEntry(gpa, desc); errdefer entry.deinit(gpa); var resource: PendingResource = .{ .buf = try ResourceStore.allocBuffer(gpa, entry.defaultBytes()), .collection_blocks = &.{} }; errdefer resource.deinit(gpa); @@ -7481,12 +7500,14 @@ fn tagSetDesc(size: u16, default_bytes: []const u8, content_digest: u64) weld_co }; } -/// The layout recorded for the type already holding a declaration's name. +/// What is recorded for the type already holding a declaration's name. const LiveLayout = struct { digest: ?u64, size: u16, alignment: u16, fields_len: usize, + kind: TypeKind, + requires: []const []const u8, }; fn liveLayoutOf(registry: *const Registry, id: ComponentId) LiveLayout { @@ -7495,12 +7516,38 @@ fn liveLayoutOf(registry: *const Registry, id: ComponentId) LiveLayout { .size = registry.componentSize(id), .alignment = registry.componentAlignment(id), .fields_len = registry.componentFields(id).len, + .kind = registry.componentKind(id), + .requires = registry.componentRequires(id), }; } +fn typeKindOf(reg_kind: RegKind) TypeKind { + return switch (reg_kind) { + .component => .component, + .resource => .resource, + }; +} + +/// Whether two `@requires` lists name the same types, in any order. +fn sameRequisites(a: []const []const u8, b: []const []const u8) bool { + if (a.len != b.len) return false; + outer: for (a) |x| { + for (b) |y| if (std.mem.eql(u8, x, y)) continue :outer; + return false; + } + return true; +} + /// Refuse `shape` with `error.SchemaChanged` unless its layout has `live`'s -/// digest. An absent digest refuses: an unknown layout is not a matching one. +/// digest and it keeps `live`'s kind and requisites, which a reload does not +/// apply. The storage mode is no part of the comparison: a changed mode is +/// neither refused nor migrated (`engine-ecs-internals.md` §13). An absent +/// digest refuses: an unknown layout is not a matching one. fn confrontLayout(gpa: std.mem.Allocator, ast: *const AstArena, shape: DeclShape, live: LiveLayout) !void { + if (live.kind != typeKindOf(shape.reg_kind) or !sameRequisites(live.requires, shape.requires)) { + std.log.warn("etch/hot-reload: '{s}' changed its kind or its @requires — reload REFUSED, previous image kept", .{shape.name}); + return error.SchemaChanged; + } var layout = try computeLayout(gpa, ast, shape.fields_start, shape.fields_len, shape.reg_kind); defer layout.deinit(gpa); const candidate = schemaDigestFor(shape.name, layout); @@ -7707,6 +7754,7 @@ fn prepareTypeEntry(gpa: std.mem.Allocator, ast: *const AstArena, registry: *con .fields = fields.items, .storage = shape.storage, .requires = shape.requires, + .kind = typeKindOf(shape.reg_kind), }); } diff --git a/src/etch/scene_cook.zig b/src/etch/scene_cook.zig index 06756f42..d85aa8a1 100644 --- a/src/etch/scene_cook.zig +++ b/src/etch/scene_cook.zig @@ -85,6 +85,8 @@ pub const CookError = error{ NonConstValue, /// A constant field value overflows, or does not fit its field's type. ValueOutOfRange, + /// `@requires` names a resource, or a resource carries `@requires`. + RequisiteIsResource, /// A value's type does not match the field's kind. TypeMismatch, /// A `uuid:`/`parent:` enum value referenced an unknown enum variant. @@ -372,15 +374,17 @@ const Builder = struct { /// declaration may name a component registered later — Etch admits forward /// references, and the descriptor carries NAMES for exactly that reason. fn finalizeDecls(self: *Builder, diag_out: ?*[]const u8) CookError!void { - // Every arm named, no `else`: `finalizeRequires` returns exactly three - // errors and an `else` here would widen `CookError` with whatever it - // grows next, which is the opposite of what a typed error set is for. + // Every arm named, no `else`: an `else` here would widen `CookError` + // with whatever `finalizeRequires` grows next, which is the opposite of + // what a typed error set is for. self.registry.finalizeRequires(self.gpa) catch |e| switch (e) { error.RequiresCycle => return fail(diag_out, error.RequiresCycle, "`@requires` closure contains a cycle"), // `UndeclaredType` and not a new member: an unknown requisite IS a // type the program never declared, which is exactly what that // member already means. error.UnknownRequisite => return fail(diag_out, error.UndeclaredType, "`@requires` names a component that does not exist"), + error.RequisiteIsResource => return fail(diag_out, error.RequisiteIsResource, "`@requires` names a resource, which no entity carries"), + error.RequiresOnResource => return fail(diag_out, error.RequisiteIsResource, "a resource carries `@requires`, which only a component can"), error.OutOfMemory => return error.OutOfMemory, }; } diff --git a/src/etch/types.zig b/src/etch/types.zig index 13def5d1..58a75556 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -5005,13 +5005,13 @@ pub const TypeChecker = struct { }; const req_name = self.arena.strings.slice(name_id); if (self.requisiteDecl(name_id) == null) { - try self.emit( - .unknown_requisite, - .error_, - annot.span, - "@requires names `{s}`, which is not a declared component", - .{req_name}, - ); + const is_resource = builtinResourceByName(req_name) != null or + (if (self.symbols.get(name_id)) |sym| sym.kind == .resource else false); + if (is_resource) { + try self.emit(.unknown_requisite, .error_, annot.span, "@requires names `{s}`, which is a resource: no entity carries one", .{req_name}); + } else { + try self.emit(.unknown_requisite, .error_, annot.span, "@requires names `{s}`, which is not a declared component", .{req_name}); + } return; } } diff --git a/src/etch/zig_codegen/errors.zig b/src/etch/zig_codegen/errors.zig index 82196e5f..1f92023b 100644 --- a/src/etch/zig_codegen/errors.zig +++ b/src/etch/zig_codegen/errors.zig @@ -1,6 +1,6 @@ //! Codegen error set: the closed `CodegenError` covering -//! `UnsupportedConstruct`, `SparseStorageUnsupported`, `NonPodComponent` and -//! `InternalCodegenBug`. +//! `UnsupportedConstruct`, `SparseStorageUnsupported`, `RequiresUnsupported`, +//! `NonPodComponent` and `InternalCodegenBug`. //! //! The codegen is fed an AST the two-pass type-checker has already accepted, so //! structural and POD violations should not reach here. They are surfaced as @@ -30,6 +30,10 @@ pub const CodegenError = error{ /// ECS image contradicts its own source with nothing to say so. Parity is /// unimplemented. SparseStorageUnsupported, + /// A `component` declaration carries `@requires`. The emitted `register()` + /// records no requisite and resolves no closure, so the cooked program would + /// add the component without the types its declaration requires. + RequiresUnsupported, /// A component declaration carries a non-POD field type. The type-checker /// rejects these; the variant exists so an un-type-checked AST surfaces a /// clean error instead of a panic. diff --git a/src/etch/zig_codegen/lower.zig b/src/etch/zig_codegen/lower.zig index 205254aa..461d2ef4 100644 --- a/src/etch/zig_codegen/lower.zig +++ b/src/etch/zig_codegen/lower.zig @@ -117,6 +117,9 @@ pub fn generateFile( if (types_mod.storageModeOf(ast, ast.component_decls.items[data]) != .table) { return CodegenError.SparseStorageUnsupported; } + const requisites = types_mod.requiresNamesOf(gpa, ast, ast.component_decls.items[data]) catch return CodegenError.OutOfMemory; + defer gpa.free(requisites); + if (requisites.len != 0) return CodegenError.RequiresUnsupported; try emitComponentLikeStruct(&w, ast, data, .component); stats.components += 1; }, diff --git a/tests/ecs/hybrid_query_test.zig b/tests/ecs/hybrid_query_test.zig index 87ec75d8..f76fcc90 100644 --- a/tests/ecs/hybrid_query_test.zig +++ b/tests/ecs/hybrid_query_test.zig @@ -251,23 +251,23 @@ const ecs_root_surface = [_][]const u8{ "hybrid_query", "archetype_dynamic", "resources", "comptime_query", "command_buffer", "observers", "World", "EntityId", "ComponentId", - "StorageKind", "ArchetypeId", "Tick", - "Transform", "Velocity", "Archetype", - "Chunk", "Location", "WorldError", - "Query", "With", "Without", - "Predicate", "Changed", "CommandBuffer", - "Command", "ObserverFn", "SystemScheduler", - "SystemDescriptor", "Phase", "FrameContext", - "SystemContext", "SystemFn", "Reads", - "Writes", "ReadsResource", "WritesResource", - "AccessDescriptor", "AccessKind", "JobBuilder", - "RegistrationError", "view", "View", - "Access", "SystemContextOf", + "StorageKind", "TypeKind", "ArchetypeId", + "Tick", "Transform", "Velocity", + "Archetype", "Chunk", "Location", + "WorldError", "Query", "With", + "Without", "Predicate", "Changed", + "CommandBuffer", "Command", "ObserverFn", + "SystemScheduler", "SystemDescriptor", "Phase", + "FrameContext", "SystemContext", "SystemFn", + "Reads", "Writes", "ReadsResource", + "WritesResource", "AccessDescriptor", "AccessKind", + "JobBuilder", "RegistrationError", "view", + "View", "Access", "SystemContextOf", // Deriving the ACCESSES alone from a spec, for a preflight that wants to // know what a declaration would conflict with before registering it. It is // public where `SystemDescriptor.of` is not, and the asymmetry is the // point: there is no `run` beside these for them to disagree with. - "descriptorsOf", + "descriptorsOf", }; /// Whether `name` appears in the enumerated surface. A comptime function diff --git a/tests/ecs/requires_test.zig b/tests/ecs/requires_test.zig index 98e7ebb9..128777ce 100644 --- a/tests/ecs/requires_test.zig +++ b/tests/ecs/requires_test.zig @@ -631,3 +631,109 @@ test "P4: an add with no closure and no listener allocates nothing of its own" { try testing.expectEqual(@as(usize, 10), Seen.n); try testing.expect(listener_ops > 0); } + +fn regKind(world: *World, gpa: std.mem.Allocator, name: []const u8, requires: []const []const u8, kind: ecs.TypeKind) !ComponentId { + return world.registry.registerComponentRaw(gpa, .{ + .name = name, + .size = 8, + .alignment = 8, + .default_bytes = &zero8, + .fields = &.{}, + .requires = requires, + .kind = kind, + }); +} + +test "a registration records the kind it declares" { + const gpa = testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + const r = try regKind(&world, gpa, "R", &.{}, .resource); + const c = try regKind(&world, gpa, "C", &.{}, .component); + try testing.expectEqual(ecs.TypeKind.resource, world.registry.componentKind(r)); + try testing.expectEqual(ecs.TypeKind.component, world.registry.componentKind(c)); +} + +test "a requisite that is a resource is refused" { + const gpa = testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + _ = try regKind(&world, gpa, "C", &.{"R"}, .component); + _ = try regKind(&world, gpa, "R", &.{}, .resource); + try testing.expectError(error.RequisiteIsResource, world.registry.finalizeRequires(gpa)); +} + +test "the same requisite registered as a component resolves" { + const gpa = testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + const c = try regKind(&world, gpa, "C", &.{"R"}, .component); + const r = try regKind(&world, gpa, "R", &.{}, .component); + try world.registry.finalizeRequires(gpa); + try testing.expect(world.registry.isRequiredBy(r, c)); +} + +test "a resource reached through a requisite's own requisite is refused" { + const gpa = testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + _ = try regKind(&world, gpa, "C", &.{"B"}, .component); + _ = try regKind(&world, gpa, "B", &.{"R"}, .component); + _ = try regKind(&world, gpa, "R", &.{}, .resource); + try testing.expectError(error.RequisiteIsResource, world.registry.finalizeRequires(gpa)); +} + +test "a refused finalization keeps the closures it had" { + const gpa = testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + const a = try regKind(&world, gpa, "A", &.{"B"}, .component); + const b = try regKind(&world, gpa, "B", &.{}, .component); + try world.registry.finalizeRequires(gpa); + _ = try regKind(&world, gpa, "C", &.{"R"}, .component); + _ = try regKind(&world, gpa, "R", &.{}, .resource); + try testing.expectError(error.RequisiteIsResource, world.registry.finalizeRequires(gpa)); + try testing.expect(world.registry.isRequiredBy(b, a)); +} + +test "a resource carrying requisites is refused" { + const gpa = testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + _ = try regKind(&world, gpa, "R", &.{"C"}, .resource); + _ = try regKind(&world, gpa, "C", &.{}, .component); + try testing.expectError(error.RequiresOnResource, world.registry.finalizeRequires(gpa)); +} + +test "adding as a resource a type a closure requires is refused, and changes nothing" { + const gpa = testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + _ = try regKind(&world, gpa, "C", &.{"R"}, .component); + const r = try regKind(&world, gpa, "R", &.{}, .component); + try world.registry.finalizeRequires(gpa); + try testing.expectError(error.RequisiteIsResource, world.addResource(gpa, r, &zero8)); + try testing.expect(!world.resources.contains(r)); + try testing.expectEqual(ecs.TypeKind.component, world.registry.componentKind(r)); +} + +test "a type added as a resource is refused as a later requisite" { + const gpa = testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + const r = try regKind(&world, gpa, "R", &.{}, .component); + try world.addResource(gpa, r, &zero8); + try testing.expectEqual(ecs.TypeKind.resource, world.registry.componentKind(r)); + _ = try regKind(&world, gpa, "C", &.{"R"}, .component); + try testing.expectError(error.RequisiteIsResource, world.registry.finalizeRequires(gpa)); +} + +test "adding as a resource a type carrying requisites is refused" { + const gpa = testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + const c = try regKind(&world, gpa, "C", &.{"B"}, .component); + _ = try regKind(&world, gpa, "B", &.{}, .component); + try world.registry.finalizeRequires(gpa); + try testing.expectError(error.RequiresOnResource, world.addResource(gpa, c, &zero8)); +} diff --git a/tests/etch/hot_reload_test.zig b/tests/etch/hot_reload_test.zig index 57aa069a..44e99e9c 100644 --- a/tests/etch/hot_reload_test.zig +++ b/tests/etch/hot_reload_test.zig @@ -759,8 +759,25 @@ test "a Zig requisite on TagSet resolves at the first compile" { try std.testing.expect(try zigRequisiteResolves(std.testing.allocator, weld_etch.types.tagset_component_name, "tags {\n a { t00 }\n}\n")); } -test "a Zig requisite on a builtin time resource resolves at the first compile" { - try std.testing.expect(try zigRequisiteResolves(std.testing.allocator, "GameTime", "component Plain { x: int = 0 }\n")); +test "a Zig requisite on a builtin time resource is refused at the first compile" { + const gpa = std.testing.allocator; + var pr = try weld_etch.parseSource(gpa, "component Plain { x: int = 0 }\n"); + defer pr.deinit(gpa); + try typeCheckClean(gpa, &pr.ast); + var world = World.init(); + defer world.deinit(gpa); + _ = try world.registry.registerComponentRaw(gpa, .{ + .name = "ZigRequirer", + .size = 4, + .alignment = 4, + .default_bytes = &[_]u8{0} ** 4, + .fields = &.{}, + .requires = &.{"GameTime"}, + }); + const before = world.registry.componentCount(); + try std.testing.expectError(error.RequisiteIsResource, Interpreter.compile(gpa, &pr.ast, &world)); + try std.testing.expectEqual(before, world.registry.componentCount()); + try std.testing.expect(world.registry.idOf("GameTime") == null); } test "every type a compile registers without the program declaring it has a reserved name" { @@ -783,3 +800,56 @@ test "every type a compile registers without the program declaring it has a rese } } } + +/// Source A with `Counter` a resource of the same layout. +const src_counter_resource = + \\resource Counter { value: int = 0 } +; + +/// Source A with `Counter` requiring a new component. +const src_requires_changed = + \\component Mark { m: int = 0 } + \\@requires(Mark) + \\component Counter { value: int = 0 } + \\rule tick(entity: Entity) + \\ when entity has Counter + \\{ + \\ entity.get_mut(Counter).value += 1 + \\} +; + +test "a reload that makes a component a resource is refused" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + try liveSessionAt3(gpa, &world); + try std.testing.expectError(error.SchemaChanged, reloadOn(gpa, &world, src_counter_resource)); + try std.testing.expectEqual(@as(i64, 3), readCounter(&world)); +} + +test "a reload that changes a component's @requires is refused" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + try liveSessionAt3(gpa, &world); + try std.testing.expectError(error.SchemaChanged, reloadOn(gpa, &world, src_requires_changed)); + try std.testing.expect(world.registry.idOf("Mark") == null); + try std.testing.expectEqual(@as(i64, 3), readCounter(&world)); +} + +test "a builtin resource name held by a component is refused at compile" { + const gpa = std.testing.allocator; + var pr = try weld_etch.parseSource(gpa, "component Plain { x: int = 0 }\n"); + defer pr.deinit(gpa); + try typeCheckClean(gpa, &pr.ast); + var world = World.init(); + defer world.deinit(gpa); + _ = try world.registry.registerComponentRaw(gpa, .{ + .name = "GameTime", + .size = 4, + .alignment = 4, + .default_bytes = &[_]u8{0} ** 4, + .fields = &.{}, + }); + try std.testing.expectError(error.SchemaChanged, Interpreter.compile(gpa, &pr.ast, &world)); +} diff --git a/tests/etch/requires_resource_test.zig b/tests/etch/requires_resource_test.zig new file mode 100644 index 00000000..8cb826a2 --- /dev/null +++ b/tests/etch/requires_resource_test.zig @@ -0,0 +1,89 @@ +//! `@requires` naming a resource is refused (`engine-ecs-internals.md` §3): by +//! the checker, by the registry when a program is compiled unchecked, by the +//! scene cook, and by the codegen, which carries no `@requires` at all. + +const std = @import("std"); +const weld_etch = @import("weld_etch"); +const weld_core = @import("weld_core"); + +const World = weld_core.ecs.world.World; +const Interpreter = weld_etch.Interpreter; + +/// Whether checking `src` reports a diagnostic whose message holds `needle`. +fn reports(src: []const u8, needle: []const u8) !bool { + const gpa = std.testing.allocator; + var pr = try weld_etch.parseSource(gpa, src); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(weld_etch.Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try weld_etch.typeCheck(gpa, &pr.ast, &diags); + for (diags.items) |d| { + if (std.mem.indexOf(u8, d.primary_message, needle) != null) return true; + } + return false; +} + +test "a declared resource named by @requires is refused by the checker" { + try std.testing.expect(try reports( + \\resource R { x: int = 0 } + \\@requires(R) + \\component C { y: int = 0 } + , "@requires names `R`, which is a resource")); +} + +test "a builtin resource named by @requires is refused by the checker" { + try std.testing.expect(try reports( + \\@requires(GameTime) + \\component C { y: int = 0 } + , "@requires names `GameTime`, which is a resource")); +} + +test "a host resource named by @requires is refused by an unchecked compile" { + const gpa = std.testing.allocator; + var pr = try weld_etch.parseSource(gpa, + \\@requires(HostRes) + \\component C { y: int = 0 } + ); + defer pr.deinit(gpa); + var world = World.init(); + defer world.deinit(gpa); + _ = try world.registry.registerComponentRaw(gpa, .{ + .name = "HostRes", + .size = 8, + .alignment = 8, + .default_bytes = &[_]u8{0} ** 8, + .fields = &.{}, + .kind = .resource, + }); + try std.testing.expectError(error.RequisiteIsResource, Interpreter.compile(gpa, &pr.ast, &world)); +} + +test "a scene whose component requires a resource is refused by the cook" { + var msg: []const u8 = ""; + try std.testing.expectError(error.RequisiteIsResource, weld_etch.scene_cook.cook(std.testing.allocator, + \\resource R { x: int = 0 } + \\@requires(R) + \\component C { y: int = 0 } + \\scene "S" { + \\ entity "E" { uuid: "7b3e2f1a-42a3-4f2b-8c9d-a3f2b1c98d4e" C { } } + \\} + , &msg)); + try std.testing.expect(msg.len > 0); +} + +test "the codegen refuses a component carrying @requires" { + const gpa = std.testing.allocator; + var pr = try weld_etch.parseSource(gpa, + \\component B { x: int = 0 } + \\@requires(B) + \\component C { y: int = 0 } + ); + defer pr.deinit(gpa); + var buf: std.ArrayListUnmanaged(u8) = .empty; + defer buf.deinit(gpa); + try std.testing.expectError(error.RequiresUnsupported, weld_etch.codegen_zig.lower.generateFile(gpa, &pr.ast, "requires_resource_test.etch", &buf)); +} diff --git a/tests/scene/load_resources_test.zig b/tests/scene/load_resources_test.zig index f67cc362..648538cb 100644 --- a/tests/scene/load_resources_test.zig +++ b/tests/scene/load_resources_test.zig @@ -26,6 +26,7 @@ test "resource string fields round-trip through the persistent heap" { .fields = &[_]registry.FieldDesc{ .{ .name = "title", .offset = 0, .kind = .string_ }, }, + .kind = .resource, }); const title_value = "Verdant Keep"; @@ -84,6 +85,7 @@ test "loader rejects a resource collection field (guard)" { .fields = &[_]registry.FieldDesc{ .{ .name = "items", .offset = 0, .kind = .array_ }, }, + .kind = .resource, }); var arena = std.heap.ArenaAllocator.init(gpa); diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index ea547303..4c61d6c1 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -237,8 +237,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2521, - else => 2523, + .windows => 2540, + else => 2542, }; } From 6a73304f463cf490ddaa86751164892c4d24eb97 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 23 Sep 2026 16:01:42 +0200 Subject: [PATCH 024/141] =?UTF-8?q?fix(etch):=20parse=20an=20annotation=20?= =?UTF-8?q?argument=20as=20=C2=A71.5=20writes=20it=20(S5/G8=20ter)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item 4 of S5/G8 ter. `annotation_arg = expression | IDENT ":" expression | IDENT`. The parser looked for a name by consuming a leading ident and, when no `:` followed, rebuilt an expression from it by hand, which skipped every form `parsePrimary` owns: `@tag(v as f32)` was refused, `@tag(none)` and `@tag(some(1))` became an ident and a call, and `@tag(Name: 1)` was refused because a capitalised name was not looked for. The argument is now a name by two-token lookahead, and otherwise an expression. Adjacents on the same production. The annotation name is an IDENT, as §1.5 writes it: a capitalised `@Unit(...)` was parsed and accepted as a custom annotation, and no source in the tree uses one. `@storage(none)` stays E0503, a value outside the domain, and not E0504, `none` being a constant. The event and structural observer readers refuse a named argument, as the tag and requisite readers already did: `@on_event(e: Hit)` was read as `@on_event(Hit)`. Floor 2542 -> 2553 / 2551, measured: 2534/2553 passed, 19 skipped. Co-Authored-By: Claude Opus 5.5 --- build.zig | 2 + src/etch/ast.zig | 2 + src/etch/parser.zig | 114 +++++++++++++++++++---------- src/etch/types.zig | 3 +- tests/etch/annotation_arg_test.zig | 66 +++++++++++++++++ tests/etch/storage_mode_test.zig | 9 +++ tools/weld_lint/dead_tests.zig | 4 +- 7 files changed, 158 insertions(+), 42 deletions(-) create mode 100644 tests/etch/annotation_arg_test.zig diff --git a/build.zig b/build.zig index 49c50ffc..88227a95 100644 --- a/build.zig +++ b/build.zig @@ -974,6 +974,8 @@ pub fn build(b: *std.Build) void { .{ .path = "tests/etch/collection_default_test.zig", .etch = true, .dedicated_step = "test-collection-default" }, // `@requires` naming a resource is refused on every path. .{ .path = "tests/etch/requires_resource_test.zig", .etch = true, .dedicated_step = "test-requires-resource" }, + // An annotation argument follows §1.5; positional readers refuse names. + .{ .path = "tests/etch/annotation_arg_test.zig", .etch = true, .dedicated_step = "test-annotation-arg" }, // one test per type-checker diagnostic code: each names its code and // asserts PRESENCE, so it reddens the day emission stops. .{ .path = "tests/etch/diagnostic_coverage_test.zig", .etch = true }, diff --git a/src/etch/ast.zig b/src/etch/ast.zig index 0df73433..9e0351d7 100644 --- a/src/etch/ast.zig +++ b/src/etch/ast.zig @@ -3953,6 +3953,7 @@ pub const AstArena = struct { pub fn onEventTypeName(self: *const AstArena, annot: Annotation) ?StringId { if (annot.args_len == 0) return null; const arg = self.annot_args.items[annot.args_start]; + if (arg.name != 0) return null; // a named argument is not the event type if (self.exprKind(arg.value) != .path) return null; return self.exprData(arg.value); } @@ -3979,6 +3980,7 @@ pub const AstArena = struct { pub fn observerComponentName(self: *const AstArena, annot: Annotation) ?StringId { if (annot.args_len == 0) return null; const arg = self.annot_args.items[annot.args_start]; + if (arg.name != 0) return null; // a named argument is not the component if (self.exprKind(arg.value) != .path) return null; return self.exprData(arg.value); } diff --git a/src/etch/parser.zig b/src/etch/parser.zig index 12fe2584..9dff3d78 100644 --- a/src/etch/parser.zig +++ b/src/etch/parser.zig @@ -929,10 +929,11 @@ pub const Parser = struct { const start: u32 = @intCast(self.arena.annot_pool.items.len); while (self.peek() == .at) { const at_tok = try self.advance(); - const name_tok = if (self.peek() == .ident or self.peek() == .type_ident) + // `annotation = "@" , IDENT` (`etch-grammar.md` §1.5). + const name_tok = if (self.peek() == .ident) try self.advance() else - return self.parseErr(self.peekSpan(), "expected annotation name after '@'"); + return self.parseErr(self.peekSpan(), "expected a lowercase annotation name after '@'"); const name_slice = self.sliceOf(name_tok.span); const name_id = try self.internSlice(name_tok.span); @@ -972,35 +973,16 @@ pub const Parser = struct { return .{ .start = start, .len = len }; } + /// `annotation_arg = expression | IDENT ":" expression | IDENT` + /// (`etch-grammar.md` §1.5). A name followed by `:` is a named argument; + /// anything else is a positional expression. fn parseAnnotationArg(self: *Parser) ParseError!ast_mod.AnnotationArg { - // Named arg if `ident ':' expr`. - if (self.peek() == .ident) { - // Lookahead: if next non-ident token is `:`, treat as named. - // The lexer's one-token lookahead is `self.current`; we have - // to commit to the ident and check the following token. - const saved = self.current; + if ((self.peek() == .ident or self.peek() == .type_ident) and self.peekNext() == .colon) { + const name = try self.advance(); _ = try self.advance(); - if (self.peek() == .colon) { - _ = try self.advance(); - const name_id = try self.internSlice(saved.span); - const value = try self.parseExpr(0); - return .{ .name = name_id, .value = value }; - } - // Not named: this was the start of a positional expression - // beginning with an ident. Build the expr starting from here - // by emitting an ident expr and continuing through Pratt. - const ident_id = try self.internSlice(saved.span); - const lhs = try self.arena.addExpr(self.gpa, .ident, ident_id, saved.span); - // Route through the postfix chain first so `@requires(self.health)` - // (ident + `.field`) parses; then the binary continuation - // (the annotation field-access rule). - const after_postfix = try self.continuePostfix(lhs); - const continued = try self.continuePostfixAndBinary(after_postfix, 0); - return .{ .name = 0, .value = continued }; - } - // Positional: bare expression. - const expr = try self.parseExpr(0); - return .{ .name = 0, .value = expr }; + return .{ .name = try self.internSlice(name.span), .value = try self.parseExpr(0) }; + } + return .{ .name = 0, .value = try self.parseExpr(0) }; } // ─── Component / Resource ─────────────────────────────────────────── @@ -6203,12 +6185,7 @@ pub const Parser = struct { } /// Continue a postfix `.field` / `.get(T)` / `.get_mut(T)` chain on an - /// already-parsed receiver. Extracted from `parsePostfix` so annotation - /// arguments that begin with an identifier (`@requires(self.health)`) - /// also pick up the postfix chain (the annotation field-access rule): the - /// named-arg lookahead in `parseAnnotationArg` consumes the leading - /// ident before the normal `parsePrimary` postfix path can run, so the - /// ident must be threaded back through this helper. + /// already-parsed receiver. fn continuePostfix(self: *Parser, expr_in: NodeId) ParseError!NodeId { var expr = expr_in; while (true) { @@ -7401,10 +7378,6 @@ test "doc comments and leading comments attach to top-level items" { test "annotation arg accepts a field access expression" { const gpa = std.testing.allocator; - // `@requires(self.health)` — annotation positional arg that is a field - // access. Pre-fix, the annotation-arg path built the ident then called - // the binary-only continuation, leaving `.health` unconsumed and - // surfacing "expected ')'". Postfix routing now parses it cleanly. var result = try parse(gpa, \\@requires(self.health) \\component Inventory { gold: int = 0 } @@ -7423,6 +7396,69 @@ test "annotation arg accepts a field access expression" { try std.testing.expectEqual(ast_mod.ExprKind.field_access, result.ast.exprKind(arg.value)); } +/// The single argument of `@tag()` on a component, parsed: its name and +/// the kind of its value. The caller owns `result`. +fn parseTagArg(gpa: std.mem.Allocator, arg_src: []const u8, result: *ParseResult) !ast_mod.AnnotationArg { + const src = try std.fmt.allocPrint(gpa, "@tag({s})\ncomponent C {{ x: int = 0 }}\n", .{arg_src}); + defer gpa.free(src); + result.* = try parse(gpa, src); + try std.testing.expectEqual(@as(usize, 0), result.diagnostics.len); + const cd = result.ast.component_decls.items[0]; + const annot = result.ast.annot_pool.items[cd.annotations_extra]; + try std.testing.expectEqual(@as(u32, 1), annot.args_len); + return result.ast.annot_args.items[annot.args_start]; +} + +test "an annotation argument may be a cast" { + const gpa = std.testing.allocator; + var result: ParseResult = undefined; + const arg = try parseTagArg(gpa, "v as f32", &result); + defer result.deinit(gpa); + try std.testing.expectEqual(ast_mod.ExprKind.cast, result.ast.exprKind(arg.value)); +} + +test "an annotation argument may be none" { + const gpa = std.testing.allocator; + var result: ParseResult = undefined; + const arg = try parseTagArg(gpa, "none", &result); + defer result.deinit(gpa); + try std.testing.expectEqual(ast_mod.ExprKind.none_lit, result.ast.exprKind(arg.value)); +} + +test "an annotation argument may be some(value)" { + const gpa = std.testing.allocator; + var result: ParseResult = undefined; + const arg = try parseTagArg(gpa, "some(1)", &result); + defer result.deinit(gpa); + try std.testing.expectEqual(ast_mod.ExprKind.some_lit, result.ast.exprKind(arg.value)); +} + +test "an annotation argument may be named by a capitalised name" { + const gpa = std.testing.allocator; + var result: ParseResult = undefined; + const arg = try parseTagArg(gpa, "Name: 1", &result); + defer result.deinit(gpa); + try std.testing.expectEqualStrings("Name", result.ast.strings.slice(arg.name)); + try std.testing.expectEqual(ast_mod.ExprKind.int_lit, result.ast.exprKind(arg.value)); +} + +test "an annotation argument named by a lowercase name keeps its name" { + const gpa = std.testing.allocator; + var result: ParseResult = undefined; + const arg = try parseTagArg(gpa, "reason: \"x\"", &result); + defer result.deinit(gpa); + try std.testing.expectEqualStrings("reason", result.ast.strings.slice(arg.name)); + try std.testing.expectEqual(ast_mod.ExprKind.string_lit, result.ast.exprKind(arg.value)); +} + +test "an annotation name is an IDENT, never a TYPE_IDENT" { + const gpa = std.testing.allocator; + var result = try parse(gpa, "@Unit(.meters)\ncomponent C { x: int = 0 }\n"); + defer result.deinit(gpa); + try std.testing.expect(result.diagnostics.len > 0); + try std.testing.expect(std.mem.indexOf(u8, result.diagnostics[0].primary_message, "lowercase annotation name") != null); +} + test "parser builds array literals, fill, and index/slice access (collections)" { const gpa = std.testing.allocator; var result = try parse(gpa, diff --git a/src/etch/types.zig b/src/etch/types.zig index 58a75556..7d815a1c 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -8535,7 +8535,8 @@ pub fn isEmptySetConstructor(arena: *const AstArena, id: NodeId) bool { pub fn isConstEvaluable(arena: *const AstArena, id: NodeId) bool { const kind = arena.exprKind(id); return switch (kind) { - .int_lit, .float_lit, .bool_lit, .string_lit, .tag_path => true, + .int_lit, .float_lit, .bool_lit, .string_lit, .tag_path, .none_lit => true, + .some_lit => isConstEvaluable(arena, @bitCast(arena.exprData(id))), .binary => blk: { const bin = arena.binary_exprs.items[arena.exprData(id)]; // Arithmetic / comparison / logic on const-evaluable args is OK. diff --git a/tests/etch/annotation_arg_test.zig b/tests/etch/annotation_arg_test.zig new file mode 100644 index 00000000..01eed0c0 --- /dev/null +++ b/tests/etch/annotation_arg_test.zig @@ -0,0 +1,66 @@ +//! An annotation argument follows `etch-grammar.md` §1.5, and a reader that +//! takes a positional argument refuses a named one. + +const std = @import("std"); +const weld_etch = @import("weld_etch"); + +/// The diagnostic codes of checking `src`, which must parse clean. +fn codesOf(gpa: std.mem.Allocator, src: []const u8, out: *std.ArrayListUnmanaged([]const u8)) !void { + var pr = try weld_etch.parseSource(gpa, src); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(weld_etch.Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try weld_etch.typeCheck(gpa, &pr.ast, &diags); + for (diags.items) |d| try out.append(gpa, try gpa.dupe(u8, d.code.code())); +} + +/// Whether checking `src` reports `code`. +fn reportsCode(src: []const u8, code: []const u8) !bool { + const gpa = std.testing.allocator; + var codes: std.ArrayListUnmanaged([]const u8) = .empty; + defer { + for (codes.items) |c| gpa.free(c); + codes.deinit(gpa); + } + try codesOf(gpa, src, &codes); + for (codes.items) |c| { + if (std.mem.eql(u8, c, code)) return true; + } + return false; +} + +test "an event observer named argument is refused" { + try std.testing.expect(try reportsCode( + \\event Hit { amount: int = 0 } + \\@on_event(e: Hit) + \\rule r() {} + , "E1203")); +} + +test "a positional event observer argument is accepted" { + try std.testing.expect(!try reportsCode( + \\event Hit { amount: int = 0 } + \\@on_event(Hit) + \\rule r() {} + , "E1203")); +} + +test "a structural observer named argument is refused" { + try std.testing.expect(try reportsCode( + \\component Health { hp: int = 0 } + \\@on_added(c: Health) + \\rule r(entity: Entity, value: Health) {} + , "E1209")); +} + +test "a positional structural observer argument is accepted" { + try std.testing.expect(!try reportsCode( + \\component Health { hp: int = 0 } + \\@on_added(Health) + \\rule r(entity: Entity, value: Health) {} + , "E1209")); +} diff --git a/tests/etch/storage_mode_test.zig b/tests/etch/storage_mode_test.zig index 20b6da54..403608d6 100644 --- a/tests/etch/storage_mode_test.zig +++ b/tests/etch/storage_mode_test.zig @@ -915,6 +915,15 @@ fn freeCodes(gpa: std.mem.Allocator, list: *std.ArrayListUnmanaged([]const u8)) list.deinit(gpa); } +test "@storage(none) is a value outside the domain, not a non-constant" { + const gpa = std.testing.allocator; + var codes: std.ArrayListUnmanaged([]const u8) = .empty; + defer freeCodes(gpa, &codes); + try diagCodes(gpa, "@storage(none)\ncomponent C { x: int = 0 }", &codes); + try std.testing.expectEqual(@as(usize, 1), codes.items.len); + try std.testing.expectEqualStrings("E0503", codes.items[0]); +} + // `E1216` IS RETIRED, AND THIS FAMILY IS ITS RECORD. // // A STATIC refusal of a dead `@requires` removal refused CORRECT CODE five diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 4c61d6c1..0553b5bb 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -237,8 +237,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2540, - else => 2542, + .windows => 2551, + else => 2553, }; } From d1efad767797db7c5167df87fc98d05fd0995770 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 23 Sep 2026 16:02:46 +0200 Subject: [PATCH 025/141] =?UTF-8?q?fix(assets):=20read=20an=20asset=20file?= =?UTF-8?q?=20as=20=C2=A721.4=20and=20=C2=A73=20write=20it=20(S5/G8=20ter)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item 6 of S5/G8 ter. `asset_field = [ annotation ] , IDENT , ":" , asset_value , [ "," ]`, in a file the user edits by hand: the reader refused the comma between fields, the one form the ruling names, and much of the rest of the grammar. It now takes: - the comma between fields; - a field annotation, parsed and not kept (the model carries none); - comments; - the six string escapes, decoded, with any other refused; - triple-quoted strings with their common indentation removed; - `_` digit separators where the lexer admits them. It refuses what the grammar does not produce: a `+` sign, an exponent, `1.`, a float that is not finite, an unescaped `{`, a raw newline in a simple string, a boolean literal as a field name, and content after the construct. The §3 schema is enforced where it was defaulted or ignored: each fixed field and block present once and of its kind, no other top-level field, `type` an identifier, and `uuid`, `source_hash` and `extracted.blob` in canonical form (`SchemaViolation`, a new variant). The writer escapes what the reader decodes, so a string holding a quote, a backslash, a brace or a line break round-trips. The glTF importer refuses a non-finite position or bound, which the writer had no literal for and emitted as `nan` or `inf`. `asset_cook` minted a fresh UUID for any intermediate the reader refused, against §3's "Jamais régénérée"; `existingUuid` makes an unreadable intermediate an error. Measured end to end: a second cook keeps all three UUIDs, a corrupted intermediate fails the cook. Two fixtures change value to satisfy the schema: the round-trip hashes and the float test's empty `uuid` and missing blob. Floor 2553 -> 2573 / 2571, measured: 2554/2573 passed, 19 skipped. Co-Authored-By: Claude Opus 5.5 --- .../asset_pipeline/codecs/gltf/decode.zig | 3 + .../asset_pipeline/format/intermediate.zig | 578 ++++++++++++++---- tools/asset_cook/main.zig | 17 +- tools/weld_lint/dead_tests.zig | 4 +- 4 files changed, 488 insertions(+), 114 deletions(-) diff --git a/src/modules/asset_pipeline/codecs/gltf/decode.zig b/src/modules/asset_pipeline/codecs/gltf/decode.zig index 8334e178..e2e45e59 100644 --- a/src/modules/asset_pipeline/codecs/gltf/decode.zig +++ b/src/modules/asset_pipeline/codecs/gltf/decode.zig @@ -127,9 +127,12 @@ pub fn decode(gpa: std.mem.Allocator, src: []const u8) Error!Mesh { try sequentialIndices(gpa, vertex_count); errdefer gpa.free(indices); + // The intermediate document has no literal for a non-finite float. + for (positions) |v| if (!std.math.isFinite(v)) return error.MalformedGltf; var bmin: [3]f32 = .{ 0, 0, 0 }; var bmax: [3]f32 = .{ 0, 0, 0 }; computeBounds(try accessorAt(doc, pos_index), positions, &bmin, &bmax); + for (bmin, bmax) |lo, hi| if (!std.math.isFinite(lo) or !std.math.isFinite(hi)) return error.MalformedGltf; return .{ .positions = positions, diff --git a/src/modules/asset_pipeline/format/intermediate.zig b/src/modules/asset_pipeline/format/intermediate.zig index 8c8a7ca4..53dfe9b5 100644 --- a/src/modules/asset_pipeline/format/intermediate.zig +++ b/src/modules/asset_pipeline/format/intermediate.zig @@ -17,9 +17,11 @@ //! grammar) is frozen; block *contents* are open per asset category. //! //! This ad-hoc reader/writer avoids a `weld_etch` dependency (the -//! full Etch parser lives elsewhere). It covers exactly the §21.4 value grammar -//! minus `@unit(...)` annotations, which the writer does not emit -//! (adding them is additive). The on-disk text is the frozen contract, not this +//! full Etch parser lives elsewhere). The reader takes the §21.4 grammar with +//! the Etch lexical rules of §1 — comments, the string escapes and triple-quoted +//! strings, `_` digit separators — and the §3 schema. A field annotation +//! (`@unit(...)`) is parsed and not kept: the document model carries none, and +//! the writer emits none. The on-disk text is the frozen contract, not this //! reader implementation. //! //! Ownership: `parseEtch` allocates every string/array/object into the @@ -180,12 +182,17 @@ pub const WriteError = std.Io.Writer.Error; /// Serialize `doc` as `.asset.etch` text into `out`. pub fn writeEtch(doc: AssetDoc, out: *std.Io.Writer) WriteError!void { - try out.print("asset \"{s}\" {{\n", .{doc.name}); - try out.print(" uuid: \"{s}\"\n", .{doc.uuid}); - try out.print(" type: {s}\n", .{doc.type_name}); + try out.writeAll("asset "); + try writeString(out, doc.name); + try out.writeAll(" {\n uuid: "); + try writeString(out, doc.uuid); + try out.print("\n type: {s}\n", .{doc.type_name}); try out.print(" version: {d}\n", .{doc.version}); - try out.print(" source: \"{s}\"\n", .{doc.source}); - try out.print(" source_hash: \"{s}\"\n", .{doc.source_hash}); + try out.writeAll(" source: "); + try writeString(out, doc.source); + try out.writeAll("\n source_hash: "); + try writeString(out, doc.source_hash); + try out.writeAll("\n"); try writeBlock(out, "import_settings", doc.import_settings); try writeBlock(out, "process_settings", doc.process_settings); try writeBlock(out, "cook_settings", doc.cook_settings); @@ -221,7 +228,7 @@ fn writeValue(out: *std.Io.Writer, v: Value, depth: usize) WriteError!void { .int => |i| try out.print("{d}", .{i}), .float => |f| try writeFloat(out, f), .boolean => |b| try out.writeAll(if (b) "true" else "false"), - .string => |s| try out.print("\"{s}\"", .{s}), + .string => |s| try writeString(out, s), .identifier => |s| try out.writeAll(s), .enum_literal => |s| try out.print(".{s}", .{s}), .array => |items| { @@ -246,6 +253,22 @@ fn writeValue(out: *std.Io.Writer, v: Value, depth: usize) WriteError!void { } } +/// Emit `s` as a string literal: `"`, `\\`, `{` and the line breaks escaped +/// (`etch-grammar.md` §1.4), so the reader returns the same bytes. +fn writeString(out: *std.Io.Writer, s: []const u8) WriteError!void { + try out.writeAll("\""); + for (s) |c| switch (c) { + '"' => try out.writeAll("\\\""), + '\\' => try out.writeAll("\\\\"), + '{' => try out.writeAll("\\{"), + '\n' => try out.writeAll("\\n"), + '\t' => try out.writeAll("\\t"), + '\r' => try out.writeAll("\\r"), + else => try out.writeByte(c), + }; + try out.writeAll("\""); +} + /// Emit a float with a guaranteed decimal point so the reader keeps it a /// float (otherwise `1.0` would format as `1` and parse back as an int). fn writeFloat(out: *std.Io.Writer, f: f64) WriteError!void { @@ -265,14 +288,18 @@ pub const ParseError = error{ OutOfMemory, /// Hit end of input mid-construct. UnexpectedEnd, - /// A character not valid at this position. + /// A character not valid at this position, or content after the construct. UnexpectedChar, /// The document does not start with the `asset` keyword. ExpectedAssetKeyword, - /// A numeric literal failed to parse. + /// A numeric literal failed to parse, overflows, or is not finite. InvalidNumber, /// The `version` field was absent, non-integer, or out of `u16` range. InvalidVersion, + /// The document breaks the `engine-asset-pipeline.md` §3 schema: a fixed + /// field or block missing, repeated, unknown or of the wrong kind, or a + /// `uuid` / hash not in its canonical form. + SchemaViolation, }; /// Parse `.asset.etch` text into an `AssetDoc`. Every owned slice is @@ -282,6 +309,14 @@ pub fn parseEtch(arena: std.mem.Allocator, src: []const u8) ParseError!AssetDoc return p.parseDoc(); } +/// The `uuid` of an existing intermediate document, which a re-import keeps +/// (`engine-asset-pipeline.md` §3), or null when `text` is null: there is no +/// document yet. A document that does not parse is an error, never a reason to +/// mint another identity. +pub fn existingUuid(arena: std.mem.Allocator, text: ?[]const u8) ParseError!?[]const u8 { + return (try parseEtch(arena, text orelse return null)).uuid; +} + const Parser = struct { src: []const u8, pos: usize = 0, @@ -294,73 +329,176 @@ const Parser = struct { return (c >= 'a' and c <= 'z') or (c >= 'A' and c <= 'Z') or c == '_'; } fn isIdentChar(c: u8) bool { - return isIdentStart(c) or (c >= '0' and c <= '9'); + return isIdentStart(c) or isDigit(c); + } + fn isDigit(c: u8) bool { + return c >= '0' and c <= '9'; } - fn skipWs(self: *Parser) void { - while (self.pos < self.src.len and isWs(self.src[self.pos])) : (self.pos += 1) {} + /// Skip whitespace and comments: `// …` to the end of the line, `/* … */` + /// unnested (`etch-grammar.md` §1). + fn skipWs(self: *Parser) ParseError!void { + while (self.pos < self.src.len) { + const c = self.src[self.pos]; + if (isWs(c)) { + self.pos += 1; + } else if (std.mem.startsWith(u8, self.src[self.pos..], "//")) { + self.pos = std.mem.indexOfScalarPos(u8, self.src, self.pos, '\n') orelse self.src.len; + } else if (std.mem.startsWith(u8, self.src[self.pos..], "/*")) { + const close = std.mem.indexOfPos(u8, self.src, self.pos + 2, "*/") orelse return error.UnexpectedEnd; + self.pos = close + 2; + } else return; + } } fn peekNonWs(self: *Parser) ParseError!u8 { - self.skipWs(); + try self.skipWs(); if (self.pos >= self.src.len) return error.UnexpectedEnd; return self.src[self.pos]; } fn expect(self: *Parser, ch: u8) ParseError!void { - self.skipWs(); - if (self.pos >= self.src.len) return error.UnexpectedEnd; - if (self.src[self.pos] != ch) return error.UnexpectedChar; + if (try self.peekNonWs() != ch) return error.UnexpectedChar; self.pos += 1; } + /// An `IDENT`. `true` and `false` are boolean literals, never identifiers. fn parseIdent(self: *Parser) ParseError![]const u8 { - self.skipWs(); + try self.skipWs(); const start = self.pos; if (self.pos >= self.src.len or !isIdentStart(self.src[self.pos])) return error.UnexpectedChar; self.pos += 1; while (self.pos < self.src.len and isIdentChar(self.src[self.pos])) : (self.pos += 1) {} - return self.arena.dupe(u8, self.src[start..self.pos]); + const id = self.src[start..self.pos]; + if (std.mem.eql(u8, id, "true") or std.mem.eql(u8, id, "false")) return error.UnexpectedChar; + return self.arena.dupe(u8, id); } + /// A string literal, simple or triple-quoted (`etch-grammar.md` §1.4), with + /// its escapes decoded. An unescaped `{` would open an interpolation, which + /// a data value has no meaning for, and a simple string does not span lines. fn parseString(self: *Parser) ParseError![]const u8 { try self.expect('"'); - const start = self.pos; - while (self.pos < self.src.len and self.src[self.pos] != '"') : (self.pos += 1) {} + if (std.mem.startsWith(u8, self.src[self.pos..], "\"\"")) { + self.pos += 2; + return self.parseTripleString(); + } + var out: std.ArrayList(u8) = .empty; + while (true) { + if (self.pos >= self.src.len) return error.UnexpectedEnd; + const c = self.src[self.pos]; + self.pos += 1; + switch (c) { + '"' => return out.toOwnedSlice(self.arena), + '\\' => try out.append(self.arena, try self.escaped()), + '{', '\n' => return error.UnexpectedChar, + else => try out.append(self.arena, c), + } + } + } + + /// The body of a `"""…"""` literal, after its opening fence: escapes + /// decoded and the common indentation of its non-blank lines removed. + fn parseTripleString(self: *Parser) ParseError![]const u8 { + const body_start = self.pos; + const close = std.mem.indexOfPos(u8, self.src, body_start, "\"\"\"") orelse return error.UnexpectedEnd; + const body = self.src[body_start..close]; + self.pos = close + 3; + const indent = commonIndentOf(body); + var out: std.ArrayList(u8) = .empty; + var i: usize = 0; + var skip_left = indent; + while (i < body.len) { + const c = body[i]; + if (skip_left > 0 and (c == ' ' or c == '\t')) { + skip_left -= 1; + i += 1; + continue; + } + skip_left = 0; + switch (c) { + '\\' => { + if (i + 1 >= body.len) return error.UnexpectedChar; + try out.append(self.arena, try escapeByte(body[i + 1])); + i += 2; + }, + '{' => return error.UnexpectedChar, + '\n' => { + try out.append(self.arena, '\n'); + i += 1; + skip_left = indent; + }, + else => { + try out.append(self.arena, c); + i += 1; + }, + } + } + return out.toOwnedSlice(self.arena); + } + + /// The common leading indentation (spaces and tabs) of the non-blank lines + /// of `body`, as `etch-grammar.md` §1.4 strips it. + fn commonIndentOf(body: []const u8) usize { + var min: ?usize = null; + var lines = std.mem.splitScalar(u8, body, '\n'); + while (lines.next()) |line| { + var ws: usize = 0; + while (ws < line.len and (line[ws] == ' ' or line[ws] == '\t')) : (ws += 1) {} + if (std.mem.trim(u8, line, " \t\r").len == 0) continue; + min = if (min) |m| @min(m, ws) else ws; + } + return min orelse 0; + } + + fn escaped(self: *Parser) ParseError!u8 { if (self.pos >= self.src.len) return error.UnexpectedEnd; - const inner = self.src[start..self.pos]; - self.pos += 1; // consume closing quote - return self.arena.dupe(u8, inner); + const b = try escapeByte(self.src[self.pos]); + self.pos += 1; + return b; } + /// The byte an escape sequence `\\c` denotes; `etch-grammar.md` §1.4 admits + /// exactly six. + fn escapeByte(c: u8) ParseError!u8 { + return switch (c) { + '"' => '"', + '\\' => '\\', + 'n' => '\n', + 't' => '\t', + 'r' => '\r', + '{' => '{', + else => error.UnexpectedChar, + }; + } + + /// `INT_LITERAL` or `FLOAT_LITERAL` (`etch-grammar.md` §1.4): an optional + /// `-`, a digit, digits and `_`, and a fraction only where `.` is followed by + /// a digit. No `+`, no exponent. fn parseNumber(self: *Parser) ParseError!Value { - self.skipWs(); + try self.skipWs(); const start = self.pos; - if (self.pos < self.src.len and (self.src[self.pos] == '-' or self.src[self.pos] == '+')) { - self.pos += 1; - } + if (self.pos < self.src.len and self.src[self.pos] == '-') self.pos += 1; + if (self.pos >= self.src.len or !isDigit(self.src[self.pos])) return error.InvalidNumber; + self.skipDigits(); var is_float = false; - while (self.pos < self.src.len) : (self.pos += 1) { - const ch = self.src[self.pos]; - if (ch >= '0' and ch <= '9') continue; - if (ch == '.' or ch == 'e' or ch == 'E') { - is_float = true; - continue; - } - if ((ch == '+' or ch == '-') and self.pos > start) { - const prev = self.src[self.pos - 1]; - if (prev == 'e' or prev == 'E') continue; - } - break; + if (self.pos + 1 < self.src.len and self.src[self.pos] == '.' and isDigit(self.src[self.pos + 1])) { + is_float = true; + self.pos += 1; + self.skipDigits(); } - const slice = self.src[start..self.pos]; - if (slice.len == 0) return error.InvalidNumber; + var digits: std.ArrayList(u8) = .empty; + for (self.src[start..self.pos]) |c| if (c != '_') try digits.append(self.arena, c); if (is_float) { - const f = std.fmt.parseFloat(f64, slice) catch return error.InvalidNumber; + const f = std.fmt.parseFloat(f64, digits.items) catch return error.InvalidNumber; + if (!std.math.isFinite(f)) return error.InvalidNumber; return .{ .float = f }; } - const i = std.fmt.parseInt(i64, slice, 10) catch return error.InvalidNumber; - return .{ .int = i }; + return .{ .int = std.fmt.parseInt(i64, digits.items, 10) catch return error.InvalidNumber }; + } + + fn skipDigits(self: *Parser) void { + while (self.pos < self.src.len and (isDigit(self.src[self.pos]) or self.src[self.pos] == '_')) : (self.pos += 1) {} } fn parseArray(self: *Parser) ParseError!Value { @@ -391,7 +529,8 @@ const Parser = struct { return .{ .object = try self.parseFields() }; } - /// Parse a `{ key: value … }` block and return its fields. + /// Parse a `{ key: value … }` block and return its fields: + /// `asset_field = [ annotation ] , IDENT , ":" , asset_value , [ "," ]`. fn parseFields(self: *Parser) ParseError![]const Field { try self.expect('{'); var fields: std.ArrayList(Field) = .empty; @@ -401,14 +540,44 @@ const Parser = struct { self.pos += 1; break; } + if (c == '@') try self.skipAnnotation(); const key = try self.parseIdent(); try self.expect(':'); const value = try self.parseValue(); try fields.append(self.arena, .{ .key = key, .value = value }); + if (try self.peekNonWs() == ',') self.pos += 1; } return fields.toOwnedSlice(self.arena); } + /// `annotation = "@" , IDENT , [ "(" , [ annotation_args ] , ")" ]`, with each + /// argument an asset value, optionally named. + fn skipAnnotation(self: *Parser) ParseError!void { + try self.expect('@'); + _ = try self.parseIdent(); + if (try self.peekNonWs() != '(') return; + self.pos += 1; + while (true) { + if (try self.peekNonWs() == ')') { + self.pos += 1; + return; + } + if (isIdentStart(self.src[self.pos])) { + const save = self.pos; + const ident_ok = if (self.parseIdent()) |_| true else |err| switch (err) { + error.UnexpectedChar => false, + else => return err, + }; + if (!(ident_ok and try self.peekNonWs() == ':')) self.pos = save else self.pos += 1; + } + _ = try self.parseValue(); + const d = try self.peekNonWs(); + if (d == ',') { + self.pos += 1; + } else if (d != ')') return error.UnexpectedChar; + } + } + fn parseValue(self: *Parser) ParseError!Value { const c = try self.peekNonWs(); switch (c) { @@ -419,22 +588,34 @@ const Parser = struct { self.pos += 1; // consume '.' return .{ .enum_literal = try self.parseIdent() }; }, - '-', '+', '0'...'9' => return self.parseNumber(), + '-', '0'...'9' => return self.parseNumber(), else => { if (!isIdentStart(c)) return error.UnexpectedChar; - const id = try self.parseIdent(); - if (std.mem.eql(u8, id, "true")) return .{ .boolean = true }; - if (std.mem.eql(u8, id, "false")) return .{ .boolean = false }; - return .{ .identifier = id }; + if (self.boolLiteral()) |b| return .{ .boolean = b }; + return .{ .identifier = try self.parseIdent() }; }, } } + /// Consumes `true` or `false` when it stands as a whole identifier. + fn boolLiteral(self: *Parser) ?bool { + const rest = self.src[self.pos..]; + inline for (.{ .{ "true", true }, .{ "false", false } }) |lit| { + if (std.mem.startsWith(u8, rest, lit[0]) and (rest.len == lit[0].len or !isIdentChar(rest[lit[0].len]))) { + self.pos += lit[0].len; + return lit[1]; + } + } + return null; + } + fn parseDoc(self: *Parser) ParseError!AssetDoc { const keyword = self.parseIdent() catch return error.ExpectedAssetKeyword; if (!std.mem.eql(u8, keyword, "asset")) return error.ExpectedAssetKeyword; const name = try self.parseString(); const fields = try self.parseFields(); + try self.skipWs(); + if (self.pos != self.src.len) return error.UnexpectedChar; var doc = AssetDoc{ .name = name, @@ -444,58 +625,77 @@ const Parser = struct { .source = "", .source_hash = "", }; + const Fixed = enum { uuid, type, version, source, source_hash, import_settings, process_settings, cook_settings, extracted }; + var seen = std.EnumSet(Fixed).initEmpty(); for (fields) |f| { - if (std.mem.eql(u8, f.key, "uuid")) { - doc.uuid = switch (f.value) { - .string => |s| s, - else => return error.UnexpectedChar, - }; - } else if (std.mem.eql(u8, f.key, "type")) { - doc.type_name = switch (f.value) { + const which = std.meta.stringToEnum(Fixed, f.key) orelse return error.SchemaViolation; + if (seen.contains(which)) return error.SchemaViolation; + seen.insert(which); + switch (which) { + .uuid => doc.uuid = try canonical(stringOf(f.value), isUuid), + .type => doc.type_name = switch (f.value) { .identifier => |s| s, - .string => |s| s, - else => return error.UnexpectedChar, - }; - } else if (std.mem.eql(u8, f.key, "version")) { - doc.version = switch (f.value) { + else => return error.SchemaViolation, + }, + .version => doc.version = switch (f.value) { .int => |i| std.math.cast(u16, i) orelse return error.InvalidVersion, else => return error.InvalidVersion, - }; - } else if (std.mem.eql(u8, f.key, "source")) { - doc.source = switch (f.value) { - .string => |s| s, - else => return error.UnexpectedChar, - }; - } else if (std.mem.eql(u8, f.key, "source_hash")) { - doc.source_hash = switch (f.value) { - .string => |s| s, - else => return error.UnexpectedChar, - }; - } else if (std.mem.eql(u8, f.key, "import_settings")) { - doc.import_settings = switch (f.value) { - .object => |o| o, - else => return error.UnexpectedChar, - }; - } else if (std.mem.eql(u8, f.key, "process_settings")) { - doc.process_settings = switch (f.value) { - .object => |o| o, - else => return error.UnexpectedChar, - }; - } else if (std.mem.eql(u8, f.key, "cook_settings")) { - doc.cook_settings = switch (f.value) { - .object => |o| o, - else => return error.UnexpectedChar, - }; - } else if (std.mem.eql(u8, f.key, "extracted")) { - doc.extracted = switch (f.value) { - .object => |o| o, - else => return error.UnexpectedChar, - }; + }, + .source => doc.source = stringOf(f.value) orelse return error.SchemaViolation, + .source_hash => doc.source_hash = try canonical(stringOf(f.value), isHash128), + .import_settings => doc.import_settings = try objectOf(f.value), + .process_settings => doc.process_settings = try objectOf(f.value), + .cook_settings => doc.cook_settings = try objectOf(f.value), + .extracted => doc.extracted = try objectOf(f.value), } - // Unknown top-level keys are ignored (forward-compatibility). } + if (!seen.eql(std.EnumSet(Fixed).initFull())) return error.SchemaViolation; + _ = try canonical(doc.blobHash(), isHash128); return doc; } + + fn stringOf(v: Value) ?[]const u8 { + return switch (v) { + .string => |s| s, + else => null, + }; + } + + fn objectOf(v: Value) ParseError![]const Field { + return switch (v) { + .object => |o| o, + else => error.SchemaViolation, + }; + } + + /// `s` when present and of the canonical form `form` checks. + fn canonical(s: ?[]const u8, comptime form: fn ([]const u8) bool) ParseError![]const u8 { + const v = s orelse return error.SchemaViolation; + if (!form(v)) return error.SchemaViolation; + return v; + } + + /// A UUID in its canonical `8-4-4-4-12` lowercase hexadecimal form. + fn isUuid(s: []const u8) bool { + if (s.len != 36) return false; + for (s, 0..) |c, i| { + const dash = i == 8 or i == 13 or i == 18 or i == 23; + if (dash != (c == '-')) return false; + if (!dash and !isLowerHex(c)) return false; + } + return true; + } + + /// A 128-bit hash as 32 lowercase hexadecimal digits. + fn isHash128(s: []const u8) bool { + if (s.len != 32) return false; + for (s) |c| if (!isLowerHex(c)) return false; + return true; + } + + fn isLowerHex(c: u8) bool { + return isDigit(c) or (c >= 'a' and c <= 'f'); + } }; test "intermediate doc round-trips through etch text" { @@ -526,7 +726,7 @@ test "intermediate doc round-trips through etch text" { .{ .key = "vertex_count", .value = .{ .int = 24 } }, .{ .key = "bounds", .value = .{ .object = &bounds } }, .{ .key = "materials", .value = .{ .array = &materials } }, - .{ .key = "blob", .value = .{ .string = "a3f2b1c98d" } }, // mandatory + .{ .key = "blob", .value = .{ .string = "a3f2b1c98d0011223344556677889900" } }, // mandatory }; const original = AssetDoc{ @@ -535,7 +735,7 @@ test "intermediate doc round-trips through etch text" { .type_name = "StaticMesh", .version = 1, .source = "cube.gltf", - .source_hash = "abc123", + .source_hash = "abc12300112233445566778899aabbcc", .import_settings = &import_settings, .process_settings = &process_settings, .cook_settings = &cook_settings, @@ -555,11 +755,11 @@ test "intermediate doc round-trips through etch text" { try std.testing.expectEqual(@as(u16, 1), parsed.version); try std.testing.expectEqual(@as(usize, 4), parsed.extracted.len); try std.testing.expectEqual(@as(usize, 1), parsed.cook_settings.len); - try std.testing.expectEqualStrings("a3f2b1c98d", original.blobHash().?); - try std.testing.expectEqualStrings("a3f2b1c98d", parsed.blobHash().?); + try std.testing.expectEqualStrings("a3f2b1c98d0011223344556677889900", original.blobHash().?); + try std.testing.expectEqualStrings("a3f2b1c98d0011223344556677889900", parsed.blobHash().?); // Field accessors used by the cookers. try std.testing.expectEqual(@as(i64, 24), fieldInt(parsed.extracted, "vertex_count").?); - try std.testing.expectEqualStrings("a3f2b1c98d", fieldStr(parsed.extracted, "blob").?); + try std.testing.expectEqualStrings("a3f2b1c98d0011223344556677889900", fieldStr(parsed.extracted, "blob").?); try std.testing.expectEqual(@as(?i64, null), fieldInt(parsed.extracted, "bounds")); // not an int } @@ -592,13 +792,16 @@ test "intermediate float keeps its decimal point through a round-trip" { const import_settings = [_]Field{ .{ .key = "scale", .value = .{ .float = 1.0 } }, }; + const extracted = [_]Field{.{ .key = "blob", .value = .{ .string = "00112233445566778899aabbccddeeff" } }}; const doc = AssetDoc{ .name = "x", + .uuid = "0190b3f0-1c2d-7e4a-8b6c-0123456789ab", .type_name = "Texture2D", .version = 1, .source = "x.png", - .source_hash = "0", + .source_hash = "00112233445566778899aabbccddeeff", .import_settings = &import_settings, + .extracted = &extracted, }; const text = try writeAlloc(gpa, doc); defer gpa.free(text); @@ -615,3 +818,174 @@ test "intermediate parse rejects input without the asset keyword" { defer arena.deinit(); try std.testing.expectError(error.ExpectedAssetKeyword, parseEtch(arena.allocator(), "widget \"x\" {}")); } + +/// A canonical document, one line per field, for the reader tests to vary. +const minimal_doc = + \\asset "a" { + \\ uuid: "0190b3f0-1c2d-7e4a-8b6c-0123456789ab" + \\ type: StaticMesh + \\ version: 1 + \\ source: "a.gltf" + \\ source_hash: "00112233445566778899aabbccddeeff" + \\ import_settings: { } + \\ process_settings: { } + \\ cook_settings: { } + \\ extracted: { blob: "00112233445566778899aabbccddeeff" } + \\} + \\ +; + +/// Parses `src` into a fresh arena and returns the parse result. +fn parseText(arena: *std.heap.ArenaAllocator, src: []const u8) ParseError!AssetDoc { + return parseEtch(arena.allocator(), src); +} + +/// `minimal_doc` with the text of `old` replaced by `new`. +fn variant(arena: *std.heap.ArenaAllocator, old: []const u8, new: []const u8) ![]const u8 { + const out = try std.mem.replaceOwned(u8, arena.allocator(), minimal_doc, old, new); + try std.testing.expect(!std.mem.eql(u8, out, minimal_doc)); + return out; +} + +test "the canonical document parses" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + _ = try parseText(&arena, minimal_doc); +} + +test "a comma between fields is accepted" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const src = try variant(&arena, " import_settings: { }", " import_settings: { a: 1, b: 2, },"); + const doc = try parseText(&arena, src); + try std.testing.expectEqual(@as(usize, 2), doc.import_settings.len); +} + +test "an annotation on a field is accepted" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const src = try variant(&arena, " process_settings: { }", " process_settings: { @unit(.meters) min_fragment_size: 0.05 }"); + const doc = try parseText(&arena, src); + try std.testing.expectEqualStrings("min_fragment_size", doc.process_settings[0].key); +} + +test "comments are accepted" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const src = try variant(&arena, " cook_settings: { }", " // per platform\n cook_settings: { /* none yet */ }"); + _ = try parseText(&arena, src); +} + +test "a string with escapes round-trips through the writer" { + const gpa = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const base = try parseText(&arena, minimal_doc); + var doc = base; + doc.source = "dir\\a \"b\" {c}\nd"; + const text = try writeAlloc(gpa, doc); + defer gpa.free(text); + const back = try parseText(&arena, text); + try std.testing.expectEqualStrings(doc.source, back.source); +} + +test "an unknown escape is refused" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + try std.testing.expectError(error.UnexpectedChar, parseText(&arena, try variant(&arena, "\"a.gltf\"", "\"a\\q.gltf\""))); +} + +test "an unescaped brace in a string is refused" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + try std.testing.expectError(error.UnexpectedChar, parseText(&arena, try variant(&arena, "\"a.gltf\"", "\"a{1}.gltf\""))); +} + +test "a triple-quoted string loses its common indentation" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const src = try variant(&arena, " import_settings: { }", " import_settings: { note: \"\"\"\n one\n two\n \"\"\" }"); + const doc = try parseText(&arena, src); + try std.testing.expectEqualStrings("\none\n two\n", doc.import_settings[0].value.string); +} + +test "digit separators are accepted" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const doc = try parseText(&arena, try variant(&arena, " import_settings: { }", " import_settings: { n: 1_000_, f: 1_0.2_5 }")); + try std.testing.expectEqual(@as(i64, 1000), doc.import_settings[0].value.int); + try std.testing.expectEqual(@as(f64, 10.25), doc.import_settings[1].value.float); +} + +test "a numeric form outside the grammar is refused" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + for ([_][]const u8{ "+5", "1e5", "1.", ".5x", "1" ++ "0" ** 400 ++ ".0", "9223372036854775808" }) |n| { + const field = try std.fmt.allocPrint(arena.allocator(), " import_settings: {{ n: {s} }}", .{n}); + if (parseText(&arena, try variant(&arena, " import_settings: { }", field))) |_| { + std.debug.print("accepted: {s}\n", .{n}); + return error.TestExpectedError; + } else |_| {} + } +} + +test "content after the construct is refused" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + try std.testing.expectError(error.UnexpectedChar, parseText(&arena, minimal_doc ++ "junk\n")); +} + +test "a boolean literal is not a field name" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + try std.testing.expectError(error.UnexpectedChar, parseText(&arena, try variant(&arena, " import_settings: { }", " import_settings: { true: 1 }"))); +} + +test "a string asset type is refused" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + try std.testing.expectError(error.SchemaViolation, parseText(&arena, try variant(&arena, "type: StaticMesh", "type: \"StaticMesh\""))); +} + +test "a missing fixed field is refused" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + try std.testing.expectError(error.SchemaViolation, parseText(&arena, try variant(&arena, " source: \"a.gltf\"\n", ""))); +} + +test "a repeated fixed field is refused" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + try std.testing.expectError(error.SchemaViolation, parseText(&arena, try variant(&arena, " source: \"a.gltf\"", " source: \"a.gltf\"\n source: \"b.gltf\""))); +} + +test "an unknown top-level field is refused" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + try std.testing.expectError(error.SchemaViolation, parseText(&arena, try variant(&arena, " version: 1", " version: 1\n extra: 2"))); +} + +test "a uuid not in canonical form is refused" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + try std.testing.expectError(error.SchemaViolation, parseText(&arena, try variant(&arena, "0190b3f0-1c2d-7e4a-8b6c-0123456789ab", "not-a-uuid"))); +} + +test "an extracted block without its blob is refused" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + try std.testing.expectError(error.SchemaViolation, parseText(&arena, try variant(&arena, "extracted: { blob: \"00112233445566778899aabbccddeeff\" }", "extracted: { }"))); +} + +test "an existing document keeps its uuid, and none yields none" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + try std.testing.expectEqual(@as(?[]const u8, null), try existingUuid(arena.allocator(), null)); + try std.testing.expectEqualStrings("0190b3f0-1c2d-7e4a-8b6c-0123456789ab", (try existingUuid(arena.allocator(), minimal_doc)).?); +} + +test "an existing document that does not parse is an error, not a new identity" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + try std.testing.expectError(error.UnexpectedChar, existingUuid(arena.allocator(), minimal_doc ++ "junk")); +} diff --git a/tools/asset_cook/main.zig b/tools/asset_cook/main.zig index 89b37588..cad4a13f 100644 --- a/tools/asset_cook/main.zig +++ b/tools/asset_cook/main.zig @@ -112,16 +112,13 @@ fn cookOne(gpa: std.mem.Allocator, fx: Fixture, doc: assets.AssetDoc, blob: []co /// Reuse the uuid of an existing intermediate `.asset.etch`, else generate a /// fresh UUIDv7. The result is written into `buf` (lives across the import). fn resolveUuid(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, etch_name: []const u8, buf: *[36]u8) ![]const u8 { - if (try readFileOpt(gpa, io, dir, etch_name)) |text| { - defer gpa.free(text); - var arena = std.heap.ArenaAllocator.init(gpa); - defer arena.deinit(); - if (assets.format.intermediate.parseEtch(arena.allocator(), text)) |doc| { - if (doc.uuid.len == 36) { - @memcpy(buf, doc.uuid[0..36]); - return buf; - } - } else |_| {} + const text = try readFileOpt(gpa, io, dir, etch_name); + defer if (text) |t| gpa.free(t); + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + if (try assets.format.intermediate.existingUuid(arena.allocator(), text)) |uuid| { + @memcpy(buf, uuid[0..36]); + return buf; } @memcpy(buf, &assets.uuid.toString(assets.uuid.generateV7(io))); return buf; diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 0553b5bb..c77dc072 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -237,8 +237,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2551, - else => 2553, + .windows => 2571, + else => 2573, }; } From fd82bfc572d3017c915e0f1b3f4271a7a28d2b98 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 23 Sep 2026 16:04:11 +0200 Subject: [PATCH 026/141] test(assets): witness the refusal of a non-finite glTF bound The importer refuses a bound the intermediate format has no literal for; the test gives an accessor a maximum of 1e40, infinite as an f32. Floor 2573 -> 2574 / 2572, measured: 2555/2574 passed, 19 skipped. Co-Authored-By: Claude Opus 5.5 --- src/modules/asset_pipeline/codecs/gltf/decode.zig | 8 ++++++++ tools/weld_lint/dead_tests.zig | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/modules/asset_pipeline/codecs/gltf/decode.zig b/src/modules/asset_pipeline/codecs/gltf/decode.zig index e2e45e59..6c26d2c8 100644 --- a/src/modules/asset_pipeline/codecs/gltf/decode.zig +++ b/src/modules/asset_pipeline/codecs/gltf/decode.zig @@ -320,6 +320,14 @@ test "decode static cube glTF extracts positions, normals, uvs, indices" { try std.testing.expectEqual([3]u32{ 0, 1, 2 }, mesh.indices[0..3].*); } +test "a bound that is not finite is refused" { + const gpa = std.testing.allocator; + const src = try std.mem.replaceOwned(u8, gpa, cube_gltf, "\"max\":[1,1,1]", "\"max\":[1,1,1e40]"); + defer gpa.free(src); + try std.testing.expect(!std.mem.eql(u8, src, cube_gltf)); + try std.testing.expectError(error.MalformedGltf, decode(gpa, src)); +} + test "decode rejects non-glTF json" { const gpa = std.testing.allocator; try std.testing.expectError(error.NoMesh, decode(gpa, "{\"buffers\":[],\"bufferViews\":[],\"accessors\":[],\"meshes\":[]}")); diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index c77dc072..207373c1 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -237,8 +237,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2571, - else => 2573, + .windows => 2572, + else => 2574, }; } From 949795730ef5ae6748fb3e4cd6e544a8f94a07ef Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 23 Sep 2026 16:14:42 +0200 Subject: [PATCH 027/141] fix(build): forward target, cpu, mode and precision to nested builds The three nested `zig build` invocations (the triangle example, the synth_100 proof, the access counter-proof cases) ran with no option at all, so each compiled for the host's native CPU, in Debug, at f32, whatever cell ran it. They now receive `-Dtarget`, `-Dcpu`, `-Doptimize` and `-Dphysics_f64` from the outer build, and each sub-project declares `physics_f64` and passes it to its `weld` dependency. CI passes the matrix precision to the two nested steps it runs. Measured: `verify-synth-100` succeeds; `ecs-access-counterproof -Dphysics_f64=true -Dcpu=baseline` 8/8. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 4 ++-- bench/fixtures/synth_100/build.zig | 2 ++ build.zig | 19 +++++++++++++++++++ examples/triangle/build.zig | 2 ++ tests/core/ecs/access_counterproof/build.zig | 2 ++ 5 files changed, 27 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 453288b5..09fde0d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,7 +160,7 @@ jobs: - name: zig build verify-synth-100 if: matrix.os == 'ubuntu-24.04' && matrix.mode == 'Debug' - run: zig build verify-synth-100 -Dcpu=${{ env.ZIG_CPU }} + run: zig build verify-synth-100 -Dphysics_f64=${{ matrix.precision }} -Dcpu=${{ env.ZIG_CPU }} - name: zig build forge-asm-inventory if: matrix.os == 'ubuntu-24.04' && matrix.mode == 'Debug' @@ -172,7 +172,7 @@ jobs: - name: zig build ecs-access-counterproof if: matrix.os == 'ubuntu-24.04' && matrix.mode == 'Debug' - run: zig build ecs-access-counterproof -Dcpu=${{ env.ZIG_CPU }} + run: zig build ecs-access-counterproof -Dphysics_f64=${{ matrix.precision }} -Dcpu=${{ env.ZIG_CPU }} - name: zig build ecs-access-zero-cost if: matrix.os == 'ubuntu-24.04' && matrix.mode == 'Debug' diff --git a/bench/fixtures/synth_100/build.zig b/bench/fixtures/synth_100/build.zig index bb8dcd4e..dc0f7766 100644 --- a/bench/fixtures/synth_100/build.zig +++ b/bench/fixtures/synth_100/build.zig @@ -22,9 +22,11 @@ pub fn build(b: *std.Build) void { // Dependency on the Weld engine (local path in the monolithic // repo, three levels up — same rationale as `examples/triangle`). + const physics_f64 = b.option(bool, "physics_f64", "Build the engine's forge_3d in f64 precision") orelse false; const weld = b.dependency("weld", .{ .target = target, .optimize = optimize, + .physics_f64 = physics_f64, }); // Cook the committed corpus through the parent's installed `etch_cook` diff --git a/build.zig b/build.zig index 88227a95..9ef1d5e9 100644 --- a/build.zig +++ b/build.zig @@ -268,6 +268,7 @@ pub fn build(b: *std.Build) void { "build", "run", }); + addNestedOptions(b, ex_run, target, optimize, physics_f64); ex_run.setCwd(b.path("examples/triangle")); if (b.args) |args| { ex_run.addArg("--"); @@ -286,6 +287,7 @@ pub fn build(b: *std.Build) void { b.graph.zig_exe, "build", }); + addNestedOptions(b, synth_verify, target, optimize, physics_f64); synth_verify.setCwd(b.path("bench/fixtures/synth_100")); const synth_verify_step = b.step("verify-synth-100", "Build the synth_100 sub-project (nested zig build — the standalone proof)"); synth_verify_step.dependOn(&synth_verify.step); @@ -370,6 +372,7 @@ pub fn build(b: *std.Build) void { b.addSystemCommand(&.{ b.graph.zig_exe, "build", name }) else b.addSystemCommand(&.{ b.graph.zig_exe, "build" }); + addNestedOptions(b, run, target, optimize, physics_f64); run.setCwd(b.path(counterproof_dir)); // ALWAYS RE-RUN, and this is not a precaution. A `Run` step with no file // argument is cached on its argv alone, and `setCwd` does not make the @@ -2653,3 +2656,19 @@ pub fn build(b: *std.Build) void { const bindgen_tests = b.addTest(.{ .root_module = bindgen_test_module }); test_step.dependOn(&b.addRunArtifact(bindgen_tests).step); } + +/// Passes this build's target, CPU, mode and physics precision to a nested +/// `zig build` of a sub-project, so it compiles for the cell that runs it +/// (`ARCH-031` rule 6). +fn addNestedOptions( + b: *std.Build, + run: *std.Build.Step.Run, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + physics_f64: bool, +) void { + run.addArg(b.fmt("-Dtarget={s}", .{target.query.zigTriple(b.allocator) catch @panic("OOM")})); + run.addArg(b.fmt("-Dcpu={s}", .{target.query.serializeCpuAlloc(b.allocator) catch @panic("OOM")})); + run.addArg(b.fmt("-Doptimize={s}", .{@tagName(optimize)})); + run.addArg(if (physics_f64) "-Dphysics_f64=true" else "-Dphysics_f64=false"); +} diff --git a/examples/triangle/build.zig b/examples/triangle/build.zig index dc43e9e7..512aed69 100644 --- a/examples/triangle/build.zig +++ b/examples/triangle/build.zig @@ -13,9 +13,11 @@ pub fn build(b: *std.Build) void { // The engine, by local path while the repo is monolithic. It becomes a url // plus hash the day separable extraction is validated (`ARCH-017`). + const physics_f64 = b.option(bool, "physics_f64", "Build the engine's forge_3d in f64 precision") orelse false; const weld = b.dependency("weld", .{ .target = target, .optimize = optimize, + .physics_f64 = physics_f64, }); const main_module = b.createModule(.{ diff --git a/tests/core/ecs/access_counterproof/build.zig b/tests/core/ecs/access_counterproof/build.zig index 859efcbf..571b440c 100644 --- a/tests/core/ecs/access_counterproof/build.zig +++ b/tests/core/ecs/access_counterproof/build.zig @@ -74,9 +74,11 @@ pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + const physics_f64 = b.option(bool, "physics_f64", "Build the engine's forge_3d in f64 precision") orelse false; const weld = b.dependency("weld", .{ .target = target, .optimize = optimize, + .physics_f64 = physics_f64, }); for (cases) |case| { From 4803755a6f0ae17b0971e3d5f52cf0a98391e45b Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 23 Sep 2026 16:14:58 +0200 Subject: [PATCH 028/141] fix(lint): extend the dead-test guard to examples/ `dead-tests` walked the lint paths only, so a test block under `examples/` was invisible to the conservation check whether or not a step collected it. Extended, the guard names one: `examples/vertical_slice/math.zig`, one block, reached by no test root because the slice module is imported across a module boundary, which collects none of its tests. It is std-only and now has its own test root. Floor 2574 -> 2575 / 2573, measured: 2556/2575 passed, 19 skipped. Co-Authored-By: Claude Opus 5.5 --- build.zig | 4 ++++ tools/weld_lint/dead_tests.zig | 4 ++-- tools/weld_lint/main.zig | 6 +++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/build.zig b/build.zig index 9ef1d5e9..2661862a 100644 --- a/build.zig +++ b/build.zig @@ -979,6 +979,10 @@ pub fn build(b: *std.Build) void { .{ .path = "tests/etch/requires_resource_test.zig", .etch = true, .dedicated_step = "test-requires-resource" }, // An annotation argument follows §1.5; positional readers refuse names. .{ .path = "tests/etch/annotation_arg_test.zig", .etch = true, .dedicated_step = "test-annotation-arg" }, + // The vertical slice's math is std-only and tested on its own: the + // slice module is imported across a module boundary, which collects + // none of its tests. + .{ .path = "examples/vertical_slice/math.zig" }, // one test per type-checker diagnostic code: each names its code and // asserts PRESENCE, so it reddens the day emission stops. .{ .path = "tests/etch/diagnostic_coverage_test.zig", .etch = true }, diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 207373c1..f74e54eb 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -237,8 +237,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2572, - else => 2574, + .windows => 2573, + else => 2575, }; } diff --git a/tools/weld_lint/main.zig b/tools/weld_lint/main.zig index 82239c05..ec3ee680 100644 --- a/tools/weld_lint/main.zig +++ b/tools/weld_lint/main.zig @@ -38,6 +38,10 @@ const comment_scan = @import("comment_scan.zig"); const default_lint_paths = [_][]const u8{ "src", "bench", "tests", "tools", "build.zig" }; +/// Where `dead-tests` looks for test blocks: the lint paths and `examples/`, whose +/// sources a test root can reach without the lint rules applying to them. +const dead_test_paths = default_lint_paths ++ [_][]const u8{"examples"}; + /// Default subtrees for `census` and `fingerprint`. /// /// Narrower than `default_lint_paths` on purpose: `tests/` carries no content @@ -398,7 +402,7 @@ fn runDeadTests(arena: std.mem.Allocator, io: std.Io, out: *std.Io.Writer, argv_ var files: std.ArrayList([]const u8) = .empty; defer files.deinit(arena); - for (default_lint_paths) |p| try scan.collectZigFiles(arena, io, p, &files); + for (dead_test_paths) |p| try scan.collectZigFiles(arena, io, p, &files); // The reader closes over an arena and the io handle through a file-scope // slot: `analyze` takes a plain function pointer so its fixtures can drive From 9a1472d41c36dada4c45be17add3aacb4dd048de Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 23 Sep 2026 16:39:08 +0200 Subject: [PATCH 029/141] test(assets): witness the refusal of a non-finite glTF position The decoder refuses a position the intermediate format has no literal for, and nothing witnessed it: under an accessor without bounds, the computed bounds are infinite too and the bound check refuses first. The test gives one vertex an x of +inf under finite accessor bounds, the only case the position check alone decides. Floor 2575 -> 2576 / 2574, measured: 2557/2576 passed, 19 skipped. Co-Authored-By: Claude Opus 5.5 --- src/modules/asset_pipeline/codecs/gltf/decode.zig | 9 +++++++++ tools/weld_lint/dead_tests.zig | 4 ++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/modules/asset_pipeline/codecs/gltf/decode.zig b/src/modules/asset_pipeline/codecs/gltf/decode.zig index 6c26d2c8..c11df2f1 100644 --- a/src/modules/asset_pipeline/codecs/gltf/decode.zig +++ b/src/modules/asset_pipeline/codecs/gltf/decode.zig @@ -328,6 +328,15 @@ test "a bound that is not finite is refused" { try std.testing.expectError(error.MalformedGltf, decode(gpa, src)); } +test "a position that is not finite is refused under finite bounds" { + const gpa = std.testing.allocator; + // One vertex whose x is +inf; the accessor's own bounds are finite. + const src = + \\{"buffers":[{"byteLength":12,"uri":"data:application/octet-stream;base64,AACAfwAAAAAAAAAA"}],"bufferViews":[{"buffer":0,"byteOffset":0,"byteLength":12}],"accessors":[{"bufferView":0,"componentType":5126,"count":1,"type":"VEC3","min":[0,0,0],"max":[1,1,1]}],"meshes":[{"primitives":[{"attributes":{"POSITION":0}}]}]} + ; + try std.testing.expectError(error.MalformedGltf, decode(gpa, src)); +} + test "decode rejects non-glTF json" { const gpa = std.testing.allocator; try std.testing.expectError(error.NoMesh, decode(gpa, "{\"buffers\":[],\"bufferViews\":[],\"accessors\":[],\"meshes\":[]}")); diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index f74e54eb..df20c494 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -237,8 +237,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2573, - else => 2575, + .windows => 2574, + else => 2576, }; } From 22072a41ca1d292367a0f612a11ea3cda5e48180 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 23 Sep 2026 16:40:36 +0200 Subject: [PATCH 030/141] fix(render): run the PSNR gate on the capture the smoke job writes The gate never ran. In CI, `test-ppm-psnr` reported `2/3 tests passed (1 skipped)` at 9d2609cf: the example runs from its own directory and writes `examples/triangle/out/smoke_test.ppm`, while the gate read `out/smoke_test.ppm` at the repository root, found nothing and skipped. The artifact upload, pointed at the same wrong path, uploaded `test-output.txt` alone (294 bytes). The gate now reads where the example writes, fails and names the file when the capture or the golden is absent, and always re-runs, since it reads a file no input of the step declares. It leaves `zig build test`, where no cell can produce the capture and it could only skip, and `dead-tests` declares it uncollected. The workflow's `exit 0` for an absent golden goes: the golden is committed. `tests/render/capture.zig` is deleted: its spawning test duplicated the smoke job's triangle run plus this gate and skipped on every cell (no triangle binary is built in `zig build test`), and its two helper tests duplicated the gate's own. Witnessed locally: no capture fails with `PpmAbsent` naming the path; a copy of the golden passes 3/3; the same copy shifted by 8 in every channel fails. Floor 2576 -> 2570 / 2568, measured: 2553/2570 passed, 17 skipped. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 8 +- build.zig | 37 +++--- tests/render/capture.zig | 183 ------------------------------ tests/render/ppm_psnr_compare.zig | 33 +++--- tools/weld_lint/dead_tests.zig | 11 +- 5 files changed, 40 insertions(+), 232 deletions(-) delete mode 100644 tests/render/capture.zig diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09fde0d1..62a71b82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -332,12 +332,6 @@ jobs: - name: Verify PSNR vs golden run: | set -euo pipefail - if [ ! -f tests/golden/smoke_test_software.ppm ]; then - echo "Golden PPM not yet committed — capture written to out/smoke_test.ppm." >&2 - echo "First-run procedure: download out/smoke_test.ppm from this job, validate visually," >&2 - echo "commit it as tests/golden/smoke_test_software.ppm, then re-run." >&2 - exit 0 - fi zig build test-ppm-psnr -Doptimize=ReleaseSafe -Dcpu=${{ env.ZIG_CPU }} --summary all 2>&1 | tee test-output.txt - name: Upload capture artifact @@ -346,7 +340,7 @@ jobs: with: name: runtime-smoke-test-capture path: | - out/smoke_test.ppm + examples/triangle/out/smoke_test.ppm test-output.txt retention-days: 30 diff --git a/build.zig b/build.zig index 2661862a..3501304d 100644 --- a/build.zig +++ b/build.zig @@ -872,12 +872,8 @@ pub fn build(b: *std.Build) void { /// when set, imports the `.d.etch` emitter and the toy /// service, so a test exercises the SAME functions `bindgen-check` runs. bindgen_detch: bool = false, - /// when set, create a dedicated `zig build - /// ` step that runs ONLY this test. Used by the CI - /// runtime-smoke-test job to gate strictly on the capture PSNR - /// without re-running every other test in the repo (some of - /// which have unrelated ReleaseSafe issues tracked as - /// out-of-scope debt). + /// when set, also create a `zig build ` step that runs ONLY + /// this test. dedicated_step: ?[]const u8 = null, }; const test_specs = [_]TestSpec{ @@ -1091,23 +1087,9 @@ pub fn build(b: *std.Build) void { // Shader disk cache round-trip (hit/miss source change / // miss glslc version change). .{ .path = "tests/render/shader_cache.zig", .render = true }, - // smoke-test capture PSNR vs golden. - // Skip if the platform has no Vulkan window backend or if the golden - // has not yet been committed. - // `dedicated_step` exposes `zig build test-render-capture` so - // the CI runtime-smoke-test job can run only this test (the - // generic `zig build test` pulls in the whole repo, including - // unrelated tests with current ReleaseSafe issues — tracked as - // housekeeping debt). - .{ .path = "tests/render/capture.zig", .render = true, .dedicated_step = "test-render-capture" }, // GAL capture helper surface coverage (encodePpm + // Device.captureFrameToPPM); §13 consumer test, runs on every platform. .{ .path = "tests/render/capture_helper.zig", .render = true }, - // direct PSNR gate reading the pre-produced - // out/smoke_test.ppm with no rebuild and no triangle re-spawn. - // std-only (no .render), so `zig build test-ppm-psnr` compiles in - // seconds and replaces the rebuild-heavy `test-render-capture` in CI. - .{ .path = "tests/render/ppm_psnr_compare.zig", .dedicated_step = "test-ppm-psnr" }, // hot-reload filewatch latency < 200 ms. // Skip if glslc absent from PATH. .{ .path = "tests/render/shader_hot_reload.zig", .render = true }, @@ -1220,6 +1202,21 @@ pub fn build(b: *std.Build) void { } } + // `zig build test-ppm-psnr` is OUT of `test_step`: it reads the capture + // `run-example-triangle` writes, which only the lavapipe CI job produces. + // `dead-tests` declares the file uncollected for that reason. + { + const psnr_mod = b.createModule(.{ + .root_source_file = b.path("tests/render/ppm_psnr_compare.zig"), + .target = target, + .optimize = optimize, + }); + const psnr_run = b.addRunArtifact(b.addTest(.{ .root_module = psnr_mod })); + psnr_run.has_side_effects = true; + const psnr_step = b.step("test-ppm-psnr", "Compare the smoke-test capture with the golden (after run-example-triangle)"); + psnr_step.dependOn(&psnr_run.step); + } + // `zig build test-stress` builds and runs ONLY the // scheduler-livelock stress test. It is deliberately OUT of `test_step` and // must STAY out: the 100× stress-signal loop is a local validation tool, not diff --git a/tests/render/capture.zig b/tests/render/capture.zig deleted file mode 100644 index 6eb10249..00000000 --- a/tests/render/capture.zig +++ /dev/null @@ -1,183 +0,0 @@ -//! Smoke-test capture PSNR. -//! -//! Drives `examples/triangle` in capture mode and compares the produced PPM -//! against `tests/golden/smoke_test_software.ppm`. Skipped on a platform with -//! no Vulkan window backend, and when the golden is not committed: it is -//! generated once on Linux + lavapipe + weston headless, checked visually, then -//! committed. -//! -//! PSNR formula: 20 * log10(MAX_I / sqrt(MSE)). Gate is ≥ 40 dB which -//! tolerates the typical ±1/255 quantization noise across compositors -//! while still catching genuine rendering regressions (a single channel -//! shifted by 5/255 already drops below the gate). - -const std = @import("std"); -const builtin = @import("builtin"); - -const FRAME_WIDTH: u32 = 1280; -const FRAME_HEIGHT: u32 = 720; -const CAPTURE_FRAME: u32 = 10; -const PSNR_GATE_DB: f64 = 40.0; -const GOLDEN_PATH: []const u8 = "tests/golden/smoke_test_software.ppm"; -const CAPTURED_PATH: []const u8 = "out/smoke_test.ppm"; - -fn supportsVulkanWindow() bool { - return switch (builtin.os.tag) { - .windows, .linux => true, - else => false, - }; -} - -fn fileExists(io: std.Io, path: []const u8) bool { - var f = std.Io.Dir.cwd().openFile(io, path, .{}) catch return false; - f.close(io); - return true; -} - -fn locateTriangleBinary(allocator: std.mem.Allocator, io: std.Io) ![]u8 { - const candidates = [_][]const u8{ - "examples/triangle/zig-out/bin/triangle", - "zig-out/bin/triangle", - }; - for (candidates) |c| { - if (fileExists(io, c)) return allocator.dupe(u8, c); - } - return error.TriangleBinaryNotFound; -} - -fn readPpm(allocator: std.mem.Allocator, io: std.Io, path: []const u8) ![]u8 { - var file = try std.Io.Dir.cwd().openFile(io, path, .{}); - defer file.close(io); - - const stat = try file.stat(io); - const bytes = try allocator.alloc(u8, stat.size); - errdefer allocator.free(bytes); - var read_buf: [4096]u8 = undefined; - var reader = file.reader(io, &read_buf); - try reader.interface.readSliceAll(bytes); - - // Parse the P6 header: "P6\n \n255\n". - if (bytes.len < 11) return error.InvalidHeader; - if (!std.mem.startsWith(u8, bytes, "P6\n")) return error.UnsupportedFormat; - - var cursor: usize = 3; - // - while (cursor < bytes.len and bytes[cursor] != '\n') cursor += 1; - if (cursor >= bytes.len) return error.InvalidHeader; - cursor += 1; - // 255 - while (cursor < bytes.len and bytes[cursor] != '\n') cursor += 1; - if (cursor >= bytes.len) return error.InvalidHeader; - cursor += 1; - - const pixel_bytes = bytes.len - cursor; - const expected: usize = @as(usize, FRAME_WIDTH) * FRAME_HEIGHT * 3; - if (pixel_bytes != expected) return error.SizeMismatch; - - const out = try allocator.alloc(u8, expected); - @memcpy(out, bytes[cursor..]); - allocator.free(bytes); - return out; -} - -fn psnrDb(a: []const u8, b: []const u8) f64 { - std.debug.assert(a.len == b.len); - var sse: u128 = 0; - for (a, b) |x, y| { - const dx: i32 = @as(i32, x) - @as(i32, y); - sse += @intCast(dx * dx); - } - if (sse == 0) return std.math.inf(f64); - const mse: f64 = @as(f64, @floatFromInt(sse)) / @as(f64, @floatFromInt(a.len)); - return 20.0 * std.math.log10(255.0 / @sqrt(mse)); -} - -test "capture pass produces PPM matching golden within PSNR 40 dB" { - if (!supportsVulkanWindow()) return error.SkipZigTest; - const io = std.testing.io; - if (!fileExists(io, GOLDEN_PATH)) return error.SkipZigTest; - - const allocator = std.testing.allocator; - const triangle = locateTriangleBinary(allocator, io) catch return error.SkipZigTest; - defer allocator.free(triangle); - - var capture_arg_buf: [64]u8 = undefined; - const capture_arg = try std.fmt.bufPrint(&capture_arg_buf, "--capture-frame={d}", .{CAPTURE_FRAME}); - - const argv = [_][]const u8{ - triangle, - "--smoke-test", - "--vulkan-driver=software", - capture_arg, - }; - // Spawn failure (subprocess could not even start) is a setup - // condition — skip. Once the subprocess has launched, any non-zero - // exit or abnormal termination is a real regression and is surfaced - // via `error.TriangleCaptureFailed`. - const result = std.process.run(allocator, io, .{ - .argv = &argv, - }) catch return error.SkipZigTest; - defer allocator.free(result.stdout); - defer allocator.free(result.stderr); - switch (result.term) { - .exited => |code| { - if (code != 0) { - std.log.err( - "triangle binary exited with code {d}, stderr:\n{s}", - .{ code, result.stderr }, - ); - return error.TriangleCaptureFailed; - } - }, - .signal => |sig| { - std.log.err( - "triangle binary terminated by signal {any}, stderr:\n{s}", - .{ sig, result.stderr }, - ); - return error.TriangleCaptureFailed; - }, - .stopped => |sig| { - std.log.err( - "triangle binary stopped by signal {any}, stderr:\n{s}", - .{ sig, result.stderr }, - ); - return error.TriangleCaptureFailed; - }, - .unknown => |code| { - std.log.err( - "triangle binary terminated abnormally (raw code {any}), stderr:\n{s}", - .{ code, result.stderr }, - ); - return error.TriangleCaptureFailed; - }, - } - - const captured = readPpm(allocator, io, CAPTURED_PATH) catch |e| { - std.log.warn("capture: failed to read produced PPM at {s}: {t}", .{ CAPTURED_PATH, e }); - return error.SkipZigTest; - }; - defer allocator.free(captured); - - const golden = try readPpm(allocator, io, GOLDEN_PATH); - defer allocator.free(golden); - - const psnr = psnrDb(captured, golden); - std.log.info("capture PSNR vs golden: {d:.2} dB (gate {d:.2})", .{ psnr, PSNR_GATE_DB }); - try std.testing.expect(psnr >= PSNR_GATE_DB); -} - -test "PSNR helper: identical inputs report infinity" { - const a = [_]u8{ 1, 2, 3 }; - const b = [_]u8{ 1, 2, 3 }; - try std.testing.expect(std.math.isInf(psnrDb(&a, &b))); -} - -test "PSNR helper: 1-unit average error sits around 48 dB" { - var a: [3072]u8 = undefined; - var b: [3072]u8 = undefined; - for (a[0..]) |*v| v.* = 128; - for (b[0..]) |*v| v.* = 129; - const psnr = psnrDb(&a, &b); - try std.testing.expect(psnr > 46.0); - try std.testing.expect(psnr < 50.0); -} diff --git a/tests/render/ppm_psnr_compare.zig b/tests/render/ppm_psnr_compare.zig index b7841437..438a7075 100644 --- a/tests/render/ppm_psnr_compare.zig +++ b/tests/render/ppm_psnr_compare.zig @@ -1,16 +1,10 @@ -//! The direct PPM PSNR gate. +//! The smoke-test PSNR gate. //! -//! Reads the smoke-test capture at `out/smoke_test.ppm`, produced by a prior -//! `run-example-triangle --smoke-test --capture-frame=N` step, and compares it -//! against the committed golden by PSNR — WITHOUT rebuilding the render stack -//! and WITHOUT re-running the triangle. Going through -//! `zig build test-render-capture` instead rebuilds the test target in -//! ReleaseSafe and re-spawns the triangle for a PPM the previous step has -//! already produced, at a measured 3-5 minutes per CI run. -//! -//! This module imports only `std` (no `weld_render`), so `zig build -//! test-ppm-psnr` compiles in seconds. The gate skips when either PPM is -//! absent (e.g. run locally without first producing the capture). +//! Compares the capture `zig build run-example-triangle -- --smoke-test +//! --capture-frame=N` writes against the committed golden. It runs only in the +//! CI job that produces that capture on lavapipe, through `zig build +//! test-ppm-psnr`, and is outside `zig build test`: no other cell can produce +//! the capture. A missing capture or golden fails the gate and names the file. const std = @import("std"); @@ -18,7 +12,8 @@ const FRAME_WIDTH: u32 = 1280; const FRAME_HEIGHT: u32 = 720; const PSNR_GATE_DB: f64 = 40.0; const GOLDEN_PATH: []const u8 = "tests/golden/smoke_test_software.ppm"; -const CAPTURED_PATH: []const u8 = "out/smoke_test.ppm"; +/// The example runs from its own directory and writes `out/smoke_test.ppm` there. +const CAPTURED_PATH: []const u8 = "examples/triangle/out/smoke_test.ppm"; fn fileExists(io: std.Io, path: []const u8) bool { var f = std.Io.Dir.cwd().openFile(io, path, .{}) catch return false; @@ -76,16 +71,14 @@ fn psnrDb(a: []const u8, b: []const u8) f64 { test "ppm psnr gate: captured smoke frame matches golden within 40 dB" { const io = std.testing.io; - if (!fileExists(io, CAPTURED_PATH)) return error.SkipZigTest; - if (!fileExists(io, GOLDEN_PATH)) return error.SkipZigTest; - const allocator = std.testing.allocator; - const captured = readPpm(allocator, io, CAPTURED_PATH) catch |e| { - std.log.warn("ppm-psnr: failed to read {s}: {t}", .{ CAPTURED_PATH, e }); - return error.SkipZigTest; + for ([_][]const u8{ CAPTURED_PATH, GOLDEN_PATH }) |path| if (!fileExists(io, path)) { + std.log.err("ppm-psnr: {s} is absent", .{path}); + return error.PpmAbsent; }; - defer allocator.free(captured); + const captured = try readPpm(allocator, io, CAPTURED_PATH); + defer allocator.free(captured); const golden = try readPpm(allocator, io, GOLDEN_PATH); defer allocator.free(golden); diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index df20c494..cba8f2dc 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -208,6 +208,13 @@ pub const uncollected = [_]Uncollected{ "attempts failed to localise: it was never dead and never missing, it was in a " ++ "step the suite does not run", }, + .{ + .path = "tests/render/ppm_psnr_compare.zig", + .blocks = 3, + .reason = "the smoke-test PSNR gate hangs off `zig build test-ppm-psnr` and is kept " ++ + "OUT of `zig build test`: it reads the capture `run-example-triangle` writes, " ++ + "which only the lavapipe CI job produces, and fails where that capture is absent", + }, }; /// The number of test blocks `zig build test` COLLECTS on each platform. @@ -237,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2574, - else => 2576, + .windows => 2568, + else => 2570, }; } From dbcc69d5e8f9925c5b27acebe73c544e03cd1919 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 23 Sep 2026 16:42:20 +0200 Subject: [PATCH 031/141] test(render): run the environment-bound tests on the cell that has it Six tests skip on every CI cell because their environment exists on none of them: the two Vulkan device tests of `gal_vulkan_offline` (no loader or ICD), `wayland_open_close`, `wayland_thread_safety` and the Linux path of `multi_monitor` (no compositor), and `shader_hot_reload` (no glslc). None has ever run in CI. The smoke job has weston and lavapipe; it now installs glslc too and runs `zig build test-runtime-env`, which compiles those six files a second time with `test_env.required` set. There an absent environment fails and names what is missing; in `zig build test` it stays a skip. Every skip site of the six goes through `test_env.absent`, the platform checks included, so the step cannot pass on a host that lacks what it claims to test. Witnessed on macOS: the step reports 3/8 passed, the five failures each naming its absence (a Linux host, a Win32 or Wayland host, a Vulkan host), and hot-reload passes with glslc present. Floor unchanged at 2570 / 2568, measured: 2553/2570 passed, 17 skipped. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 10 +- build.zig | 176 ++++++++++-------- tests/platform/multi_monitor_test.zig | 14 +- tests/platform/wayland_thread_safety_test.zig | 10 +- tests/render/gal_vulkan_offline.zig | 28 +-- tests/render/shader_hot_reload.zig | 14 +- tests/support/test_env.zig | 16 ++ tests/window/wayland_open_close_test.zig | 17 +- 8 files changed, 158 insertions(+), 127 deletions(-) create mode 100644 tests/support/test_env.zig diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62a71b82..f0d004bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -286,7 +286,8 @@ jobs: mesa-vulkan-drivers \ libvulkan1 \ libwayland-client0 \ - libwayland-cursor0 + libwayland-cursor0 \ + glslc - name: Locate lavapipe ICD on the runner run: | @@ -334,6 +335,13 @@ jobs: set -euo pipefail zig build test-ppm-psnr -Doptimize=ReleaseSafe -Dcpu=${{ env.ZIG_CPU }} --summary all 2>&1 | tee test-output.txt + - name: zig build test-runtime-env + env: + VK_ICD_FILENAMES: /usr/share/vulkan/icd.d/lvp_icd.json + run: | + set -euo pipefail + zig build test-runtime-env -Dcpu=${{ env.ZIG_CPU }} --summary all + - name: Upload capture artifact if: always() uses: actions/upload-artifact@v6 diff --git a/build.zig b/build.zig index 3501304d..b938b5cf 100644 --- a/build.zig +++ b/build.zig @@ -844,6 +844,10 @@ pub fn build(b: *std.Build) void { /// `stub_install_steps[]` so the three stub libraries are /// built before the test runs. needs_stub_plugins: bool = false, + /// when set, the test imports `test_env` and is compiled a second time, + /// with an absent environment made a failure, into + /// `zig build test-runtime-env`. + runtime_env: bool = false, /// when set, imports the `weld_audio` module. audio: bool = false, /// when set, imports the `weld_render` module (GAL public @@ -937,7 +941,7 @@ pub fn build(b: *std.Build) void { .{ .path = "tests/jobs/deque_test.zig" }, .{ .path = "tests/jobs/scheduler_test.zig" }, .{ .path = "tests/window/win32_open_close_test.zig" }, - .{ .path = "tests/window/wayland_open_close_test.zig" }, + .{ .path = "tests/window/wayland_open_close_test.zig", .runtime_env = true }, .{ .path = "tests/bindings/vk_abi_test.zig" }, .{ .path = "tests/bindings/wayland_abi_test.zig", .wl_protocols = true }, .{ .path = "tests/etch/corpus_test.zig", .etch = true }, @@ -1062,9 +1066,9 @@ pub fn build(b: *std.Build) void { // Win32 thread safety stress (Windows runner only). .{ .path = "tests/platform/win32_thread_safety_test.zig" }, // Wayland thread safety stress (Linux runner only). - .{ .path = "tests/platform/wayland_thread_safety_test.zig" }, + .{ .path = "tests/platform/wayland_thread_safety_test.zig", .runtime_env = true }, // Multi-monitor enumeration + current monitor + per-monitor DPI. - .{ .path = "tests/platform/multi_monitor_test.zig" }, + .{ .path = "tests/platform/multi_monitor_test.zig", .runtime_env = true }, // WindowEvent union surface validation. .{ .path = "tests/platform/window_events_test.zig" }, // Input Tier 0 (event-driven path, runs on all OSes). @@ -1075,7 +1079,7 @@ pub fn build(b: *std.Build) void { // GAL Null backend smoke + interface check (CI headless). .{ .path = "tests/render/gal_null_smoke.zig", .render = true }, // GAL Vulkan backend offline init test (skip if Vulkan absent). - .{ .path = "tests/render/gal_vulkan_offline.zig", .render = true }, + .{ .path = "tests/render/gal_vulkan_offline.zig", .render = true, .runtime_env = true }, // Render graph topological sort + cycle detection. .{ .path = "tests/render/render_graph_topo.zig", .render = true }, // Render graph auto-tracking barriers (write-after-read, @@ -1091,8 +1095,7 @@ pub fn build(b: *std.Build) void { // Device.captureFrameToPPM); §13 consumer test, runs on every platform. .{ .path = "tests/render/capture_helper.zig", .render = true }, // hot-reload filewatch latency < 200 ms. - // Skip if glslc absent from PATH. - .{ .path = "tests/render/shader_hot_reload.zig", .render = true }, + .{ .path = "tests/render/shader_hot_reload.zig", .render = true, .runtime_env = true }, // vk_gen whitelist closure (variant filtering + closure // convergence under 20 iterations). .{ .path = "tests/vk_gen/whitelist_closure.zig" }, @@ -1126,79 +1129,100 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); watchdog_module.addImport("weld_core", core_module); - for (test_specs) |spec| { - const t_mod = b.createModule(.{ - .root_source_file = b.path(spec.path), + var test_env_modules: [2]*std.Build.Module = undefined; + for (&test_env_modules, [_]bool{ false, true }) |*m, required| { + const env_options = b.addOptions(); + env_options.addOption(bool, "required", required); + m.* = b.createModule(.{ + .root_source_file = b.path("tests/support/test_env.zig"), .target = target, .optimize = optimize, }); - t_mod.addImport("weld_core", core_module); - t_mod.addImport("test_watchdog", watchdog_module); - if (spec.wl_protocols) { - t_mod.addImport("wl_protocols", wl_protocols_test_module); - } - if (spec.etch) { - t_mod.addImport("weld_etch", etch_module); - t_mod.addImport("corpus_facade", etch_corpus_module); - } - if (spec.etch_interp) { - t_mod.addImport("weld_etch", etch_module); - t_mod.addImport("corpus_facade", etch_interp_corpus_module); - t_mod.addImport("diff_runner", etch_interp_driver_module); - t_mod.addImport("runner_interp", etch_interp_runner_module); - } - if (spec.audio) { - t_mod.addImport("weld_audio", audio_module); - } - if (spec.render) { - t_mod.addImport("weld_render", render_module); - } - if (spec.asset_pipeline) { - t_mod.addImport("weld_asset_pipeline", asset_pipeline_module); - } - if (spec.forge) { - t_mod.addImport("weld_forge", forge_api_module); - t_mod.addImport("forge_3d", forge_3d_module); - t_mod.addImport("forge_sync", forge_sync_module); - t_mod.addImport("forge_module", forge_module); - t_mod.addImport("foundation", foundation_module); - t_mod.addImport("weld_interfaces_physics", interfaces_physics_module); - } - if (spec.foundation) { - t_mod.addImport("foundation", foundation_module); - } - if (spec.scene) { - t_mod.addImport("weld_etch", etch_module); - } - if (spec.etch_events) { - t_mod.addImport("weld_etch", etch_module); - t_mod.addImport("toy_service", toy_service_module); - } - if (spec.arena_slice) { - t_mod.addImport("arena_slice", arena_slice_module); - } - if (spec.physics_service) { - t_mod.addImport("weld_etch", etch_module); - t_mod.addImport("forge_services", forge_services_module); - t_mod.addImport("forge_sensor_events", forge_sensor_events_module); - t_mod.addImport("forge_sync", forge_sync_module); - } - if (spec.bindgen_detch) { - t_mod.addImport("weld_etch", etch_module); - t_mod.addImport("emit_detch", emit_detch_module); - t_mod.addImport("toy_service", toy_service_module); - t_mod.addImport("forge_services", forge_services_module); - t_mod.addImport("forge_sensor_events", forge_sensor_events_module); - } - const t = b.addTest(.{ .root_module = t_mod }); - const t_run = b.addRunArtifact(t); - if (spec.needs_stub_plugins) { - for (stub_install_steps) |s| t_run.step.dependOn(s); - } - test_step.dependOn(&t_run.step); - if (spec.dedicated_step) |name| { - const dedicated = b.step(name, "Run only this test (used by targeted CI gates)"); - dedicated.dependOn(&t_run.step); + m.*.addOptions("test_env_options", env_options); + } + const runtime_env_step = b.step("test-runtime-env", "Run the tests that need a compositor, a Vulkan ICD or glslc; an absent one fails"); + for (test_specs) |spec| { + for ([_]bool{ false, true }) |required| { + if (required and !spec.runtime_env) continue; + const t_mod = b.createModule(.{ + .root_source_file = b.path(spec.path), + .target = target, + .optimize = optimize, + }); + t_mod.addImport("weld_core", core_module); + t_mod.addImport("test_watchdog", watchdog_module); + if (spec.runtime_env) t_mod.addImport("test_env", test_env_modules[@intFromBool(required)]); + if (spec.wl_protocols) { + t_mod.addImport("wl_protocols", wl_protocols_test_module); + } + if (spec.etch) { + t_mod.addImport("weld_etch", etch_module); + t_mod.addImport("corpus_facade", etch_corpus_module); + } + if (spec.etch_interp) { + t_mod.addImport("weld_etch", etch_module); + t_mod.addImport("corpus_facade", etch_interp_corpus_module); + t_mod.addImport("diff_runner", etch_interp_driver_module); + t_mod.addImport("runner_interp", etch_interp_runner_module); + } + if (spec.audio) { + t_mod.addImport("weld_audio", audio_module); + } + if (spec.render) { + t_mod.addImport("weld_render", render_module); + } + if (spec.asset_pipeline) { + t_mod.addImport("weld_asset_pipeline", asset_pipeline_module); + } + if (spec.forge) { + t_mod.addImport("weld_forge", forge_api_module); + t_mod.addImport("forge_3d", forge_3d_module); + t_mod.addImport("forge_sync", forge_sync_module); + t_mod.addImport("forge_module", forge_module); + t_mod.addImport("foundation", foundation_module); + t_mod.addImport("weld_interfaces_physics", interfaces_physics_module); + } + if (spec.foundation) { + t_mod.addImport("foundation", foundation_module); + } + if (spec.scene) { + t_mod.addImport("weld_etch", etch_module); + } + if (spec.etch_events) { + t_mod.addImport("weld_etch", etch_module); + t_mod.addImport("toy_service", toy_service_module); + } + if (spec.arena_slice) { + t_mod.addImport("arena_slice", arena_slice_module); + } + if (spec.physics_service) { + t_mod.addImport("weld_etch", etch_module); + t_mod.addImport("forge_services", forge_services_module); + t_mod.addImport("forge_sensor_events", forge_sensor_events_module); + t_mod.addImport("forge_sync", forge_sync_module); + } + if (spec.bindgen_detch) { + t_mod.addImport("weld_etch", etch_module); + t_mod.addImport("emit_detch", emit_detch_module); + t_mod.addImport("toy_service", toy_service_module); + t_mod.addImport("forge_services", forge_services_module); + t_mod.addImport("forge_sensor_events", forge_sensor_events_module); + } + const t = b.addTest(.{ .root_module = t_mod }); + const t_run = b.addRunArtifact(t); + if (spec.needs_stub_plugins) { + for (stub_install_steps) |s| t_run.step.dependOn(s); + } + if (required) { + t_run.has_side_effects = true; + runtime_env_step.dependOn(&t_run.step); + continue; + } + test_step.dependOn(&t_run.step); + if (spec.dedicated_step) |name| { + const dedicated = b.step(name, "Run only this test (used by targeted CI gates)"); + dedicated.dependOn(&t_run.step); + } } } diff --git a/tests/platform/multi_monitor_test.zig b/tests/platform/multi_monitor_test.zig index d2079819..47d4b96c 100644 --- a/tests/platform/multi_monitor_test.zig +++ b/tests/platform/multi_monitor_test.zig @@ -2,32 +2,32 @@ //! //! `enumerateMonitors`, `currentMonitor` and per-monitor DPI. //! -//! Skipped on platforms without a window subsystem (the stub backend -//! returns error.UnsupportedPlatform for both query functions). +//! Needs a Win32 or Wayland host with a compositor (`test_env`); the stub +//! backend returns error.UnsupportedPlatform for both query functions. const std = @import("std"); const builtin = @import("builtin"); const weld = @import("weld_core"); const window_api = weld.platform.window; +const test_env = @import("test_env"); test "enumerateMonitors + currentMonitor + per-monitor DPI" { // Only Win32 and Wayland implement multi-monitor; the macOS stub // returns UnsupportedPlatform. if (builtin.os.tag != .windows and builtin.os.tag != .linux) { - return error.SkipZigTest; + return test_env.absent("a Win32 or Wayland host"); } const gpa = std.testing.allocator; - // Try to open a window — the Wayland backend needs a live compositor, - // which CI runners (headless) may not have. Skip gracefully. + // The Wayland backend needs a live compositor. var win = window_api.Window.create(gpa, .{ .width = 320, .height = 240 }) catch { - return error.SkipZigTest; + return test_env.absent("a compositor"); }; defer win.destroy(); const monitors = window_api.enumerateMonitors(gpa) catch |err| switch (err) { - error.UnsupportedPlatform => return error.SkipZigTest, + error.UnsupportedPlatform => return test_env.absent("monitor enumeration"), else => return err, }; defer gpa.free(monitors); diff --git a/tests/platform/wayland_thread_safety_test.zig b/tests/platform/wayland_thread_safety_test.zig index 3138cf36..258ee9c7 100644 --- a/tests/platform/wayland_thread_safety_test.zig +++ b/tests/platform/wayland_thread_safety_test.zig @@ -10,6 +10,7 @@ //! Skipped on non-Linux runners. const std = @import("std"); +const test_env = @import("test_env"); const builtin = @import("builtin"); const weld = @import("weld_core"); @@ -54,15 +55,14 @@ fn workerStress(ctx: *Ctx) void { // tested pattern. Phase 0+ multi-window cleanup (cf. wayland.zig live_state // comment) will address this tension. test "concurrent createWindow + destroyWindow" { - if (builtin.os.tag != .linux) return error.SkipZigTest; + if (builtin.os.tag != .linux) return test_env.absent("a Linux host"); const gpa = std.heap.page_allocator; - // CI runners that lack a Wayland compositor would fail on - // create() and trip err_count. We probe once to detect that case - // and skip cleanly. + // Probe first, so a missing compositor is reported as one and not as + // worker errors. var probe = weld.platform.window.Window.create(gpa, .{}) catch { - return error.SkipZigTest; + return test_env.absent("a Wayland compositor"); }; probe.destroy(); diff --git a/tests/render/gal_vulkan_offline.zig b/tests/render/gal_vulkan_offline.zig index 40af2a3d..3d858ecf 100644 --- a/tests/render/gal_vulkan_offline.zig +++ b/tests/render/gal_vulkan_offline.zig @@ -3,42 +3,32 @@ //! The smoke-test PPM in `examples/triangle/` is what covers the swapchain, on //! the three GPU configurations. //! -//! Linux and Windows attempt the Vulkan init through the native loader and skip -//! when the library is absent; macOS skips outright. +//! Needs the Vulkan loader and a device (`test_env`); macOS has neither. const std = @import("std"); const builtin = @import("builtin"); const gal = @import("weld_render"); const vk = @import("weld_core").platform.vk; +const test_env = @import("test_env"); const VulkanAvailable = enum { yes, no }; -/// Attempts to load the Vulkan loader. If the lib is not present, we -/// skip with a warn (the CI runner must be able to run without Vulkan on the -/// default Ubuntu GitHub Actions runner). +/// Whether the Vulkan loader loads. fn detectVulkan() VulkanAvailable { - // Heuristic : if Vulkan does not load, we skip. The env var detection - // (`LAVAPIPE_AVAILABLE` to force-on) relied on - // `std.process.hasEnvVarConstant` / `std.posix.getenv`, removed in - // Zig 0.16. The test is silently skipped if the loader fails — - // enough for the CI that does not have Vulkan installed by default. vk.loadLoader() catch return .no; return .yes; } test "Vulkan backend init and teardown over headless device" { - if (builtin.os.tag == .macos) return error.SkipZigTest; - if (detectVulkan() == .no) return error.SkipZigTest; + if (builtin.os.tag == .macos) return test_env.absent("a Vulkan host (macOS has none)"); + if (detectVulkan() == .no) return test_env.absent("the Vulkan loader"); - // Linux CI often has the loader installed but no GPU. We skip - // silently on init failure (no log.warn — Zig 0.16 - // treats some log levels as test failures). var device = gal.vulkan_backend.Device.init(std.testing.allocator, .{ .label = "offline_test", .vulkan_driver = .auto, .gpu_preference = .auto, .enable_validation = false, - }) catch return error.SkipZigTest; + }) catch return test_env.absent("a Vulkan device"); defer device.deinit(); // Sanity : feature query without crash, getQueue returns a non-null handle. @@ -53,10 +43,10 @@ test "Vulkan backend satisfies comptime interface check" { } test "Vulkan backend Device struct keeps allocator + selection" { - if (builtin.os.tag == .macos) return error.SkipZigTest; - if (detectVulkan() == .no) return error.SkipZigTest; + if (builtin.os.tag == .macos) return test_env.absent("a Vulkan host (macOS has none)"); + if (detectVulkan() == .no) return test_env.absent("the Vulkan loader"); - var device = gal.vulkan_backend.Device.init(std.testing.allocator, .{}) catch return error.SkipZigTest; + var device = gal.vulkan_backend.Device.init(std.testing.allocator, .{}) catch return test_env.absent("a Vulkan device"); defer device.deinit(); // The device name is filled in (terminated by a null byte). diff --git a/tests/render/shader_hot_reload.zig b/tests/render/shader_hot_reload.zig index 2e1ed829..bc5e6db6 100644 --- a/tests/render/shader_hot_reload.zig +++ b/tests/render/shader_hot_reload.zig @@ -3,15 +3,11 @@ //! Drops a probe `.frag.glsl` into `assets/shaders/`, starts the //! `shader_pipeline.hot_reload` watcher on a 10 ms poll interval, and measures //! the time between the probe's creation and the `on_recompile` callback. The -//! specified gate is < 200 ms. -//! -//! Skipped when: -//! - `glslc` is absent from PATH (the watcher's documented behavior in -//! that case is to log a warning and exit `start` without spawning the -//! thread — nothing to measure). -//! - The probe file cannot be created (read-only checkout, CI quirks). +//! specified gate is < 200 ms. Needs `glslc` (`test_env`): without it the +//! watcher does not start. const std = @import("std"); +const test_env = @import("test_env"); const builtin = @import("builtin"); const hot_reload = @import("weld_render").shader_pipeline.hot_reload; const compiler_mod = @import("weld_render").shader_pipeline.compiler; @@ -66,9 +62,9 @@ test "filewatch triggers recompile under 200 ms" { const allocator = std.testing.allocator; const io = std.testing.io; - if (!compiler_mod.isAvailable(allocator, io)) return error.SkipZigTest; + if (!compiler_mod.isAvailable(allocator, io)) return test_env.absent("glslc"); - writeProbe(io) catch return error.SkipZigTest; + writeProbe(io) catch return test_env.absent("a writable assets/shaders"); defer deleteProbe(io); var state = ProbeState{}; diff --git a/tests/support/test_env.zig b/tests/support/test_env.zig new file mode 100644 index 00000000..7d4d12c7 --- /dev/null +++ b/tests/support/test_env.zig @@ -0,0 +1,16 @@ +//! What a test does when the environment it needs is absent: a compositor, a +//! Vulkan ICD, `glslc`, or the platform itself. +//! +//! In `zig build test` it skips. `zig build test-runtime-env` compiles the same +//! tests with `required` set, for the CI job that provides that environment, +//! and there an absence fails and names what is missing. + +const std = @import("std"); +const options = @import("test_env_options"); + +/// The skip, or the failure where the environment is required. +pub fn absent(what: []const u8) error{ SkipZigTest, EnvironmentAbsent } { + if (!options.required) return error.SkipZigTest; + std.log.err("required environment absent: {s}", .{what}); + return error.EnvironmentAbsent; +} diff --git a/tests/window/wayland_open_close_test.zig b/tests/window/wayland_open_close_test.zig index a7ea015f..f22c861c 100644 --- a/tests/window/wayland_open_close_test.zig +++ b/tests/window/wayland_open_close_test.zig @@ -1,29 +1,26 @@ -//! Mirrors the Win32 50× open/close gate, on the Wayland leg. Skips: -//! * On non-Linux hosts (no Wayland backend in scope). -//! * On Linux hosts without a running compositor (CI runners, -//! `WAYLAND_DISPLAY` unset, or `wl_display_connect` returning null) — -//! the actual hardware validation runs from a developer session via -//! `--smoke-test`, not from `zig build test`. +//! Mirrors the Win32 50× open/close gate, on the Wayland leg. Needs a Linux +//! host and a compositor (`test_env`). const std = @import("std"); const builtin = @import("builtin"); const weld_core = @import("weld_core"); const window = weld_core.platform.window; +const test_env = @import("test_env"); test "wayland backend opens and closes 50 windows without leaking" { - if (builtin.os.tag != .linux) return error.SkipZigTest; + if (builtin.os.tag != .linux) return test_env.absent("a Linux host"); const gpa = std.testing.allocator; - // Probe before the 50× loop so a missing compositor short-circuits - // cleanly with `error.SkipZigTest` instead of failing the test. + // Probe first, so a missing compositor is reported as one and not as a + // failure of the loop. { var probe = window.Window.create(gpa, .{ .title = "Weld S2 — probe", .width = 320, .height = 240, }) catch |err| switch (err) { - error.UnsupportedPlatform, error.BackendInitFailed => return error.SkipZigTest, + error.UnsupportedPlatform, error.BackendInitFailed => return test_env.absent("a Wayland compositor"), else => return err, }; probe.destroy(); From 92ff73b2a6e82aae65a76cda8415a6bbd214c4fe Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 23 Sep 2026 16:42:41 +0200 Subject: [PATCH 032/141] fix(render): make shaders-check fail when glslc cannot answer `shaders-check` had four false greens and one false red, measured on the tool at 4803755a: - glslc absent: "skipping", exit 0, in both modes; - a `.glsl` whose name carries no stage: skipped in silence, exit 0; - an unreadable `.glsl`: "failed to read", exit 0; - a glslc that refuses every shader: exit 0 in build mode; - the same glslc in check mode: reported as DRIFT, a verdict on the committed `.spv` drawn from a tool that never answered. And `examples/vertical_slice/shaders/` was never checked. The tool now exits with the most severe outcome it met, and only two of them are verdicts: 0 no drift, 1 drift (a `.spv` differs or is missing); 2 glslc unavailable, 3 a shader could not be read, compiled or written, a `.glsl` carries no stage, or a directory holds none. It checks both shader directories and reports on stderr, the stream `zig build` shows for a failing step (stdout is dropped there). `compile` passed the source through `/tmp/weld_shader_N.glsl`, a path with no meaning on Windows; glslc now reads it on stdin, byte-identical output measured on the six committed shaders. The hot-reload watcher and the tool derive a stage from a file name through one `Stage.ofFileName`. Witnessed through `zig build`, each naming its cause: drift 1, missing `.spv` 1, glslc absent 2 (check and build), a refusing glslc 3 (check and build), no stage 3, empty directory 3, unreadable source 3; a regeneration with glslc present rewrites the six `.spv` unchanged. Floor 2570 -> 2573 / 2571, measured: 2556/2573 passed, 17 skipped. Co-Authored-By: Claude Opus 5.5 --- build.zig | 2 + .../render/shader_pipeline/compiler.zig | 116 +++---- .../render/shader_pipeline/hot_reload.zig | 9 +- tools/shader_compiler/main.zig | 288 ++++++++++-------- tools/weld_lint/dead_tests.zig | 4 +- 5 files changed, 230 insertions(+), 189 deletions(-) diff --git a/build.zig b/build.zig index b938b5cf..01773a69 100644 --- a/build.zig +++ b/build.zig @@ -420,6 +420,8 @@ pub fn build(b: *std.Build) void { const test_step = b.step("test", "Run all tests"); + test_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = shader_compiler_module })).step); + // Inline tests living next to the core code. const core_tests = b.addTest(.{ .root_module = core_module }); test_step.dependOn(&b.addRunArtifact(core_tests).step); diff --git a/src/modules/render/shader_pipeline/compiler.zig b/src/modules/render/shader_pipeline/compiler.zig index bef61960..4fe2140c 100644 --- a/src/modules/render/shader_pipeline/compiler.zig +++ b/src/modules/render/shader_pipeline/compiler.zig @@ -1,22 +1,11 @@ -//! Shader compiler. +//! Shader compiler: compiles GLSL to SPIR-V by spawning the `glslc` CLI (no +//! shaderc binding, `ARCH-024`). //! -//! Compiles GLSL shaders into SPIR-V by spawning the `glslc` CLI (consistent -//! with the keeper policy: no shaderc/glslang binding, the keeper count -//! stays at 7). -//! -//! Consistent with "glslc is a peer dependency only -//! for `zig build shaders` (regeneration) or for the dev runtime hot-reload. -//! The standard `zig build` build does not depend on `glslc`." If -//! `glslc` is absent from PATH, `compile` returns `error.GlslcNotFound`. -//! -//! Zig 0.16 API: `std.process.run` (which takes `io: Io`) — not -//! `std.process.Child.init` which no longer exists. +//! `glslc` is needed by `zig build shaders`, `zig build shaders-check` and the +//! dev hot-reload only; `zig build` does not depend on it. const std = @import("std"); -/// Global counter to generate unique temp file names. -var unique_id: std.atomic.Value(u64) = .init(0); - /// The supported shader stages (geometry, tessellation /// raygen/closesthit/miss for RT). pub const Stage = enum { @@ -24,6 +13,15 @@ pub const Stage = enum { fragment, compute, + /// The stage a shader file's name carries (`*.vert*`, `*.frag*`, + /// `*.comp*`), or null when it carries none. + pub fn ofFileName(name: []const u8) ?Stage { + if (std.mem.indexOf(u8, name, ".vert") != null) return .vertex; + if (std.mem.indexOf(u8, name, ".frag") != null) return .fragment; + if (std.mem.indexOf(u8, name, ".comp") != null) return .compute; + return null; + } + pub fn glslcArg(self: Stage) []const u8 { return switch (self) { .vertex => "-fshader-stage=vertex", @@ -42,6 +40,8 @@ pub const CompileError = error{ OutOfMemory, InvalidUtf8, ProcessSpawnFailed, + /// Reading glslc's output or waiting for it failed. + GlslcIoFailed, }; /// Result of a compilation. @@ -60,15 +60,13 @@ pub const Result = struct { } }; -/// Compiles `source` (GLSL text) to SPIR-V via glslc. The caller must -/// pass the correct `stage` (glslc needs it for shader model -/// selection). `entry_point` defaults to "main". -/// -/// Returns `error.GlslcNotFound` if glslc is not findable in -/// PATH — usable as a heuristic to disable hot-reload. +/// Compiles `source` (GLSL text) to SPIR-V via glslc, which reads it on +/// stdin. The caller must pass the correct `stage` (glslc needs it for shader +/// model selection). `entry_point` defaults to "main". /// -/// Uses `std.process.run` (Zig 0.16 API). The caller provides -/// the required `io: std.Io` instance. +/// Returns `error.GlslcNotFound` if glslc is not findable in PATH. A source +/// glslc refuses is not an error: the result carries no SPIR-V and glslc's +/// diagnostics. pub fn compile( allocator: std.mem.Allocator, io: std.Io, @@ -76,23 +74,6 @@ pub fn compile( stage: Stage, entry_point: ?[]const u8, ) CompileError!Result { - // Writes the source to a temp file. Unique name via an atomic - // counter (avoids the dependency on `std.time.nanoTimestamp` which - // no longer exists in Zig 0.16). - const id = unique_id.fetchAdd(1, .monotonic); - var tmp_buf: [128]u8 = undefined; - const tmp_name = std.fmt.bufPrint(&tmp_buf, "/tmp/weld_shader_{d}.glsl", .{id}) catch return error.OutOfMemory; - - { - var file = std.Io.Dir.cwd().createFile(io, tmp_name, .{ .truncate = true }) catch return error.ProcessSpawnFailed; - defer file.close(io); - file.writeStreamingAll(io, source) catch return error.ProcessSpawnFailed; - } - defer { - std.Io.Dir.cwd().deleteFile(io, tmp_name) catch {}; - } - - // Builds argv. var argv: std.ArrayListUnmanaged([]const u8) = .empty; defer argv.deinit(allocator); argv.append(allocator, "glslc") catch return error.OutOfMemory; @@ -104,34 +85,55 @@ pub fn compile( ep_buf = ep_arg; argv.append(allocator, ep_arg) catch return error.OutOfMemory; } - argv.append(allocator, "-o") catch return error.OutOfMemory; - argv.append(allocator, "-") catch return error.OutOfMemory; - argv.append(allocator, tmp_name) catch return error.OutOfMemory; + argv.appendSlice(allocator, &.{ "-o", "-", "-" }) catch return error.OutOfMemory; - const run_result = std.process.run(allocator, io, .{ + var child = std.process.spawn(io, .{ .argv = argv.items, - .stdout_limit = std.Io.Limit.limited(16 * 1024 * 1024), - .stderr_limit = std.Io.Limit.limited(1024 * 1024), + .stdin = .pipe, + .stdout = .pipe, + .stderr = .pipe, }) catch |e| switch (e) { error.FileNotFound => return error.GlslcNotFound, error.OutOfMemory => return error.OutOfMemory, else => return error.ProcessSpawnFailed, }; - defer allocator.free(run_result.stdout); - defer allocator.free(run_result.stderr); + defer child.kill(io); + + // glslc reads all of its input before it writes anything, so the whole + // source goes in before either output is read. A write that fails means + // glslc has exited; its exit status and stderr say why. + child.stdin.?.writeStreamingAll(io, source) catch {}; + child.stdin.?.close(io); + child.stdin = null; + + var multi_reader_buffer: std.Io.File.MultiReader.Buffer(2) = undefined; + var multi_reader: std.Io.File.MultiReader = undefined; + multi_reader.init(allocator, io, multi_reader_buffer.toStreams(), &.{ child.stdout.?, child.stderr.? }); + defer multi_reader.deinit(); + while (multi_reader.fill(64, .none)) |_| {} else |err| switch (err) { + error.EndOfStream => {}, + else => return error.GlslcIoFailed, + } + multi_reader.checkAnyError() catch return error.GlslcIoFailed; + const term = child.wait(io) catch return error.GlslcIoFailed; + + const stdout = multi_reader.toOwnedSlice(0) catch return error.OutOfMemory; + defer allocator.free(stdout); + const stderr = multi_reader.toOwnedSlice(1) catch return error.OutOfMemory; + defer allocator.free(stderr); - switch (run_result.term) { + switch (term) { .exited => |code| if (code != 0) { - const diag = allocator.dupe(u8, run_result.stderr) catch return error.OutOfMemory; + const diag = allocator.dupe(u8, stderr) catch return error.OutOfMemory; return Result{ .spv = &.{}, .diagnostics = diag }; }, else => return error.GlslcCrashed, } // SPIR-V in stdout. Basic validation: ≥ 4 bytes. - if (run_result.stdout.len < 4) return error.GlslSyntaxError; - const spv = allocator.dupe(u8, run_result.stdout) catch return error.OutOfMemory; - const diag = allocator.dupe(u8, run_result.stderr) catch return error.OutOfMemory; + if (stdout.len < 4) return error.GlslSyntaxError; + const spv = allocator.dupe(u8, stdout) catch return error.OutOfMemory; + const diag = allocator.dupe(u8, stderr) catch return error.OutOfMemory; return Result{ .spv = spv, .diagnostics = diag }; } @@ -158,6 +160,14 @@ test "compiler: Stage.glslcArg covers all stages" { try t.expectEqualStrings("-fshader-stage=compute", Stage.compute.glslcArg()); } +test "compiler: a file name gives its stage, or none" { + const t = std.testing; + try t.expectEqual(Stage.vertex, Stage.ofFileName("a.vert.glsl").?); + try t.expectEqual(Stage.fragment, Stage.ofFileName("a.frag.glsl").?); + try t.expectEqual(Stage.compute, Stage.ofFileName("a.comp.glsl").?); + try t.expectEqual(@as(?Stage, null), Stage.ofFileName("a.glsl")); +} + test "compiler: isAvailable does not crash" { // Purely structural test — calls the fn and verifies it returns // a bool without crashing, regardless of the actual presence of glslc. diff --git a/src/modules/render/shader_pipeline/hot_reload.zig b/src/modules/render/shader_pipeline/hot_reload.zig index 0be65706..44e003b3 100644 --- a/src/modules/render/shader_pipeline/hot_reload.zig +++ b/src/modules/render/shader_pipeline/hot_reload.zig @@ -145,14 +145,7 @@ fn recompile(watcher: *Watcher, name: []const u8) !void { var reader = file.reader(watcher.config.io, &read_buf); reader.interface.readSliceAll(source) catch return; - const stage: compiler.Stage = if (std.mem.indexOf(u8, name, ".vert") != null) - .vertex - else if (std.mem.indexOf(u8, name, ".frag") != null) - .fragment - else if (std.mem.indexOf(u8, name, ".comp") != null) - .compute - else - return; + const stage = compiler.Stage.ofFileName(name) orelse return; var result = compiler.compile(watcher.allocator, watcher.config.io, source, stage, null) catch |e| { const msg = std.fmt.allocPrint(watcher.allocator, "compile failed: {t}", .{e}) catch return; diff --git a/tools/shader_compiler/main.zig b/tools/shader_compiler/main.zig index ac442a4a..13e22768 100644 --- a/tools/shader_compiler/main.zig +++ b/tools/shader_compiler/main.zig @@ -1,29 +1,39 @@ -//! Shader compiler tool. +//! Shader compiler tool, behind `zig build shaders` and `zig build shaders-check`. //! -//! Standalone tool invoked by `build.zig` via `zig build shaders` / -//! `zig build shaders-check`. -//! -//! `shaders` mode: -//! - Discovers all `.glsl` under `assets/shaders/`. -//! - For each file, derives the stage from the name (`*.vert.glsl` → -//! vertex, `*.frag.glsl` → fragment, `*.comp.glsl` → compute). -//! - Compiles via a `glslc` CLI spawn (cf. `src/modules/render/shader_pipeline/compiler.zig`). -//! - Writes the `.spv` next to the `.glsl`. Consistent with the committed -//! generated-artifact pattern. -//! -//! `shaders-check` mode: -//! - Compiles into a temp folder. -//! - Diff vs the committed `.spv`. Exit code 0 if diff empty, non-zero otherwise. -//! - No CI workflow runs `shaders-check`. +//! Every `.glsl` of `shader_dirs` compiles through `glslc`; `shaders` writes the +//! `.spv` beside it, `shaders-check` (`--check`) compares with the committed one. +//! The exit code is the `Outcome`, the most severe one met. const std = @import("std"); const shader = @import("shader_pipeline_compiler"); -const SHADERS_DIR = "assets/shaders"; +/// Every directory holding committed `.glsl` and `.spv` pairs. +const shader_dirs = [_][]const u8{ "assets/shaders", "examples/vertical_slice/shaders" }; + +/// What a run established, as its exit code. Only `ok` and `drift` are verdicts; +/// the two others say that no verdict was reached. +const Outcome = enum(u8) { + ok = 0, + /// A committed `.spv` differs from a fresh compilation, or is missing. + drift = 1, + /// `glslc` could not be run at all. + glslc_unavailable = 2, + /// A shader could not be read, compiled or written, a `.glsl` carries no + /// stage, or a directory holds no shader. + incomplete = 3, + + fn severity(o: Outcome) u8 { + return switch (o) { + .ok => 0, + .drift => 1, + .incomplete => 2, + .glslc_unavailable => 3, + }; + } -const Args = struct { - check: bool = false, - quiet: bool = false, + fn worst(a: Outcome, b: Outcome) Outcome { + return if (b.severity() > a.severity()) b else a; + } }; pub fn main(init: std.process.Init) !void { @@ -31,129 +41,155 @@ pub fn main(init: std.process.Init) !void { const io = init.io; const raw = try init.minimal.args.toSlice(init.arena.allocator()); - var args: Args = .{}; + var check = false; + var quiet = false; for (raw[1..]) |a| { - if (std.mem.eql(u8, a, "--check")) args.check = true; - if (std.mem.eql(u8, a, "--quiet")) args.quiet = true; + if (std.mem.eql(u8, a, "--check")) check = true; + if (std.mem.eql(u8, a, "--quiet")) quiet = true; } - var stdout_buf: [4096]u8 = undefined; - var stdout_writer = std.Io.File.stdout().writer(io, &stdout_buf); - const stdout = &stdout_writer.interface; - defer stdout.flush() catch {}; + var stderr_buf: [4096]u8 = undefined; + var stderr_writer = std.Io.File.stderr().writer(io, &stderr_buf); + const out = &stderr_writer.interface; + var outcome: Outcome = .ok; + var handled: u32 = 0; if (!shader.isAvailable(gpa, io)) { - try stdout.print("shader_compiler: glslc not in PATH — skipping ({s} mode)\n", .{ - if (args.check) "check" else "build", - }); - // Without glslc, check mode compares nothing and still exits 0. - return; + try out.print("shader_compiler: glslc is not available on PATH — no verdict is implied\n", .{}); + outcome = .glslc_unavailable; + } else for (shader_dirs) |dir_path| { + outcome = outcome.worst(try runDir(gpa, io, out, dir_path, check, quiet, &handled)); + if (outcome == .glslc_unavailable) break; } - var dir = std.Io.Dir.cwd().openDir(io, SHADERS_DIR, .{ .iterate = true }) catch |e| { - try stdout.print("shader_compiler: failed to open {s}: {t}\n", .{ SHADERS_DIR, e }); - return e; + try out.print("shader_compiler: {s} {d} shader(s): {t}\n", .{ if (check) "checked" else "compiled", handled, outcome }); + try out.flush(); + std.process.exit(@intFromEnum(outcome)); +} + +fn runDir( + gpa: std.mem.Allocator, + io: std.Io, + out: *std.Io.Writer, + dir_path: []const u8, + check: bool, + quiet: bool, + handled: *u32, +) !Outcome { + var dir = std.Io.Dir.cwd().openDir(io, dir_path, .{ .iterate = true }) catch |e| { + try out.print("shader_compiler: cannot open {s}: {t}\n", .{ dir_path, e }); + return .incomplete; }; defer dir.close(io); - var any_drift = false; - var compiled: u32 = 0; + var outcome: Outcome = .ok; + var found: u32 = 0; var it = dir.iterate(); while (try it.next(io)) |entry| { - if (entry.kind != .file) continue; - if (!std.mem.endsWith(u8, entry.name, ".glsl")) continue; - - const stage: shader.Stage = if (std.mem.indexOf(u8, entry.name, ".vert") != null) - .vertex - else if (std.mem.indexOf(u8, entry.name, ".frag") != null) - .fragment - else if (std.mem.indexOf(u8, entry.name, ".comp") != null) - .compute - else - continue; - - const glsl_path = try std.fmt.allocPrint(gpa, "{s}/{s}", .{ SHADERS_DIR, entry.name }); + if (entry.kind != .file or !std.mem.endsWith(u8, entry.name, ".glsl")) continue; + found += 1; + const glsl_path = try std.fmt.allocPrint(gpa, "{s}/{s}", .{ dir_path, entry.name }); defer gpa.free(glsl_path); + const spv_path = try std.fmt.allocPrint(gpa, "{s}/{s}.spv", .{ dir_path, entry.name[0 .. entry.name.len - ".glsl".len] }); + defer gpa.free(spv_path); - var src_file = std.Io.Dir.cwd().openFile(io, glsl_path, .{}) catch |e| { - try stdout.print("shader_compiler: failed to read {s}: {t}\n", .{ glsl_path, e }); - continue; - }; - defer src_file.close(io); - const stat = src_file.stat(io) catch continue; - const src = try gpa.alloc(u8, @intCast(stat.size)); - defer gpa.free(src); - var src_buf: [4096]u8 = undefined; - var reader = src_file.reader(io, &src_buf); - reader.interface.readSliceAll(src) catch continue; - - var result = shader.compile(gpa, io, src, stage, null) catch |e| { - try stdout.print("shader_compiler: {s} compile error {t}\n", .{ glsl_path, e }); - any_drift = true; - continue; + const one = try runShader(gpa, io, out, glsl_path, spv_path, entry.name, check, quiet); + if (one == .ok or one == .drift) handled.* += 1; + outcome = outcome.worst(one); + if (outcome == .glslc_unavailable) return outcome; + } + if (found == 0) { + try out.print("shader_compiler: {s} holds no .glsl\n", .{dir_path}); + return .incomplete; + } + return outcome; +} + +fn runShader( + gpa: std.mem.Allocator, + io: std.Io, + out: *std.Io.Writer, + glsl_path: []const u8, + spv_path: []const u8, + name: []const u8, + check: bool, + quiet: bool, +) !Outcome { + const stage = shader.Stage.ofFileName(name) orelse { + try out.print("shader_compiler: {s} carries no stage (.vert, .frag, .comp)\n", .{glsl_path}); + return .incomplete; + }; + const src = readAll(gpa, io, glsl_path) catch |e| { + try out.print("shader_compiler: cannot read {s}: {t}\n", .{ glsl_path, e }); + return .incomplete; + }; + defer gpa.free(src); + + var result = shader.compile(gpa, io, src, stage, null) catch |e| { + try out.print("shader_compiler: {s}: glslc failed: {t}\n", .{ glsl_path, e }); + return if (e == error.GlslcNotFound) .glslc_unavailable else .incomplete; + }; + defer result.deinit(gpa); + if (result.spv.len == 0) { + try out.print("shader_compiler: {s} does not compile:\n{s}\n", .{ glsl_path, result.diagnostics }); + return .incomplete; + } + + if (!check) { + writeAll(io, spv_path, result.spv) catch |e| { + try out.print("shader_compiler: cannot write {s}: {t}\n", .{ spv_path, e }); + return .incomplete; }; - defer result.deinit(gpa); - - if (result.spv.len == 0) { - try stdout.print("shader_compiler: {s} produced empty SPIR-V — diagnostics:\n{s}\n", .{ glsl_path, result.diagnostics }); - any_drift = true; - continue; - } - - // Builds the expected .spv path. - const spv_name = try std.mem.concat(gpa, u8, &.{ - entry.name[0 .. entry.name.len - 5], // strip ".glsl" - ".spv", - }); - defer gpa.free(spv_name); - const spv_path = try std.fmt.allocPrint(gpa, "{s}/{s}", .{ SHADERS_DIR, spv_name }); - defer gpa.free(spv_path); + if (!quiet) try out.print("shader_compiler: wrote {s} ({d} bytes)\n", .{ spv_path, result.spv.len }); + return .ok; + } - if (args.check) { - var spv_file = std.Io.Dir.cwd().openFile(io, spv_path, .{}) catch { - try stdout.print("shader_compiler[check]: missing {s}\n", .{spv_path}); - any_drift = true; - continue; - }; - defer spv_file.close(io); - const sst = spv_file.stat(io) catch continue; - const committed = try gpa.alloc(u8, @intCast(sst.size)); - defer gpa.free(committed); - var committed_buf: [4096]u8 = undefined; - var creader = spv_file.reader(io, &committed_buf); - creader.interface.readSliceAll(committed) catch continue; - if (!std.mem.eql(u8, committed, result.spv)) { - try stdout.print("shader_compiler[check]: DRIFT {s} ({d} vs {d} bytes)\n", .{ - spv_path, committed.len, result.spv.len, - }); - any_drift = true; - } else if (!args.quiet) { - try stdout.print("shader_compiler[check]: OK {s}\n", .{spv_path}); - } - } else { - var f = std.Io.Dir.cwd().createFile(io, spv_path, .{ .truncate = true }) catch |e| { - try stdout.print("shader_compiler: failed to write {s}: {t}\n", .{ spv_path, e }); - any_drift = true; - continue; - }; - defer f.close(io); - f.writeStreamingAll(io, result.spv) catch |e| { - try stdout.print("shader_compiler: write error on {s}: {t}\n", .{ spv_path, e }); - any_drift = true; - continue; - }; - if (!args.quiet) { - try stdout.print("shader_compiler: wrote {s} ({d} bytes)\n", .{ spv_path, result.spv.len }); - } - } - compiled += 1; + const committed = readAll(gpa, io, spv_path) catch |e| switch (e) { + error.FileNotFound => { + try out.print("shader_compiler[check]: DRIFT {s} is missing\n", .{spv_path}); + return .drift; + }, + else => { + try out.print("shader_compiler[check]: cannot read {s}: {t}\n", .{ spv_path, e }); + return .incomplete; + }, + }; + defer gpa.free(committed); + if (!std.mem.eql(u8, committed, result.spv)) { + try out.print("shader_compiler[check]: DRIFT {s} ({d} vs {d} bytes)\n", .{ spv_path, committed.len, result.spv.len }); + return .drift; } + if (!quiet) try out.print("shader_compiler[check]: OK {s}\n", .{spv_path}); + return .ok; +} + +fn readAll(gpa: std.mem.Allocator, io: std.Io, path: []const u8) ![]u8 { + var file = try std.Io.Dir.cwd().openFile(io, path, .{}); + defer file.close(io); + const bytes = try gpa.alloc(u8, @intCast((try file.stat(io)).size)); + errdefer gpa.free(bytes); + var buf: [4096]u8 = undefined; + var reader = file.reader(io, &buf); + try reader.interface.readSliceAll(bytes); + return bytes; +} - try stdout.print("shader_compiler: {s} {d} shader(s){s}\n", .{ - if (args.check) "checked" else "compiled", - compiled, - if (any_drift) " — DRIFT DETECTED" else "", - }); +fn writeAll(io: std.Io, path: []const u8, bytes: []const u8) !void { + var file = try std.Io.Dir.cwd().createFile(io, path, .{ .truncate = true }); + defer file.close(io); + try file.writeStreamingAll(io, bytes); +} + +test "the most severe outcome wins, whatever the order" { + const all = [_]Outcome{ .ok, .drift, .incomplete, .glslc_unavailable }; + for (all, 0..) |a, i| for (all, 0..) |b, j| { + try std.testing.expectEqual(all[@max(i, j)], a.worst(b)); + }; +} - if (args.check and any_drift) std.process.exit(1); +test "each outcome has its own exit code" { + try std.testing.expectEqual(@as(u8, 0), @intFromEnum(Outcome.ok)); + try std.testing.expectEqual(@as(u8, 1), @intFromEnum(Outcome.drift)); + try std.testing.expectEqual(@as(u8, 2), @intFromEnum(Outcome.glslc_unavailable)); + try std.testing.expectEqual(@as(u8, 3), @intFromEnum(Outcome.incomplete)); } diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index cba8f2dc..e29c1e21 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2568, - else => 2570, + .windows => 2571, + else => 2573, }; } From cfc1d9a9096ca182243fe33f4f40e97aaaffb4fc Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 23 Sep 2026 16:43:27 +0200 Subject: [PATCH 033/141] fix(ci): give the vertical-slice validation checks a positive control Three steps of `vertical-slice-smoke` conclude "validation clean" from the absence of any VUID in a log, and none shows that validation ran: the device loads `VK_LAYER_KHRONOS_validation` only when the loader enumerates it, and says nothing when it does not. The E6 step also discarded the demo's exit code (`|| true`), so a demo that failed to build or start left a log with no VUID and a green step. `VK_LOADER_DEBUG=layer` makes the loader log each layer it inserts; each step now fails unless its log carries the validation layer's insertion. The E6 step keeps the demo's exit code and requires its completion line, and a failure prints the log's tail. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0d004bb..151c4862 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -391,10 +391,15 @@ jobs: - name: Run vertical-slice smoke (Debug, validation layers active) env: VK_ICD_FILENAMES: /usr/share/vulkan/icd.d/lvp_icd.json + # The loader then logs each layer it inserts: without that line, an + # absent VUID says nothing about validation. + VK_LOADER_DEBUG: layer run: | set -euo pipefail mkdir -p out zig build run-vertical-slice -Dcpu=${{ env.ZIG_CPU }} -- --smoke-test --capture out/vertical_slice.ppm 2>&1 | tee slice-smoke.log + grep -qE 'Insert instance layer.*VK_LAYER_KHRONOS_validation' slice-smoke.log \ + || { echo "::error::the validation layer was not loaded — no validation verdict is implied"; exit 1; } echo "--- a frame was composed + captured ---" test -s out/vertical_slice.ppm echo "--- validation-clean (no Vulkan VUID / Validation Error) ---" @@ -407,10 +412,13 @@ jobs: - name: Run vertical-slice C0.8 IPC edit (Debug, validation layers active) env: VK_ICD_FILENAMES: /usr/share/vulkan/icd.d/lvp_icd.json + VK_LOADER_DEBUG: layer run: | set -euo pipefail mkdir -p out zig build run-vertical-slice -Dcpu=${{ env.ZIG_CPU }} -- --ipc-edit --capture out/vertical_slice_ipc.ppm 2>&1 | tee slice-c08.log + grep -qE 'Insert instance layer.*VK_LAYER_KHRONOS_validation' slice-c08.log \ + || { echo "::error::the validation layer was not loaded — no validation verdict is implied"; exit 1; } echo "--- the post-edit frame composed + captured ---" test -s out/vertical_slice_ipc.ppm if grep -E "VUID-|Validation Error" slice-c08.log; then @@ -423,13 +431,22 @@ jobs: env: VK_ICD_FILENAMES: /usr/share/vulkan/icd.d/lvp_icd.json VK_LAYER_ENABLES: VK_VALIDATION_FEATURE_ENABLE_SYNCHRONIZATION_VALIDATION_EXT + VK_LOADER_DEBUG: layer run: | set -uo pipefail mkdir -p /tmp/weld-rt && chmod 700 /tmp/weld-rt XDG_RUNTIME_DIR=/tmp/weld-rt weston --backend=headless --width=1280 --height=720 & sleep 2 export XDG_RUNTIME_DIR=/tmp/weld-rt WAYLAND_DISPLAY=wayland-1 - zig build run-ipc-demo -Dcpu=${{ env.ZIG_CPU }} -- --frames=120 > vkblit.log 2>&1 || true + rc=0 + zig build run-ipc-demo -Dcpu=${{ env.ZIG_CPU }} -- --frames=120 > vkblit.log 2>&1 || rc=$? + if [ "$rc" -ne 0 ] || ! grep -q "ipc demo completed cleanly" vkblit.log; then + tail -n 60 vkblit.log + echo "::error::the ipc demo did not complete (exit $rc) — no validation verdict is implied" + exit 1 + fi + grep -qE 'Insert instance layer.*VK_LAYER_KHRONOS_validation' vkblit.log \ + || { echo "::error::the validation layer was not loaded — no validation verdict is implied"; exit 1; } echo "=== vk_blit GAL blit on lavapipe (E6) ===" echo "--- distinct VUIDs ---" grep -oE "VUID-[A-Za-z0-9-]+" vkblit.log | sort -u || echo " (none)" From 086e49c46f57d785be7d531bfd70baf068ac1e51 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 23 Sep 2026 16:43:30 +0200 Subject: [PATCH 034/141] fix(bench): compile the etch bench's stub for the bench's own target The compile-time bench spawns `zig build-exe` on the cooked corpus with no target and no CPU, so the stub was analysed for the host's native CPU whatever `-Dcpu` built the bench, and `ZigBuildFailed` could answer for a target no cell builds. It now passes `-target` and `-mcpu` derived from `builtin.target`. Co-Authored-By: Claude Opus 5.5 --- bench/etch_compile.zig | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/bench/etch_compile.zig b/bench/etch_compile.zig index eae7e50b..b6c93c28 100644 --- a/bench/etch_compile.zig +++ b/bench/etch_compile.zig @@ -224,7 +224,12 @@ fn runCookInProcess(gpa: std.mem.Allocator, io: std.Io, cwd: std.Io.Dir, paths: } fn runZigBuildExe(gpa: std.mem.Allocator, io: std.Io) !u64 { - _ = gpa; + // The stub compiles for the target and CPU this bench was built for. + const query = std.Target.Query.fromTarget(&builtin.target); + const triple = try query.zigTriple(gpa); + defer gpa.free(triple); + const cpu = try query.serializeCpuAlloc(gpa); + defer gpa.free(cpu); // The Zig CLI applies `--dep X` to the NEXT `-MX=...` module // declaration, so the canonical incantation is // --dep cooked --dep weld_core -Mroot=stub.zig @@ -245,6 +250,10 @@ fn runZigBuildExe(gpa: std.mem.Allocator, io: std.Io) !u64 { "-Mweld_core=src/core/root.zig", "-fno-emit-bin", "-lc", + "-target", + triple, + "-mcpu", + cpu, "--cache-dir", cache_dir, }; From 8df520c84961802b2cff5aac290808042302241c Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 23 Sep 2026 16:52:48 +0200 Subject: [PATCH 035/141] fix(bench): put the stub's target flags before each module 086e49c4 appended `-target` and `-mcpu` after the last `-M`, where the Zig CLI ignores them: a bogus CPU there compiled the stub and the bench reported GO, measured. The CLI applies both, as it applies `--dep`, to the next module declaration, so each of the three modules is now preceded by its own; a bogus CPU on `weld_core` alone now fails the bench with `unknown CPU`. Co-Authored-By: Claude Opus 5.5 --- bench/etch_compile.zig | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/bench/etch_compile.zig b/bench/etch_compile.zig index b6c93c28..69e73ba2 100644 --- a/bench/etch_compile.zig +++ b/bench/etch_compile.zig @@ -230,30 +230,35 @@ fn runZigBuildExe(gpa: std.mem.Allocator, io: std.Io) !u64 { defer gpa.free(triple); const cpu = try query.serializeCpuAlloc(gpa); defer gpa.free(cpu); - // The Zig CLI applies `--dep X` to the NEXT `-MX=...` module - // declaration, so the canonical incantation is - // --dep cooked --dep weld_core -Mroot=stub.zig - // --dep weld_core -Mcooked=cooked.zig - // -Mweld_core=src/core/root.zig - // followed by the build-time flags. + // The Zig CLI applies `--dep X`, `-target` and `-mcpu` to the NEXT + // `-MX=...` module declaration and ignores them after the last one, so + // each module is preceded by its own. const argv = [_][]const u8{ zigPath(), "build-exe", + "-target", + triple, + "-mcpu", + cpu, "--dep", "cooked", "--dep", "weld_core", "-Mroot=zig-out/etch-bench/stub.zig", + "-target", + triple, + "-mcpu", + cpu, "--dep", "weld_core", "-Mcooked=zig-out/etch-bench/cooked.zig", - "-Mweld_core=src/core/root.zig", - "-fno-emit-bin", - "-lc", "-target", triple, "-mcpu", cpu, + "-Mweld_core=src/core/root.zig", + "-fno-emit-bin", + "-lc", "--cache-dir", cache_dir, }; From c931b60bf46d6921526fbbdb439f0d9a14a96403 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 23 Sep 2026 16:52:51 +0200 Subject: [PATCH 036/141] test(render): require the hot-reload recompile to produce SPIR-V The callback recorded that it fired and ignored its SPIR-V, and a failed recompile fires it too: with glslc fed an empty source, the test stayed green, measured. It now asserts the recompile produced SPIR-V. Co-Authored-By: Claude Opus 5.5 --- tests/render/shader_hot_reload.zig | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/render/shader_hot_reload.zig b/tests/render/shader_hot_reload.zig index bc5e6db6..57b6c4e7 100644 --- a/tests/render/shader_hot_reload.zig +++ b/tests/render/shader_hot_reload.zig @@ -35,12 +35,15 @@ const WAIT_TIMEOUT_NS: u64 = 5 * std.time.ns_per_s; const ProbeState = struct { fired: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + /// The recompile produced SPIR-V: a failed one fires the callback too. + compiled: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), end_ns: std.atomic.Value(u64) = std.atomic.Value(u64).init(0), }; fn onRecompile(ctx_opaque: ?*anyopaque, path: []const u8, spv: ?[]const u8, diag: ?[]const u8) void { - _ = .{ path, spv, diag }; + _ = .{ path, diag }; const state: *ProbeState = @ptrCast(@alignCast(ctx_opaque.?)); + state.compiled.store(if (spv) |bytes| bytes.len > 0 else false, .release); state.end_ns.store(time_mod.nowNanos(), .release); state.fired.store(true, .release); } @@ -96,5 +99,6 @@ test "filewatch triggers recompile under 200 ms" { @as(f64, @floatFromInt(elapsed_ns)) / 1e6, LATENCY_GATE_NS / std.time.ns_per_ms, }); + try std.testing.expect(state.compiled.load(.acquire)); try std.testing.expect(elapsed_ns < LATENCY_GATE_NS); } From 3563577119fbbe82b3c492558616bb514a82a3e6 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 23 Sep 2026 17:41:05 +0200 Subject: [PATCH 037/141] docs(brief): record S5/G8 ter up to the M1.D.47 arbitration The seven of G8 bis, closed in the order of the ruling: the enumerations, the counter-factuals with their predictions (and the one batch run without them), the four refuted predictions of batch 6 and the defect each exposed, the tests removed or changed, and the gates. M1.D.47's perimeter measured, and the ruling's premise not holding on three axes, with their witnesses: a persistent borrow freed by a reassignment of its field, arena values escaping through `event`, observer bindings and `emit` payloads, and a type that can hide a composite. Raised as B3; M1.D.41 with M1.D.42, M1.D.34 and the S5 closure come after it and are not started. Co-Authored-By: Claude Opus 5.5 --- briefs/m1.d-phase-1-debt.md | 352 ++++++++++++++++++++++++++++++++++++ 1 file changed, 352 insertions(+) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index 2f3a1df5..01979c98 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -5332,6 +5332,345 @@ On the final code: tests: six in `hot_reload_test.zig`, five inline in `registry.zig` and one inline in `loader.zig`. +### S5/G8 ter — the seven, closed in the order of the ruling + +Guy's ruling on the seven raised at G8 bis: nothing leaves the milestone. Order: +the silent corruptions first (2, then the constant folding of 1), then the rest +of 1, 3, 7, then 4 and 6, then 5. Every correction enumerated at the code before +it was written, every adjacent shown safe or closed. The commit bodies carry the +detail; this records the enumerations, the counter-factuals and what they found. + +#### Item 2 — a type node's name read only when it is named + +Code in `ba10b3cb`, `d6aced99`, `5611beda`. Enumerated: **45** reads of +`named_types` through a type node's `data` (types 11, ast 2, descriptor 4, +interp 7, codegen 21), **11** with no guard on the kind — the ruling's "twenty +codegen sites and two interpreter sites" counted reads, of which the unguarded +were codegen 338, 1256, 1421, 1533, 2519, 3948, 6408 and interp 6248, 6709, 7475, +7903. All 45 go through `AstArena.namedTypeName`, null for any other kind, and +each null branch is written: the codegen refuses, the interpreter binds `.unit`, +skips or refuses. + +Adjacents: 1256 is dominated by 338 (same fields, earlier pass), so the resource +witnesses could not tell the two apart — an event reaches only the struct +emission, and `d6aced99` witnesses it; the three casts are dominated by the +checker's cast gate; 1421 by `fnTypeZig` at 1410. Two closed on the same lines: +`checkRule` refuses every non-named rule parameter, and the interpreter resolves +rule-parameter aliases as the checker and the codegen do. A third found by the +enumeration: fn and method **return** types were never checked, so +`fn f() -> Nope` type-checked (`5611beda`). Instrument: the lint rule +`no_raw_named_type_read`, with a bad fixture run through the real binary. + +Counter-factuals R1–R4: R1 (the seven codegen guards) eleven reds, as predicted; +R2 (`fnTypeZig` infallible) its two predicted reds and one UNPREDICTED — the new +lint rule flags the raw read the mutation reintroduces; R3 (alias resolution) +both; R4 (338 alone) the event witness red, the resource tests green through +1256, as predicted. + +#### Item 1 — a literal or a fold that overflows is refused; overflow wraps only at run time + +Code in `bda4f4cd`, `2e29c78e`. `const_eval.fold` is the one folder, checked in +every mode; the checker admits a constant through it and the interpreter, the +scene cook and the codegen store what it gives, so an admitted default cannot +fall back to zero. The literal range check reaches all 27 callers of +`literalTypeFits` plus the resource instance values, the params-block defaults +and the collection-literal elements under a `let` annotation. At run time both +backends follow part1 §12.4: panic under runtime safety, wrap in ReleaseFast, +integer division by zero a panic in every mode. + +Adjacents closed on the same paths: an omitted struct field took int 0 whatever +its type; an `i32` filter refused an int literal it should accept; the scene +cook's `registerOne` mapped `SchemaChanged`, `FieldOutOfBounds` and +`CollectionDefaultNotEmpty` to OutOfMemory through an `else`, found by making +the switch exhaustive; `true or false` stored false; a string-concatenation +default, which no backend could materialise, is now refused (E1101). + +Counter-factuals, predictions formed before each run (working notes, not a +file): R6a (checker range) 17 reds; R6b (`literalTypeFits`) 5; R7a–R7f +(interpreter: mode policy, narrowing, bridge store, float cast, range end) 11, +2, 2, 4, 3 and one abort; R8a/R8b/R8c (codegen) — R8a and R8c are detected by +the corpus compile itself failing (`division with 'i64' and 'i64'`, +`remainder division with 'f64'`), the emitted program being the measurement; +R8b 4 reds. ReleaseFast run: exit 0, `2475/2495 (20 skipped)`. + +#### Item 3 — a resource collection default is checked against its elements + +Code in `31d78c94`, `397b0943`. Array literal for `T[]`, map literal for +`[K: V]`, `Set.new()` for `Set`, each element a constant of the element type, +a variant for an enum element, a literal within range. The runtime stopped +dropping what it could not read: `[v; n]` materialises `n`, an enum element is +stored as its variant, a repeated map key keeps its last value, anything the +checker refuses is `InvalidProgram`. Adjacents: the scalar enum default (E0105, +E0200), the Hash bound on a map key or set element, and Vec3 / Color / Duration +elements (E0222, which the element set's own text already excludes). + +R9 predicted 17 reds and produced 17 (its first two attempts failed to compile +and measured nothing; the third ran). R10b predicted exactly one — the element +that does not fold — and both allocation sweeps green: as predicted. R10 grouped +five refusals in one test, so a mutation of one form hid behind the first source +still refusing: split into one verdict each (`397b0943`). + +#### Item 7 — a resource is refused as a requisite, on every path + +Code in `f877cfb0`. The registry records a kind; `closeOne`, which every closure +computation reaches, refuses `RequisiteIsResource` and `RequiresOnResource`; +`World.addResource` refuses an id a closure already requires. The G8 bis lock +inverts, as ruled: it pins the refusal. Adjacents: the scene cook maps the two +refusals; the loader refuses an archetype naming a resource and a resource +section naming a component (a crafted scene installed a component as a +resource); the codegen refuses `@requires`, which it dropped; a reload changing +a type's kind or its `@requires` is a schema change, a changed storage mode +staying accepted and unmigrated per §13; a builtin resource name held by +another type is refused. + +R11a–R11f: five as predicted (14, 5, one abort, the two dropped-kind classes); +R11e met a case the prediction had not stated — a reload of a program declaring +a resource is refused on the kind mismatch — recorded as observed, not +predicted. + +#### Items 4 and 6 — the grammar where it is written + +Item 4, `6a73304f`: the argument is a name by two-token lookahead, otherwise an +expression, so `@tag(v as f32)`, `@tag(none)`, `@tag(some(1))` and +`@tag(Name: 1)` parse, each with its witness. Adjacents: the annotation name is +an IDENT as §1.5 writes it; `@storage(none)` stays E0503; the event and +structural observer readers refuse a named argument. + +Item 6, `d1efad76`, `fd82bfc5`: the reader takes the comma between fields and +the rest of the §21.4 value grammar, refuses what it does not produce, and +enforces the §3 schema (`SchemaViolation`). The writer escapes what the reader +decodes. `asset_cook` minted a fresh UUID for an intermediate it could not read, +against §3's « jamais régénérée »; it now fails. + +Counter-factuals R12a, R12b, R13a–R13c: **their predictions were not written +before the run** — a lapse against the standing rule, stated rather than +reconstructed. Read afterwards: R12a's four mutations each map to a named red +(seven in all); R12b one; R13b two; R13c two. R13a bundled seventeen mutations +and gave sixteen reds; the seventeenth — the glTF position check — had no +witness: under an accessor without bounds the computed bounds are infinite too +and the bound check refuses first. Witnessed in `9a1472d4` by a vertex at +inf +under finite accessor bounds, the only case the position check alone decides; +R19 then reddens exactly that test. + +#### Item 5 — the build false greens + +Code in `94979573`, `4803755a`, `22072a41`, `dbcc69d5`, `92ff73b2`, `cfc1d9a9`, +`086e49c4`, `8df520c8`, `c931b60b`. + +- **The PSNR gate had never run.** `test-ppm-psnr` reported `2/3 tests passed + (1 skipped)` in CI at `9d2609cf`: the example writes + `examples/triangle/out/smoke_test.ppm` and the gate read the repository root. + The artifact upload, on the same wrong path, uploaded 294 bytes. The gate now + reads where the example writes, fails naming the absent file, leaves + `zig build test` (declared uncollected) and runs in the smoke job, the one + cell that produces the capture. `capture.zig` is deleted: it skipped on every + cell and duplicated the gate. +- **Six more tests had never run in CI**, the same class: the two Vulkan device + tests, the two Wayland tests, the Linux path of `multi_monitor`, and + `shader_hot_reload`. `test-runtime-env` compiles them again with + `test_env.required`, for the smoke job, which now installs glslc; there an + absent environment fails and names itself. +- **`shaders-check`**, measured on the old tool: glslc absent exit 0 in both + modes, an unstaged `.glsl` skipped, an unreadable one exit 0, a refusing glslc + exit 0 in build mode and **DRIFT** in check mode — the M1.D.0 substitution — + and `examples/vertical_slice/shaders/` never checked. The tool now exits with + the most severe outcome, only two of which are verdicts (0 no drift, 1 drift; + 2 glslc unavailable, 3 incomplete). `compile` no longer writes + `/tmp/weld_shader_N.glsl`, a path with no meaning on Windows: glslc reads the + source on stdin, byte-identical on the six shaders. +- **The dead-test guard** now scans `examples/`, and names + `examples/vertical_slice/math.zig`, now wired. +- **The nested builds** receive `-Dtarget`, `-Dcpu`, `-Doptimize` and + `-Dphysics_f64`. +- Adjacents of the class: the vertical-slice E6 step discarded the demo's exit + code (`|| true`), and its three validation checks concluded "clean" from an + absent VUID with nothing showing the layer loaded — each now requires the + loader's insertion line (`VK_LOADER_DEBUG=layer`); the etch compile bench + spawned `zig build-exe` with no target and no CPU. + +Predictions for batch 6 were written to a file before the run. Nine held. Four +did not, and **each refutation found a defect**: + +| CF | predicted | measured | what it found | +|---|---|---|---| +| R14b / R14c | the file named / the guard blind | both red on conservation alone | the mutation left the floor at 2573; re-posed with it at 2572, R14bf names the file and R14cf is green — as predicted | +| R15b | without `has_side_effects`, the perturbed run replays the pass | red | the build runner passes a random `--seed` that enters the cache key; with `--seed 0x1` fixed, R15bs replays (exit 0, no test run) and the control R15bk is red — the line is load-bearing | +| R16b | glslc fed nothing: hot-reload red | green | the callback ignored its SPIR-V, so a failed recompile passed; `c931b60b` asserts it, and R16bt is then red | +| R18 | a bogus `-mcpu`: the bench fails | exit 0, GO | **my own fix was inert**: the CLI applies `-target`/`-mcpu` to the next `-M` and ignores them after the last; `8df520c8` puts them before each module, and a bogus CPU on `weld_core` alone now fails | + +Two tooling facts measured here: through `zig build`, a failing side-effect +step's stdout is dropped and its stderr shown, which is why the shader tool +reports on stderr; and a log redirected to a file loses the child's lines to +the build runner's own report, written by position — pipe it instead. + +Not reproducible locally and verified on CI only: the gate on a real capture, +`test-runtime-env` under weston, lavapipe and glslc, and the loader's +insertion line. + +**Withdrawn.** The etch compile bench reports Gate 2 NO-GO (about 6 s against +2 s). Measured in Debug, metric (a) — the in-process cook — alone takes 4.9 s; +S5's figures were ReleaseSafe, and cold and incremental compiles are as close +to each other today as they were then. A build-mode artefact, not a regression. + +#### Tests removed or changed + +- `evalConst on tag_path returns UnsupportedExpr` → `… is not a constant it + folds`: same case, the folder's error. +- `DivisionByZero on int` (`intDiv(10, 0) == null`) → `integer division by zero + is refused in every mode`. +- `IntegerOverflow detected in ReleaseSafe` (checked arithmetic null in every + mode) → `integer overflow panics where the mode has runtime safety and wraps + where it does not`: ReleaseFast now wraps, per §12.4. +- `a collection default of the wrong shape is refused at compile` → five tests, + one verdict each. +- `a Zig requisite on a builtin time resource resolves at the first compile` → + `… is refused at the first compile`, the c2 lock inverted as ruled. +- `capture.zig`'s three tests deleted; the PSNR gate's three moved out of + `zig build test` (declared uncollected). +- Two fixtures changed value to satisfy the §3 schema: the round-trip hashes, + and the float test's empty `uuid` and missing blob. +- The six environment-bound tests keep their assertions; their skips go through + `test_env.absent`. `shader_hot_reload` gains an assertion. + +#### `M1.D.47` — the perimeter measured, and the ruling's premise does not hold on three axes + +The ruling: no split of `ResolvedType`; the zone lives beside the type on the +paths describing a constructed value, supplied at construction by the sites +that know; a value whose zone is not known is refused wherever the zone matters. +Measure the perimeter before writing. Measured on a snapshot at `086e49c4` by +three independent read-only enumerations (the 23 local bindings, the value +constructions across 34 type-returning functions, the zone consumers) and a +completeness critic, then by probes run on the tree and retired. **Nothing is +written**, as at S5/G1: the measurement contradicts the premise the ruling rests +on, and the corrections it calls for are decisions. + +**Axis 1 — a persistent value is not safe across anything that reassigns its +field.** A resource read (`get(R).f` for a string or collection) returns a +borrowed view with no incref (`ecs_bridge.zig:365-399`); a whole-field +assignment decrefs the old block (`ecs_bridge.zig:292`, `:323`) and frees it +when the count reaches zero. `etch-memory-model.md` l. 568 says the opposite: +*« `name` est un handle ref vers la string persistent — incref. Quand `name` +sort de scope (fin de rule), decref »*. Measured, each program with **zero +diagnostics**: + +| probe | program | measured | +|---|---|---| +| P0 | an async `for` over a resource `int[]` awaits; another rule reassigns the field | segfault at `0xaaaaaaaaaaaaaaca` | +| P1 | a synchronous `for` over a resource `int[]` whose body reassigns it | segfault at `0xaaaaaaaaaaaaaaca` | +| P3 | `let xs = get(R).items`, then the field reassigned, then `xs[2]` | segfault at `0xaaaaaaaaaaaaaaea` | +| Q5 | a runtime-built string borrowed, the field reassigned, the borrow copied | **no error**: the copy holds 15 bytes of `0xaa` | +| P2 | the same with the field's DEFAULT string | safe: the default block is immortal | + +No suspension is involved in P1, P3 and Q5. So a `persistent` zone does not +make a value safe to hold, and the one persistent acceptance the checker makes +today — iterating a resource collection across an `await` — is P0. + +**Axis 2 — the arena escapes are wider than the three consumers.** Each with +zero diagnostics: + +| probe | program | measured | +|---|---|---| +| Q1 | a timer in an `@on_event` rule reads `event` | ABRT | +| Q2 | a timer in an `@on_added` observer reads `value` | ABRT | +| Q3 | an event field of struct type, emitted from a local, read by an observer | 3 runtime errors | +| Q4 | `for k, v in [1: 10, 2: 20] { await … }` | 9 runtime errors | + +`event` is typed `.event_t` and the observer bindings `.component`, both outside +`isRuleArenaType`, while the runtime binds `struct_ref`s into the per-body store +reset before the timer fires. An event payload escapes into the per-tick store +and no consumer judges it. Q4 is the case pinned at zero in +`interp.zig` for exactly this day. + +**Axis 3 — the type itself is erased, so a zone consulted only for +arena-capable types leaves the class open.** Traced by the critic, not run: the +compatibility gates compare builtin pairs only, so `let v: int = [1, 2, 3]`, +`let v = if c { 0 } else { P { x: 1 } }` (the first branch's type wins), +`some(P { x: 1 }) ?? 0`, an argument bound to a parameter declared `int`, and +`v = P { x: 1 }` on `let mut v = 0` all type a composite as `int`, which every +lifetime check then accepts. And the checker's local map is flat: a `let` inside +a timer or branch body re-types a same-name parent local. + +**Found on the way, not zone questions but zone-dependent runtime behaviour** +(traced): interpolating a persistent string is accepted and fails at run time; +a `match` literal arm never matches a `string_run` or `string_persistent` +scrutinee, the comparison being by pool id; string keys of rule-arena maps and +sets compare the same way, missing and duplicating keys; `Set.from` and slicing +on a persistent array are accepted and fail; a `resource T { … }` filter on a +resource holding an enum field reaches an `unreachable`; prefab +`on_attach`/`on_detach` bodies are never type-checked; `emit Hit { p: +Payload { v: 5 } }` is refused as *« event 'Hit' has no field 'v' »* — the +nested literal's fields checked against the event. + +**The design the measurement points to**, for arbitration: +1. The zone carried on every local and every synthesized value, supplied at + construction, and **independent of the declared type**; the compatibility + gates refuse a composite where a builtin is declared (E0200), so no type can + hide one. +2. A persistent value made safe to hold, per l. 568, in one of two ways: + **(A)** every holder of a persistent handle owns a reference — locals, + rule-arena containers, `for` frames, timer snapshots, task results — released + where the holder dies; or **(B)** a block replaced by an assignment is + released at the end of the body that replaced it, which covers every holder + dying within that body, and the only holders that outlive it (the async + `for` frame, and whatever the checker lets cross a suspension) take a + reference. (B) is the smaller change and refuses what (A) would allow, a + persistent local held across an `await`. +3. The escape consumers extended to what the measurement found: `emit` + payloads, the `event` and observer bindings inside timers and branches, an + async fn's return, and prefab hook bodies (checked, or declared unchecked). +4. String comparison by bytes wherever the runtime compares strings, and + interpolation accepting a persistent string. + +Decisions this needs: (A) or (B); whether a struct-typed event field is refused +at its declaration or copied at `emit`; and whether prefab hook bodies enter the +type-checker. Probes retired by `git checkout` in the counter-factual worktree; +the main tree was never touched. + +#### The windows hang, once more + +At `e1ee3287` the `windows-2025 / ReleaseSafe / f32` cell failed on the known +class: the signature `test runner failed to respond` once, no failed assertion, +`2248/2280 tests passed (32 skipped)` against a declared windows floor of 2413 — +**133 tests lost**, the largest loss the class has produced — the hung step +`13ad8f71…`, 52 minutes against 55. Not attributed to this gate: the diff +touched no Windows path. + +#### What G8 ter stops before + +`M1.D.47` is at arbitration. `M1.D.41` with `M1.D.42`, `M1.D.34` and the S5 +closure with the `CLAUDE.md` reduction come after it in the order given, and are +not started. + +#### Gates + +On `c931b60b`, each exit code read before any filtering: +- `zig build test`, Debug, ReleaseSafe and `-Dphysics_f64=true`: exit 0, + `327/327 steps; 2556/2573 tests passed (17 skipped)` in all three. +- `zig build lint`: exit 0, conservation OK at 2573. +- `zig fmt --check .`: exit 0, once `zig-out/etch-bench/cooked.zig` — the + bench's own generated output, untracked — was removed; with it present the + check fails on that file alone. +- `bindgen-verify`, `vk-gen-check` (`10/10 steps`), `test-codegen-diff`, + `verify-synth-100`, `forge-asm-inventory -Dphysics_f64=true`: exit 0. +- `forge-determinism`: exit 0, `none within K=60`. +- `ecs-access-counterproof`: exit 0, `8/8 steps`. +- `shaders-check`: exit 0, `checked 6 shader(s): ok`. +- `test-runtime-env` on macOS: exit 1 as it must, `3/8 passed`, the five + failures naming a Linux host (2), a Vulkan host (2) and a Win32 or Wayland + host (1); on CI it is the smoke job's. + +CI at `c931b60b`, run 35878126588: every job green. In the smoke job the PSNR +gate reads a real capture for the first time — `3/3 tests passed`, nothing +skipped — and the artifact carries it (2 files where it carried 1); +`test-runtime-env` passes `8/8` under weston, lavapipe and glslc, the six tests' +first run in CI. In `vertical-slice-smoke` the loader logs `Insert instance +layer "VK_LAYER_KHRONOS_validation"` in both teed steps, and the E6 step, whose +log stays in a file, passes its exit, completion and insertion checks. + +**Floor 2415 → 2573, windows 2571**, re-derived from the suite at each commit. +Two skips fewer on macOS (19 → 17): the PSNR gate left the suite and +`capture.zig` was deleted. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree @@ -5489,6 +5828,19 @@ this milestone is rewriting — 218 lines across S1's gates so far, and growing. `build.zig` does not diverge: this milestone has not touched it, so Kinesis's additions there land without conflict. The gesture is Guy's. +### B3 — `M1.D.47`'s premise does not hold, measured on three axes + +Stopped at S5/G8 ter, after the seven and before `M1.D.47`'s correction. The +ruling makes the zone the missing piece; the perimeter shows three things it +does not cover, each with a measured witness (§ S5/G8 ter, `M1.D.47`): a +persistent borrow is invalidated by a reassignment of its field, within one +body and across a suspension, against `etch-memory-model.md` l. 568; arena +values escape through `event`, observer bindings and `emit` payloads, which no +consumer judges; and the compatibility gates let a composite take a builtin +type, so a zone consulted only for arena-capable types misses it. The design +choices — reference per holder or deferred release, struct event fields, +prefab hook bodies — are returned to Guy. + ## Notes ### A comparison refuses the fingerprint of nothing before it concludes From b3fd9517ad0c52a4849f6307602ad7f7bdb079a4 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Thu, 24 Sep 2026 00:06:52 +0200 Subject: [PATCH 038/141] fix(ci): save the zig caches from main only, on per-commit keys The final cell save ran on a push or when the pull request's branch was `phase-1/debt/phase-1-debt`, a name written in the condition: S5's branch never saved, so every run restored main's cache of 2026-09-21 and recompiled all the branch had changed since. The clause is removed and nothing replaces it; a pull request restores by prefix and saves nothing. The smoke, vertical-slice and bench jobs keyed their cache on `build.zig.zon` alone. The primary key always hit, so actions/cache never saved again: all three restored an entry of 2026-09-21. Each now restores a per-commit key with a prefix fallback and saves on a push, as the cells do. CLAUDE.md stated the Release budget as 55 minutes; it is 75 since c3e6d316. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/bench.yml | 13 ++++++++++--- .github/workflows/ci.yml | 29 +++++++++++++++++++++-------- CLAUDE.md | 2 +- 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index d3532d08..537f581c 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -50,12 +50,12 @@ jobs: use-cache: false - name: Restore Zig cache - uses: actions/cache@v5 + uses: actions/cache/restore@v5 with: path: .zig-cache - key: zig-v2-${{ github.job }}-${{ matrix.os }}-ReleaseSafe-${{ env.ZIG_CPU }}-${{ env.ZIG_VERSION }}-${{ hashFiles('build.zig.zon') }} + key: zig-v2-${{ github.job }}-${{ matrix.os }}-ReleaseSafe-${{ env.ZIG_CPU }}-${{ env.ZIG_VERSION }}-${{ hashFiles('build.zig.zon') }}-${{ github.sha }} restore-keys: | - zig-v2-${{ github.job }}-${{ matrix.os }}-ReleaseSafe-${{ env.ZIG_CPU }}-${{ env.ZIG_VERSION }}- + zig-v2-${{ github.job }}-${{ matrix.os }}-ReleaseSafe-${{ env.ZIG_CPU }}-${{ env.ZIG_VERSION }}-${{ hashFiles('build.zig.zon') }}- - name: zig build bench-ecs -- --smoke run: zig build bench-ecs -Doptimize=ReleaseSafe -Dcpu=${{ env.ZIG_CPU }} -- --smoke @@ -66,6 +66,13 @@ jobs: - name: zig build bench-ecs-hybrid (crossover sweep) run: zig build bench-ecs-hybrid -Doptimize=ReleaseSafe -Dcpu=${{ env.ZIG_CPU }} + - name: Save Zig cache + if: always() && github.event_name == 'push' + uses: actions/cache/save@v5 + with: + path: .zig-cache + key: zig-v2-${{ github.job }}-${{ matrix.os }}-ReleaseSafe-${{ env.ZIG_CPU }}-${{ env.ZIG_VERSION }}-${{ hashFiles('build.zig.zon') }}-${{ github.sha }} + - name: Archive the crossover measurement uses: actions/upload-artifact@v6 with: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 151c4862..c2b956ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -244,8 +244,7 @@ jobs: - name: Save Zig cache (final) if: always() && matrix.mode != 'Debug' - && (github.event_name == 'push' - || github.head_ref == 'phase-1/debt/phase-1-debt') + && github.event_name == 'push' && steps.cache-size-final.outputs.save == 'true' uses: actions/cache/save@v5 with: @@ -270,12 +269,12 @@ jobs: use-cache: false - name: Restore Zig cache - uses: actions/cache@v5 + uses: actions/cache/restore@v5 with: path: .zig-cache - key: zig-v2-${{ github.job }}-ubuntu-24.04-ReleaseSafe-${{ env.ZIG_CPU }}-${{ env.ZIG_VERSION }}-${{ hashFiles('build.zig.zon') }} + key: zig-v2-${{ needs.changes.outputs.week }}-${{ github.job }}-ubuntu-24.04-ReleaseSafe-${{ env.ZIG_CPU }}-${{ env.ZIG_VERSION }}-${{ hashFiles('build.zig.zon') }}-${{ github.sha }} restore-keys: | - zig-v2-${{ github.job }}-ubuntu-24.04-ReleaseSafe-${{ env.ZIG_CPU }}-${{ env.ZIG_VERSION }}- + zig-v2-${{ needs.changes.outputs.week }}-${{ github.job }}-ubuntu-24.04-ReleaseSafe-${{ env.ZIG_CPU }}-${{ env.ZIG_VERSION }}-${{ hashFiles('build.zig.zon') }}- - name: Install weston + Mesa Vulkan software drivers run: | @@ -342,6 +341,13 @@ jobs: set -euo pipefail zig build test-runtime-env -Dcpu=${{ env.ZIG_CPU }} --summary all + - name: Save Zig cache + if: always() && github.event_name == 'push' + uses: actions/cache/save@v5 + with: + path: .zig-cache + key: zig-v2-${{ needs.changes.outputs.week }}-${{ github.job }}-ubuntu-24.04-ReleaseSafe-${{ env.ZIG_CPU }}-${{ env.ZIG_VERSION }}-${{ hashFiles('build.zig.zon') }}-${{ github.sha }} + - name: Upload capture artifact if: always() uses: actions/upload-artifact@v6 @@ -367,12 +373,12 @@ jobs: use-cache: false - name: Restore Zig cache (Debug) - uses: actions/cache@v5 + uses: actions/cache/restore@v5 with: path: .zig-cache - key: zig-v2-ubuntu-24.04-Debug-${{ env.ZIG_CPU }}-${{ env.ZIG_VERSION }}-${{ hashFiles('build.zig.zon') }} + key: zig-v2-${{ needs.changes.outputs.week }}-${{ github.job }}-ubuntu-24.04-Debug-${{ env.ZIG_CPU }}-${{ env.ZIG_VERSION }}-${{ hashFiles('build.zig.zon') }}-${{ github.sha }} restore-keys: | - zig-v2-ubuntu-24.04-Debug-${{ env.ZIG_CPU }}-${{ env.ZIG_VERSION }}- + zig-v2-${{ needs.changes.outputs.week }}-${{ github.job }}-ubuntu-24.04-Debug-${{ env.ZIG_CPU }}-${{ env.ZIG_VERSION }}-${{ hashFiles('build.zig.zon') }}- - name: Install Mesa Vulkan (lavapipe) + validation layers + weston run: | @@ -469,6 +475,13 @@ jobs: grep -E "00067|pSignalSemaphores" vkblit.log || echo " not observed on lavapipe (expected — hardware-only)" echo "=== end E6 blit validation ===" + - name: Save Zig cache (Debug) + if: always() && github.event_name == 'push' + uses: actions/cache/save@v5 + with: + path: .zig-cache + key: zig-v2-${{ needs.changes.outputs.week }}-${{ github.job }}-ubuntu-24.04-Debug-${{ env.ZIG_CPU }}-${{ env.ZIG_VERSION }}-${{ hashFiles('build.zig.zon') }}-${{ github.sha }} + - name: Upload slice capture if: always() uses: actions/upload-artifact@v6 diff --git a/CLAUDE.md b/CLAUDE.md index 17e595d1..8dbed557 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,7 +127,7 @@ commit, so a milestone still in review has no row here. **It is NOT the class M1.1.9 retired from `crash_recovery.zig`**, and the difference is worth keeping: those assertions ran AFTER the blocking receive returned and therefore guarded nothing, while this loop BOUNDS a wait and abandons it — a correctly shaped hang guard. What fires is its BUDGET on a runner the suite itself saturates. Removing it would remove a real guard; raising it weakens the bound it exists to give. Owner: unassigned. - **The sparse-driven disjunctive path's first-use allocation is BOUNDED but not ISOLATED (opened at M1.1.15-era, measured at M1.B/G10)**. The entity-keyed disjunctive path allocates an `AutoHashMapUnmanaged` the FIRST time a sparse-driven term appears and reuses it after, so steady state is allocation-free like `merge_cursors` — the same shape as `contact_constraint.zig`'s `deferred` residual. `bench/ecs_hybrid_crossover.zig` now bounds it: the sparse arm's first-tick allocation count is **constant at 3** across all 28 cells of every configuration, where the table arm's grows **2 → 183** with churn. **What the bench does NOT do is isolate the map from the other two allocations on that tick** — it establishes that the quantity is a small constant and does not grow with fraction or churn, which is what the debt needed, and not which of the three is the map. Stated rather than implied. - **`D-M0.2.1-c01-baseline-investigation` is CLOSED, and it was tracking a phantom (closed at M1.B/G11)**. It followed the divergence between C0.1's 3.74 ms and a "14.2 ms M0.1 baseline" that **no M0.1 artifact carries**: the squash commit body, the annotated tag and `briefs/M0.1-ecs-full.md:316` all read **3.84 ms in ReleaseFast**, and `git log --all --grep=14.2` returns two commits of which the earlier is `df67e1c` (M0.2.1 itself). The real delta is **2.6 %**. Closed by naming it here and in the M1.B brief plus a head note on the dated report — `engine-audit-checklist.md` carries zero occurrences of the identifier (measured), so the debt lived only in a brief, and a brief is a document of its commit and is not edited. -- **`build-and-test (windows-2025, ReleaseSafe)` HAS NO HEADROOM AT ITS 55-MINUTE BUDGET, and a comment-only commit is enough to spend it (opened at M1.E/G11, measured, mechanism named)**. **NOT the hang class**, whose count stays at eight: there, tests are LOST and the signature is `test runner failed to respond`. Here the job's conclusion is `cancelled` and **EVERY step succeeded**, `zig build test` and `Complete job` included — the job ran to its end and the wall arrived as it finished. Duration **55.0 min against `timeout-minutes: 55`** (`ci.yml:216`), and `fail-fast: false` means the other fourteen jobs ran green; `ci-gate` failed only because a required cell was not `success`. **Located step by step**: `zig build` 11.0 min, **`zig build test` 38.0 min**, the three cache steps 4.8 min together — so `M1.D.10`'s cache purging is NOT the cause here. The same cell on the two preceding commits of the same branch: `fb3d912` **12.5 min**, `7a7fc1b` **37.6 min**. A factor of 4.4 on a diff that is comments only. **The mechanism is the commit's own shape**, and it is `M1.D.8`'s transposed to another cell: the changed files are the tree's most-depended-upon Tier 0 headers — `world.zig`, `interp.zig`, `query.zig`, `observers.zig`, `archetype.zig`, `hybrid_query.zig`, `registry.zig`, `sparse_storage.zig`, both schedulers — and a comment edit changes a file's hash, so every compile step whose closure reaches them recompiles. A documentation pass over Tier 0 therefore costs the same cache as a refactor of it. **THE AMPLITUDE IS ATTRIBUTED, by a re-run at the SAME SHA whose only difference is that the first attempt's cache save had run**: attempt 2 took **10.3 min** where attempt 1 took 55.0, with `zig build test` at **3.2 min against 38.0** — a factor of **11.9** — and `zig build` at 4.0 against 11.0. So 34.8 of those 38 minutes were COLD-CACHE COMPILATION and not test execution, measured on one commit with the apparatus held fixed, which is `M1.D.8`'s finding with a clean before/after instead of a comparison across runs. The runner's intrinsic variance is NOT needed to explain it. Two options, neither taken: raise the ReleaseSafe budget past 55, or accept that a Tier 0-wide edit must be pushed with a warm cache — the second costs nothing and is what the re-run did. **AND A WARM CACHE IS NOT GUARANTEED BY A RE-RUN**: at M1.E on `387ab97`, another comment-only Tier 0 commit, the f64 leg of the same cell hung (the hang class above), and its re-run — which therefore started from whatever the FAILED attempt had left — took **53.1 min**, passing with **1.9 minutes of headroom**. The two classes appeared on one cell in one run, the hang on attempt 1 and the cold-cache cost on attempt 2, which does not merge them — one loses tests and prints `failed to respond`, the other has every step green and pays in minutes — but it is the second commit in two milestones where a comment-only edit to Tier 0 headers put this cell within two minutes of its wall. Owner: unassigned. +- **`build-and-test (windows-2025, ReleaseSafe)` HAD NO HEADROOM AT ITS 55-MINUTE BUDGET, and a comment-only commit was enough to spend it (opened at M1.E/G11, measured, mechanism named; the Release budget is 75 minutes since `c3e6d316`, Debug 35)**. **NOT the hang class**, whose count stays at eight: there, tests are LOST and the signature is `test runner failed to respond`. Here the job's conclusion is `cancelled` and **EVERY step succeeded**, `zig build test` and `Complete job` included — the job ran to its end and the wall arrived as it finished. Duration **55.0 min against `timeout-minutes: 55`** (`ci.yml:216`), and `fail-fast: false` means the other fourteen jobs ran green; `ci-gate` failed only because a required cell was not `success`. **Located step by step**: `zig build` 11.0 min, **`zig build test` 38.0 min**, the three cache steps 4.8 min together — so `M1.D.10`'s cache purging is NOT the cause here. The same cell on the two preceding commits of the same branch: `fb3d912` **12.5 min**, `7a7fc1b` **37.6 min**. A factor of 4.4 on a diff that is comments only. **The mechanism is the commit's own shape**, and it is `M1.D.8`'s transposed to another cell: the changed files are the tree's most-depended-upon Tier 0 headers — `world.zig`, `interp.zig`, `query.zig`, `observers.zig`, `archetype.zig`, `hybrid_query.zig`, `registry.zig`, `sparse_storage.zig`, both schedulers — and a comment edit changes a file's hash, so every compile step whose closure reaches them recompiles. A documentation pass over Tier 0 therefore costs the same cache as a refactor of it. **THE AMPLITUDE IS ATTRIBUTED, by a re-run at the SAME SHA whose only difference is that the first attempt's cache save had run**: attempt 2 took **10.3 min** where attempt 1 took 55.0, with `zig build test` at **3.2 min against 38.0** — a factor of **11.9** — and `zig build` at 4.0 against 11.0. So 34.8 of those 38 minutes were COLD-CACHE COMPILATION and not test execution, measured on one commit with the apparatus held fixed, which is `M1.D.8`'s finding with a clean before/after instead of a comparison across runs. The runner's intrinsic variance is NOT needed to explain it. The first of the two options was taken at `c3e6d316`: the Release budget went from 55 to 75 minutes. **AND A WARM CACHE IS NOT GUARANTEED BY A RE-RUN**: at M1.E on `387ab97`, another comment-only Tier 0 commit, the f64 leg of the same cell hung (the hang class above), and its re-run — which therefore started from whatever the FAILED attempt had left — took **53.1 min**, passing with **1.9 minutes of headroom**. The two classes appeared on one cell in one run, the hang on attempt 1 and the cold-cache cost on attempt 2, which does not merge them — one loses tests and prints `failed to respond`, the other has every step green and pays in minutes — but it is the second commit in two milestones where a comment-only edit to Tier 0 headers put this cell within two minutes of its wall. Owner: unassigned. - **The singleton-archetype exclusion is implemented at ITERATION time only, and the test written for it cannot see the gap (opened at M1.E/G11, measured, needs a number)**. `Archetype.is_singleton` promises that "user queries never see resource entities". The skip exists in `Query.maybeRescan`'s TAIL rescan (`query.zig:542`) and in `ComptimeQuery.next` (`comptime_query.zig:107`), both per-iteration and therefore order-independent — and NOT in the initial scan `World.queryFiltered` runs when a typed `Query` is CONSTRUCTED (`world.zig:2309-2318`, no `is_singleton` test). So a resource already flagged at construction enters that query's match list, while one flagged afterwards is skipped: the exclusion depends on the order between `setResource` and the query's construction. **`tests/core/resources/query_exclusion_test.zig` cannot catch it**: its own header says the exclusion is read by both the typed and the dynamic path, and it exercises only `comptime_query`, whose skip runs per iteration. Not fixed here — M1.E's object is comments, and a one-line `continue` in the typed query's initial scan changes Tier 0 query behaviour — so the flag's doc now states what is implemented and names the gap instead of promising the invariant. Owner: the ECS query owner. - **`job_bound.reasonOf` was not widened with the walk, so the guard refuses correctly and explains nothing in the one case it exists for (opened at M1.E/G11, needs a number)**. `carriesMarkedIn` enters every composite — pointer, array, vector, optional, error union, and each field of a struct or union — while `reasonOf` follows only `.pointer` and `.optional` and answers `"no reason declared"` for everything else (`job_bound.zig:168-172`). `refuseMarkedArgs` reports `reasonOf(f.type)` on the OUTER field type, so for `SystemContext` carrying `cmd: *CommandBuffer` — **the exact shape the widening was written for** — the compile error names the type and gives no reason. The file's own doctrine, written at `carriesMarkedIn`, is that "widening only to struct fields would have repeated the class this reprise exists to close — a rule applied to a subset of what it must cover"; its sibling function IS that subset. The asymmetry is now stated at the marker's doc. The symmetric widening changes a compile-error message and is therefore behaviour. Owner: whoever owns the bound. - **The `tests/` subtree is OUTSIDE the comment rules by decision, and its size is now measured (M1.E/G11)**. `comment_scan.inPerimeter` returns false for any path whose FIRST segment is `tests`, with the reason written at the function: `src/`, `tools/` and `bench/` carry a conservation pass that gives a reworded comment somewhere to go, and `tests/` does not, so firing there would make the rule green only by leaving those files permanently red. **MEASURED WITH THE BINARY ITSELF, the perimeter exclusion lifted in a throwaway build: 732 diagnostics on 519 distinct comment lines in 146 files** — 494 lines carrying a milestone/gate/step/spike/review identifier and 25 more carrying a phase mention and no identifier. That is 2.6× `build.zig`'s entire diagnostic count, and the largest single unswept perimeter the comment work leaves. Seven of the twelve remaining word-provenance rows live there too. **An earlier figure of 492 lines was published here and is WRONG TWICE**: it was the `comment_identifiers` count alone where the honest quantity is all-rule, and it came from a Python transcription of the rule rather than from the rule — a transcription that tests only lines whose `strip()` starts with `//`, so it misses a comment trailing a line of code, and that counts the `tests/lint/bad/` fixtures the walker deliberately skips. **Measure with the instrument, not with a replica of it, when the instrument is one build away.** Owner: the pass that reads `tests/`. From 5ad26b594d84c433057030f7f51af91e54597ff6 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Thu, 24 Sep 2026 00:18:35 +0200 Subject: [PATCH 039/141] fix(etch): tag the headerless string copies apart from persistent ones Three producers tagged `.string_persistent` over bytes that are no persistent block: the event store's copies of emitted strings, the copies a captured event filter keeps, and the world's extension names returned by `active_extensions`. A decref keyed on that tag reads a block header at `ptr - 16` that does not exist. They now carry `.string_view`, which is never counted, and every site that reads a string's bytes accepts it. `Value.eql` compared a store copy with a resource string by bytes, both carrying one tag; it still does, across the two tags, so a set holding an event's string and the resource's equal string keeps one element. Floor 2573 -> 2577 / 2575, measured: 2560/2577 passed, 17 skipped. Co-Authored-By: Claude Opus 5.5 --- src/etch/interp.zig | 141 +++++++++++++++++++++++++++------ src/etch/value.zig | 32 ++++++-- tools/weld_lint/dead_tests.zig | 4 +- 3 files changed, 142 insertions(+), 35 deletions(-) diff --git a/src/etch/interp.zig b/src/etch/interp.zig index df27f490..cf695961 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -528,8 +528,8 @@ const EventStore = struct { /// storage, released when the resource string field is reassigned). /// Neither survives an event that outlives the emitter's body (an `@on_event` /// observer or an awaiter's cross-tick poll), nor a mutation of its source, - /// so such a field value is deep-copied here at emit and re-tagged - /// `.string_persistent` over the copy; the copies are freed with the event + /// so such a field value is deep-copied here at emit and tagged + /// `.string_view` over the copy; the copies are freed with the event /// queue at the per-tick `clear`. (`.string_id` — the immortal AST table — is /// stable and never copied.) owned_strings: std.ArrayListUnmanaged([]u8) = .empty, @@ -554,7 +554,7 @@ const EventStore = struct { } /// Deep-copy `bytes` into store-owned memory and return a stable - /// `.string_persistent` view over the copy (freed at `clear`). Stabilizes a + /// `.string_view` over the copy (freed at `clear`). Stabilizes a /// non-AST string event field value (a per-body `.string_run` or a borrowed /// `.string_persistent`) that would otherwise dangle when the emitter's body /// ends or the source resource string is reassigned. @@ -562,7 +562,7 @@ const EventStore = struct { const dup = try gpa.dupe(u8, bytes); errdefer gpa.free(dup); try self.owned_strings.append(gpa, dup); - return Value{ .string_persistent = .{ .ptr = @intFromPtr(dup.ptr), .len = @intCast(dup.len) } }; + return Value{ .string_view = .{ .ptr = @intFromPtr(dup.ptr), .len = @intCast(dup.len) } }; } /// Number of queued events of `type_name` (test / inspection helper). @@ -1235,8 +1235,8 @@ pub const Interpreter = struct { /// `.entity_id`) are copy-stable across ticks; a NON-AST string filter — a /// computed `.string_run` (`prefix + "!"`) or a borrowed `.string_persistent` /// (`get(R).s`, released on resource reassignment) — is deep-copied into - /// `captured_filter_strings` at capture and re-tagged `.string_persistent` - /// over the copy (which also enforces capture-once §9.4). + /// `captured_filter_strings` at capture and tagged `.string_view` over the + /// copy (which also enforces capture-once §9.4). captured_filters: std.ArrayListUnmanaged(StructField) = .empty, /// Buffer-owned deep copies of non-AST (`.string_run` / borrowed /// `.string_persistent`) filter-value bytes. Parallel to @@ -3891,7 +3891,7 @@ pub const Interpreter = struct { const dup = try self.gpa.dupe(u8, self.stringBytes(v0).?); errdefer self.gpa.free(dup); try self.captured_filter_strings.append(self.gpa, dup); - break :blk Value{ .string_persistent = .{ .ptr = @intFromPtr(dup.ptr), .len = @intCast(dup.len) } }; + break :blk Value{ .string_view = .{ .ptr = @intFromPtr(dup.ptr), .len = @intCast(dup.len) } }; } else v0; try self.captured_filters.append(self.gpa, .{ .name = flit.name, .value = v }); } @@ -5191,7 +5191,7 @@ pub const Interpreter = struct { .float_ => |x| try self.msgPrint("{d}", .{x}), .bool_ => |x| try self.msgAppend(if (x) "true" else "false"), .duration => |x| try self.msgPrint("{d}s", .{x}), - .string_id, .string_run, .string_persistent => try self.msgAppend(self.stringBytes(v) orelse ""), + .string_id, .string_run, .string_persistent, .string_view => try self.msgAppend(self.stringBytes(v) orelse ""), .entity_id => |e| try self.msgPrint("entity#{d}", .{e}), .unit => try self.msgAppend("()"), else => try self.msgAppend(""), @@ -5500,9 +5500,8 @@ pub const Interpreter = struct { if (mc.args_len != 0) return error.RuntimeFailure; const handle = try self.collections.newArray(self.gpa); for (world.entityExtensions(@bitCast(eid))) |n| { - // Wrap each owned name as a borrowed persistent-string view - // (the names outlive the call — owned by the side-table). - try self.collections.arrays.items[handle].append(self.gpa, Value{ .string_persistent = .{ .ptr = @intFromPtr(n.ptr), .len = @intCast(n.len) } }); + // The names are owned by the world's side table. + try self.collections.arrays.items[handle].append(self.gpa, Value{ .string_view = .{ .ptr = @intFromPtr(n.ptr), .len = @intCast(n.len) } }); } return Value{ .array_ref = handle }; } @@ -5550,13 +5549,9 @@ pub const Interpreter = struct { const method = self.trait_methods.get(methodKey(entity_name, mc.method_name)) orelse return error.RuntimeFailure; return try self.callMethod(world, locals, method, mc, recv); }, - .string_id, .string_run, .string_persistent => { - // Builtin string methods. `len` → byte length, on a - // literal (`string_id`), a runtime-produced string - // (`string_run`), or a borrowed resource-string - // view (`string_persistent`); any other §12 method - // is unimplemented stdlib → fail loud. `stringBytes` already covers - // all three forms. + .string_id, .string_run, .string_persistent, .string_view => { + // Builtin string methods: `len` → byte length; any other §12 + // method is unimplemented stdlib → fail loud. const mname = self.ast.strings.slice(mc.method_name); if (std.mem.eql(u8, mname, "len")) { const bytes = self.stringBytes(recv) orelse return error.RuntimeFailure; @@ -5860,26 +5855,28 @@ pub const Interpreter = struct { /// Whether a string `Value`'s bytes must be deep-copied to outlive the /// current body OR survive a mutation of their source. Only /// `.string_id` (the immortal AST string table) is stable; a `.string_run` - /// (per-body `run_strings`, freed at the body boundary) and a NON-EMPTY + /// (per-body `run_strings`, freed at the body boundary), a NON-EMPTY /// borrowed `.string_persistent` (a view over resource storage, released when - /// the resource string field is reassigned) are NOT. The empty - /// `.string_persistent` sentinel (`ptr == 0`, `len == 0`) has no bytes to own. + /// the resource string field is reassigned) and a non-empty `.string_view` + /// (owned by whichever store copied it) are NOT. An empty view (`ptr == 0`, + /// `len == 0`) has no bytes to own. fn stringNeedsOwning(v: Value) bool { return switch (v) { .string_run => true, - .string_persistent => |s| s.len > 0, + .string_persistent, .string_view => |s| s.len > 0, else => false, }; } /// The bytes of a string value — an AST-table literal (`string_id`), a - /// runtime-produced string (`string_run`), or a borrowed resource-string - /// view (`string_persistent`). Null for any non-string value. + /// runtime-produced string (`string_run`), a borrowed resource-string view + /// (`string_persistent`) or a store-owned view (`string_view`). Null for any + /// non-string value. fn stringBytes(self: *const Interpreter, v: Value) ?[]const u8 { return switch (v) { .string_id => |sid| self.ast.strings.slice(sid), .string_run => |handle| self.run_strings.items[handle], - .string_persistent => |s| blk: { + .string_persistent, .string_view => |s| blk: { if (s.len == 0) break :blk &.{}; const p: [*]const u8 = @ptrFromInt(s.ptr); break :blk p[0..s.len]; @@ -13584,6 +13581,100 @@ test "global_event filter is captured once at suspension, not re-evaluated at po try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out)); // captured 7 matched despite want→9 } +test "the event store's string copy is a view, not a persistent string" { + const gpa = std.testing.allocator; + var store: EventStore = .{}; + defer store.deinit(gpa); + const v = try store.ownEscapingString(gpa, "abc"); + try std.testing.expect(v == .string_view); + try std.testing.expectEqualStrings("abc", @as([*]const u8, @ptrFromInt(v.string_view.ptr))[0..v.string_view.len]); +} + +test "a captured event filter string is a view, not a persistent string" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, + \\event Ping { s: string } + \\resource Out { n: int = 0 } + \\async rule watch() + \\ when resource Out + \\{ + \\ await global_event(Ping { s: "a" + "b" }) + \\ get_mut(Out).n = 1 + \\} + ); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(usize, 1), interp.captured_filters.items.len); + const v = interp.captured_filters.items[0].value; + try std.testing.expect(v == .string_view); + try std.testing.expectEqualStrings("ab", interp.stringBytes(v).?); +} + +test "active_extensions yields views of the world's names" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, + \\component Probe { count: i32 = 0 } + \\rule probe(entity: Entity) when entity has Probe { + \\ entity.get_mut(Probe).count = entity.active_extensions().len() as i32 + \\} + ); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const eid = try world.spawnDynamic(gpa, &[_]ComponentId{world.componentId("Probe").?}); + try world.addEntityExtension(gpa, eid, "Combat"); + interp.suppress_body_store_resets = true; + _ = try interp.runFor(&world, 1); + var seen: usize = 0; + for (interp.collections.arrays.items) |arr| for (arr.items) |el| { + try std.testing.expect(el == .string_view); + try std.testing.expectEqualStrings("Combat", interp.stringBytes(el).?); + seen += 1; + }; + try std.testing.expectEqual(@as(usize, 1), seen); +} + +test "a store-owned view and a resource string with the same bytes are one set element" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, + \\event Named { name: string } + \\resource R { name: string = "x", n: int = 0 } + \\rule emitter() when resource R { emit Named { name: "a" + "b" } } + \\rule rename() when resource R { get_mut(R).name = "a" + "b" } + \\@on_event(Named) + \\rule seen() when resource R { + \\ let mut s: Set = Set.new() + \\ s.insert(event.name) + \\ s.insert(get(R).name) + \\ get_mut(R).n = s.len() + \\} + ); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const report = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 0), report.runtime_errors); + try std.testing.expectEqual(@as(i64, 1), readResourceIntNamed(&world, "R", "n")); +} + test "emit stabilizes a computed string so an @on_event observer reads it safely" { const gpa = std.testing.allocator; var world = World.init(); diff --git a/src/etch/value.zig b/src/etch/value.zig index af224a14..662a3056 100644 --- a/src/etch/value.zig +++ b/src/etch/value.zig @@ -124,6 +124,11 @@ pub const Value = union(enum) { /// `ptr == 0` ⇔ the empty string. Additive — does not disturb `string_id` (AST /// pool) / `string_run` (rule-arena) semantics. string_persistent: StrView, + /// A view over string bytes owned outside the persistent heap: an event + /// store copy, a captured event filter value, a world extension name. It + /// has no block header, so it is never incref'd or decref'd. `ptr == 0` ⇔ + /// the empty string. + string_view: StrView, /// A borrowed view over a resource `T[]` field's persistent-heap container /// block. The `u64` is the block's exposed payload pointer (a /// `persistent` `type_array` block whose payload is the owned @@ -192,6 +197,7 @@ pub const Value = union(enum) { /// bug or a value reaching the interpreter through an `unsupported` /// path the interpreter must reject. pub fn eql(self: Value, other: Value) bool { + if (byteView(self)) |a| if (byteView(other)) |b| return viewEql(a, b); if (std.meta.activeTag(self) != std.meta.activeTag(other)) return false; return switch (self) { .int_ => |a| a == other.int_, @@ -213,14 +219,7 @@ pub const Value = union(enum) { .struct_ref => false, .optional => false, // optional equality is unexercised (unwrap via if/while let) .enum_value => |a| a.type_name == other.enum_value.type_name and a.variant == other.enum_value.variant, - .string_persistent => |a| blk: { - const b = other.string_persistent; - if (a.len != b.len) break :blk false; - if (a.len == 0) break :blk true; - const ab: [*]const u8 = @ptrFromInt(a.ptr); - const bb: [*]const u8 = @ptrFromInt(b.ptr); - break :blk std.mem.eql(u8, ab[0..a.len], bb[0..b.len]); - }, + .string_persistent, .string_view => unreachable, // Handle equality is not an Etch v0.6 operation (no `==` on // TaskHandle/TimerHandle); identity comparison is reserved for a // later spec. @@ -248,6 +247,23 @@ pub const StrView = struct { len: u32 = 0, }; +/// The view of a string held outside the AST and the rule arena, whichever +/// memory owns it; null for any other value. +fn byteView(v: Value) ?StrView { + return switch (v) { + .string_persistent, .string_view => |s| s, + else => null, + }; +} + +fn viewEql(a: StrView, b: StrView) bool { + if (a.len != b.len) return false; + if (a.len == 0) return true; + const ab: [*]const u8 = @ptrFromInt(a.ptr); + const bb: [*]const u8 = @ptrFromInt(b.ptr); + return std.mem.eql(u8, ab[0..a.len], bb[0..b.len]); +} + /// Typed sum carrying a `SourceSpan` resolved from the AST `NodeId` that /// triggered the failure. The interpreter never silently masks runtime /// errors — it reports them through this type plus the `RuntimeReport` diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index e29c1e21..986ebd31 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2571, - else => 2573, + .windows => 2575, + else => 2577, }; } From 74b44e2978f76279d6bd04462046b76b903cbb2e Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Thu, 24 Sep 2026 00:53:06 +0200 Subject: [PATCH 040/141] fix(etch): fail loud when a sync for body shrinks the array it iterates The sync `for` arms snapshot the length and re-index at each step. A `pop` in the body of a loop over an array shrinks the list under the cursor, and the next index reads past its end: a safety panic in Debug and ReleaseSafe, an out-of-bounds read in ReleaseFast. The async loops already re-check the length at each step; the two array arms now do the same and fail the body. The two map arms need no check: no map method removes an entry, so their length cannot shrink. The resource case is a checked program. The arena case is reachable only past the checker, which gives a local array no methods, and the interpreter runs such programs. Found while enumerating the holders of M1.D.47 (A). Co-Authored-By: Claude Opus 5.5 --- src/etch/interp.zig | 47 ++++++++++++++++++++++++++++++++++ tools/weld_lint/dead_tests.zig | 4 +-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/etch/interp.zig b/src/etch/interp.zig index cf695961..2f5b40d2 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -4519,6 +4519,7 @@ pub const Interpreter = struct { const len = self.collections.arrays.items[handle].items.len; var k: usize = 0; arr_loop: while (k < len) : (k += 1) { + if (k >= self.collections.arrays.items[handle].items.len) return error.RuntimeFailure; const elem = self.collections.arrays.items[handle].items[k]; try locals.put(self.gpa, f.var_name, elem, false); try self.execStmtRun(world, locals, f.body_start, f.body_len); @@ -4538,6 +4539,7 @@ pub const Interpreter = struct { const len = persistentArrayOf(ptr).items.len; var k: usize = 0; parr_loop: while (k < len) : (k += 1) { + if (k >= persistentArrayOf(ptr).items.len) return error.RuntimeFailure; const elem = persistentArrayOf(ptr).items[k]; try locals.put(self.gpa, f.var_name, elem, false); try self.execStmtRun(world, locals, f.body_start, f.body_len); @@ -13675,6 +13677,51 @@ test "a store-owned view and a resource string with the same bytes are one set e try std.testing.expectEqual(@as(i64, 1), readResourceIntNamed(&world, "R", "n")); } +test "a sync for over an arena array fails loud when its body shrinks it" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, + \\resource R { done: bool = false } + \\rule r() when resource R { + \\ if get(R).done == false { + \\ get_mut(R).done = true + \\ let mut xs = [1, 2, 3] + \\ for x in xs { + \\ let p = xs.pop() + \\ } + \\ } + \\} + ); + defer pr.deinit(gpa); + var interp = try compileUnchecked(gpa, &pr, &world); + defer interp.deinit(); + const report = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 1), report.runtime_errors); +} + +test "a sync for over a resource array fails loud when its body shrinks it" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try checkCleanProgram(gpa, + \\resource R { xs: int[] = [1, 2, 3], done: bool = false } + \\rule r() when resource R { + \\ if get(R).done == false { + \\ get_mut(R).done = true + \\ for x in get(R).xs { + \\ let p = get_mut(R).xs.pop() + \\ } + \\ } + \\} + ); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const report = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 1), report.runtime_errors); +} + test "emit stabilizes a computed string so an @on_event observer reads it safely" { const gpa = std.testing.allocator; var world = World.init(); diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 986ebd31..299bbe46 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2575, - else => 2577, + .windows => 2577, + else => 2579, }; } From 062908db4146403d751cf5e0053ad89fdbd69eb1 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Thu, 24 Sep 2026 00:53:48 +0200 Subject: [PATCH 041/141] fix(etch): count every persistent handle an interpreter holder keeps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M1.D.47 (A), on `etch-memory-model.md` §4.4 (sha256 9c97b7d0): a read of a resource string or collection takes no reference, and every holder that keeps the value counts one. Before this, no holder counted, so a reassignment freed a block that a local, a loop, a frame or a Zig-side operand still held. - Evaluation. `evalExpr` counts each result as an arena value, whose reference returns at the arena's reset through `deferred_decrefs`. A call's result is not counted again: the callee's own evaluation counted it, and the reference transfers. - Arena containers. A struct, array, map, set or optional built from evaluation results holds the count those results took; counting again at the write would be a second count of one copy that no program can tell apart. Only two writes read a value no evaluation counted and count it themselves: the payload of a resource map lookup, and a closure's captured locals. - Locals own one reference per handle: `put` retains and releases the value it replaces, `clear` and `deinit` release, an assignment goes through `replaceHeld`, a snapshot through `put`. - An async `for` frame holds the collection it iterates, and a task holds the result it parks. - The list drains at each arena reset, at the end of each tick (async drives never reset the arena) and at teardown. Seventeen witnesses. Four run past the checker, which refuses those holdings (E0223): a task local across a suspension, a timer snapshot, an awaited assignment, and a race winner's returned string. Twenty counter-factuals, each removing one holder's count or one drain, with predictions written before the batch: each landed as predicted, on the witnesses it named and by the symptom it named (freed bytes, a leak, a crash). The record is in the brief. Stale lifetime contracts corrected in `value.zig` and `ecs_bridge.zig`. Floor 2579 -> 2596, windows 2577 -> 2594. Co-Authored-By: Claude Opus 5.5 --- src/etch/ecs_bridge.zig | 11 +- src/etch/interp.zig | 562 ++++++++++++++++++++++++++++++--- src/etch/value.zig | 12 +- tools/weld_lint/dead_tests.zig | 4 +- 4 files changed, 533 insertions(+), 56 deletions(-) diff --git a/src/etch/ecs_bridge.zig b/src/etch/ecs_bridge.zig index c8a791f4..9b48b10a 100644 --- a/src/etch/ecs_bridge.zig +++ b/src/etch/ecs_bridge.zig @@ -375,12 +375,11 @@ pub fn readBytesAsValue(kind: FieldKind, bytes: []const u8) Value { // Proven invariant: this arm is never reached. .enum_ => unreachable, // Collection read: decode the `CollectionSlot { ptr }` into a - // borrowed `.array_persistent` view over the owned container block (no - // incref — the resource, hence the block, outlives the rule body). `ptr` - // is never 0 for a live field (the empty collection is a real block - // allocated with the resource's store buffer). Components never carry a - // collection kind (validator-gated, resource-only), so this is reached - // only via `readResourceField`. + // borrowed `.array_persistent` view over the owned container block, + // without incref'ing it. `ptr` is never 0 for a live field (the empty + // collection is a real block allocated with the resource's store + // buffer). Components never carry a collection kind (validator-gated, + // resource-only), so this is reached only via `readResourceField`. .array_ => blk: { var cs: persistent.CollectionSlot = undefined; @memcpy(std.mem.asBytes(&cs), bytes[0..@sizeOf(persistent.CollectionSlot)]); diff --git a/src/etch/interp.zig b/src/etch/interp.zig index 2f5b40d2..ab8d97b6 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -325,20 +325,61 @@ fn freeSelection(gpa: std.mem.Allocator, selection: []QueryPlan) void { gpa.free(selection); } +/// The persistent block a value holds a reference to, if any (§4.4). A +/// `.string_view` has no block and is never counted. +fn handleBlock(v: Value) ?[*]u8 { + return switch (v) { + .string_persistent => |s| if (s.ptr == 0) null else @ptrFromInt(s.ptr), + .array_persistent, .map_persistent, .set_persistent => |p| @ptrFromInt(p), + else => null, + }; +} + +fn retainHandle(v: Value) void { + if (handleBlock(v)) |b| persistent.incref(b); +} + +fn releaseHandle(gpa: std.mem.Allocator, v: Value) void { + if (handleBlock(v)) |b| persistent.decref(gpa, b); +} + +/// Store `v` in a slot that already holds a counted value. +fn replaceHeld(gpa: std.mem.Allocator, slot: *Value, v: Value) void { + retainHandle(v); + releaseHandle(gpa, slot.*); + slot.* = v; +} + const Local = struct { value: Value, is_mut: bool, }; +/// A scope. Each local holding a persistent handle owns one reference. const Locals = struct { map: std.AutoHashMapUnmanaged(StringId, Local) = .empty, pub fn deinit(self: *Locals, gpa: std.mem.Allocator) void { + self.releaseAll(gpa); self.map.deinit(gpa); } + /// Drop every local, keeping the map's capacity. + pub fn clear(self: *Locals, gpa: std.mem.Allocator) void { + self.releaseAll(gpa); + self.map.clearRetainingCapacity(); + } + + fn releaseAll(self: *Locals, gpa: std.mem.Allocator) void { + var it = self.map.valueIterator(); + while (it.next()) |l| releaseHandle(gpa, l.value); + } + pub fn put(self: *Locals, gpa: std.mem.Allocator, name: StringId, v: Value, is_mut: bool) !void { - try self.map.put(gpa, name, .{ .value = v, .is_mut = is_mut }); + const gop = try self.map.getOrPut(gpa, name); + retainHandle(v); + if (gop.found_existing) releaseHandle(gpa, gop.value_ptr.value); + gop.value_ptr.* = .{ .value = v, .is_mut = is_mut }; } pub fn get(self: *const Locals, name: StringId) ?Value { @@ -853,9 +894,9 @@ const ForIter = union(enum) { array: struct { handle: u32, len: usize, idx: usize }, map: struct { handle: u32, len: usize, idx: usize }, /// A resource `T[]` iterated in an async body. Carries the - /// `type_array` block pointer (stable across suspend, unlike a rule-arena - /// handle) + a snapshotted length + cursor — same index-based semantics as - /// the `.array` variant. + /// `type_array` block pointer, on which the frame holds a reference, + a + /// snapshotted length + cursor — same index-based semantics as the `.array` + /// variant. array_persistent: struct { ptr: u64, len: usize, idx: usize }, /// A resource `[K: V]` iterated in an async body, mirror of /// `.map` on a `type_map` block pointer. @@ -971,18 +1012,30 @@ const AsyncTask = struct { returned: bool = false, fn deinit(self: *AsyncTask, gpa: std.mem.Allocator) void { - for (self.frames.items) |*f| switch (f.*) { - .call => |cf| { - cf.scope.deinit(gpa); - gpa.destroy(cf.scope); - }, - else => {}, - }; + for (self.frames.items) |*f| releaseFrame(gpa, f); self.frames.deinit(gpa); self.locals.deinit(gpa); + releaseHandle(gpa, self.result); } }; +/// Release what a frame owns: a `call` frame's scope, and the reference a `for` +/// frame holds on the persistent collection it iterates. +fn releaseFrame(gpa: std.mem.Allocator, frame: *AsyncFrame) void { + switch (frame.*) { + .call => |cf| { + cf.scope.deinit(gpa); + gpa.destroy(cf.scope); + }, + .for_ => |ff| switch (ff.iter) { + .array_persistent => |a| releaseHandle(gpa, .{ .array_persistent = a.ptr }), + .map_persistent => |m| releaseHandle(gpa, .{ .map_persistent = m.ptr }), + else => {}, + }, + else => {}, + } +} + /// Outcome of one `driveTask` pass over a task's frame-stack. const AsyncOutcome = enum { suspended, completed }; @@ -1140,10 +1193,13 @@ pub const Interpreter = struct { /// values (arrays / structs / closures / `.string_run`) still held by the test /// body's locals — a use-after-free (the string class). `runTestBody` sets /// this for its whole duration; the driven bodies then accumulate into the shared - /// stores (test-scale, bounded), and `runTestBody`'s own end-defers (raw resets, - /// NOT `resetBodyStores`) free everything at once. `false` on every production path - /// — no behavior change. + /// stores (test-scale, bounded), and `runTestBody`'s own end-defer + /// (`resetArena`, NOT `resetBodyStores`) frees everything at once. `false` on + /// every production path — no behavior change. suppress_body_store_resets: bool = false, + /// Blocks arena values hold a reference to, released at the arena's reset + /// (`etch-memory-model.md` §4.4). + deferred_decrefs: std.ArrayListUnmanaged([*]u8) = .empty, /// Whether a `return` is unwinding to the enclosing `fn` boundary. /// Mirrors `thrown`: every statement-run / loop / block site that stops on a /// throw also stops on a return; the fn-call boundary consumes it. @@ -1274,6 +1330,8 @@ pub const Interpreter = struct { /// the blocks they point into belong to the world and outlive this /// interpreter. pub fn deinit(self: *Interpreter) void { + self.drainDeferredDecrefs(); + self.deferred_decrefs.deinit(self.gpa); self.event_sources.deinit(self.gpa); for (self.rule_descs) |*r| r.deinit(self.gpa); self.gpa.free(self.rule_descs); @@ -1654,11 +1712,7 @@ pub const Interpreter = struct { defer self.suppress_body_store_resets = false; self.in_test_body = true; defer self.in_test_body = false; - defer self.collections.reset(self.gpa); - defer self.closures.reset(self.gpa); - defer self.structs.reset(self.gpa); - defer self.optionals.clearRetainingCapacity(); - defer self.resetRunStrings(); + defer self.resetArena(); self.control = .none; self.thrown = false; @@ -2070,6 +2124,9 @@ pub const Interpreter = struct { // any extension hook's structural change (enqueued just above) drains in // the same boundary, with observers firing per op. try self.flushStructural(world); + // Every invocation of the tick has ended, async drives included, which + // never reset the arena. + if (!self.suppress_body_store_resets) self.drainDeferredDecrefs(); } /// Advance the two clock accumulators and publish the three builtin time @@ -2523,7 +2580,7 @@ pub const Interpreter = struct { var locals: Locals = .{}; defer locals.deinit(self.gpa); for (rd.resource_expr_filters) |rf| { - locals.map.clearRetainingCapacity(); + locals.clear(self.gpa); const bytes = world.resources.getResource(rf.resource_id) orelse { pass = false; break; @@ -2550,7 +2607,7 @@ pub const Interpreter = struct { var locals: Locals = .{}; defer locals.deinit(self.gpa); for (rd.expr_filters) |ef| { - locals.map.clearRetainingCapacity(); + locals.clear(self.gpa); // The FOURTH per-slot guard to take the locator. Its // previous form asked the archetype for a column, which answers // null for a sparse id — so a `component T { expression }` guard on @@ -2576,7 +2633,7 @@ pub const Interpreter = struct { if (pass) { const rule = self.ast.rule_decls.items[rd.rule_idx]; for (rd.expr_conds) |expr| { - locals.map.clearRetainingCapacity(); + locals.clear(self.gpa); try bindParams(self.gpa, self.ast, rule, entity_id, &locals); if (!(try self.evalGuardExpr(world, &locals, expr))) { pass = false; @@ -2608,14 +2665,33 @@ pub const Interpreter = struct { /// rule / guard / timer / observer / hook bodies `tick(n)` drives, so their /// resets must not free heap-backed values its locals still hold (UAF, the /// string class). The single choke point for every per-body reset; a test's - /// own end-cleanup calls the raw resets directly (unconditional). + /// own end-cleanup calls `resetArena` directly (unconditional). fn resetBodyStores(self: *Interpreter) void { if (self.suppress_body_store_resets) return; + self.resetArena(); + } + + /// Free the rule-arena stores and return the references their values held. + fn resetArena(self: *Interpreter) void { self.collections.reset(self.gpa); self.closures.reset(self.gpa); self.structs.reset(self.gpa); self.optionals.clearRetainingCapacity(); self.resetRunStrings(); + self.drainDeferredDecrefs(); + } + + /// Count `v` as a copy an arena value holds; its reference returns at the + /// arena's reset. + fn retainArena(self: *Interpreter, v: Value) error{OutOfMemory}!void { + const block = handleBlock(v) orelse return; + try self.deferred_decrefs.append(self.gpa, block); + persistent.incref(block); + } + + fn drainDeferredDecrefs(self: *Interpreter) void { + for (self.deferred_decrefs.items) |block| persistent.decref(self.gpa, block); + self.deferred_decrefs.clearRetainingCapacity(); } /// Reset the rule-arena stores after a guard evaluation: guard @@ -2780,16 +2856,9 @@ pub const Interpreter = struct { return &task.locals; } - /// Free a frame's owned resources: only a `call` frame owns heap - /// (its `async fn` scope). Called for every frame removal. + /// Free a frame's owned resources. Called for every frame removal. fn deinitFrame(self: *Interpreter, frame: *AsyncFrame) void { - switch (frame.*) { - .call => |cf| { - cf.scope.deinit(self.gpa); - self.gpa.destroy(cf.scope); - }, - else => {}, - } + releaseFrame(self.gpa, frame); } /// Pop the top frame, freeing its owned resources. @@ -2815,7 +2884,7 @@ pub const Interpreter = struct { .bind => |b| try currentScope(task).put(self.gpa, b.name, v, b.is_mut), .assign_local => |name| { const ptr = currentScope(task).getPtr(name) orelse return error.RuntimeFailure; - ptr.* = v; + replaceHeld(self.gpa, ptr, v); }, } } @@ -3316,6 +3385,7 @@ pub const Interpreter = struct { }; cursor.* += 1; try task.frames.append(self.gpa, .{ .for_ = .{ .for_id = stmt, .iter = for_iter } }); + retainHandle(iter); return .pushed; } // (2c) `try { } catch e { }` → push a try frame driving the `try` body; a @@ -3503,7 +3573,7 @@ pub const Interpreter = struct { fn cloneLocalsInto(gpa: std.mem.Allocator, src: *const Locals, dest: *Locals) error{OutOfMemory}!void { var it = src.map.iterator(); while (it.next()) |entry| { - try dest.map.put(gpa, entry.key_ptr.*, entry.value_ptr.*); + try dest.put(gpa, entry.key_ptr.*, entry.value_ptr.value, entry.value_ptr.is_mut); } } @@ -3551,6 +3621,7 @@ pub const Interpreter = struct { // POD-across-suspend caveat. task.returned = true; task.result = self.return_value; + retainHandle(task.result); self.returning = false; self.return_value = .{ .unit = {} }; return false; @@ -3814,9 +3885,9 @@ pub const Interpreter = struct { return true; }, .array_persistent => { - // Resource `T[]` in an async body: the block pointer - // is stable across suspend; bounds-check the snapshotted length - // against the (possibly mutated) container, same as `.array`. + // Resource `T[]` in an async body: the frame holds a reference to + // the block; bounds-check the snapshotted length against the + // (possibly mutated) container, same as `.array`. const a = &ff.iter.array_persistent; if (a.idx >= a.len) return false; const list = persistentArrayOf(a.ptr); @@ -4733,7 +4804,7 @@ pub const Interpreter = struct { if (self.thrown) return; // see `assignRhsThrew` const new_v = applyAssignOp(cur, assign.op, rhs) catch return self.fail(assignFailureKind(assign.op, cur, rhs), self.ast.exprSpan(assign.target)); const ptr = locals.getPtr(name_id) orelse return error.RuntimeFailure; - ptr.* = new_v; + replaceHeld(self.gpa, ptr, new_v); return; } if (target_kind == .field_access) { @@ -6011,7 +6082,19 @@ pub const Interpreter = struct { return Value{ .string_run = handle }; } + /// Evaluate `id`. A persistent handle in the result is a copy an arena + /// value holds, whoever keeps it next; a call's result is the callee's + /// reference, transferred (§4.4). fn evalExpr(self: *Interpreter, world: *World, locals: *Locals, id: NodeId) StmtError!Value { + const v = try self.evalExprValue(world, locals, id); + switch (self.ast.exprKind(id)) { + .fn_call, .method_call => {}, + else => try self.retainArena(v), + } + return v; + } + + fn evalExprValue(self: *Interpreter, world: *World, locals: *Locals, id: NodeId) StmtError!Value { const kind = self.ast.exprKind(id); const data = self.ast.exprData(id); switch (kind) { @@ -6384,8 +6467,8 @@ pub const Interpreter = struct { } if (recv == .map_persistent) { // `m[k] -> V?` on a resource map: byte/value key - // match (keys are promoted `.string_persistent`); the found - // value is a borrowed view (the map owns it, outlives the body). + // match (keys are promoted `.string_persistent`); the optional + // holding the found value counts it. const key_v = try self.evalExpr(world, locals, ix.index); var found: ?Value = null; for (persistentMapOf(recv.map_persistent).items) |pair| { @@ -6395,6 +6478,7 @@ pub const Interpreter = struct { } } const oh: u32 = @intCast(self.optionals.items.len); + if (found) |fv| try self.retainArena(fv); try self.optionals.append(self.gpa, found); return Value{ .optional = oh }; } @@ -6441,7 +6525,12 @@ pub const Interpreter = struct { var captured: std.AutoHashMapUnmanaged(StringId, Value) = .empty; errdefer captured.deinit(self.gpa); var it = locals.map.iterator(); - while (it.next()) |e| try captured.put(self.gpa, e.key_ptr.*, e.value_ptr.value); + while (it.next()) |e| { + // A captured local can hold a value no evaluation counted, + // such as a loop element. + try self.retainArena(e.value_ptr.value); + try captured.put(self.gpa, e.key_ptr.*, e.value_ptr.value); + } const handle = try self.closures.newClosure(self.gpa, id, captured); return Value{ .closure = handle }; }, @@ -13677,6 +13766,397 @@ test "a store-owned view and a resource string with the same bytes are one set e try std.testing.expectEqual(@as(i64, 1), readResourceIntNamed(&world, "R", "n")); } +/// One checked program, run for `ticks`, then `R.out` read back. +fn expectCheckedOut(source: []const u8, ticks: u32, expected: []const u8) !void { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try checkCleanProgram(gpa, source); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const report = try interp.runFor(&world, ticks); + try std.testing.expectEqual(@as(u64, 0), report.runtime_errors); + try expectResourceStringField(&world, "R", "out", expected); +} + +/// `expectCheckedOut` for a program the checker refuses, which is how a caller +/// that skips it reaches these paths. +fn expectUncheckedOut(source: []const u8, ticks: u32, expected: []const u8) !void { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, source); + defer pr.deinit(gpa); + var interp = try compileUnchecked(gpa, &pr, &world); + defer interp.deinit(); + const report = try interp.runFor(&world, ticks); + try std.testing.expectEqual(@as(u64, 0), report.runtime_errors); + try expectResourceStringField(&world, "R", "out", expected); +} + +test "a string concatenation keeps its left operand alive while its right operand runs" { + try expectCheckedOut( + \\resource R { name: string = "", out: string = "", done: bool = false } + \\rule r() when resource R { + \\ if get(R).done == false { + \\ get_mut(R).done = true + \\ get_mut(R).name = "old" + \\ get_mut(R).out = get(R).name + if true { + \\ get_mut(R).name = "new" + \\ "!" + \\ } else { + \\ "?" + \\ } + \\ } + \\} + , 1, "old!"); +} + +test "an arena struct keeps the resource string it was built from" { + try expectCheckedOut( + \\struct P { a: string } + \\resource R { name: string = "", out: string = "", done: bool = false } + \\rule r() when resource R { + \\ if get(R).done == false { + \\ get_mut(R).done = true + \\ get_mut(R).name = "old" + \\ let p = P { a: get(R).name } + \\ get_mut(R).name = "new" + \\ get_mut(R).out = p.a + \\ } + \\} + , 1, "old"); +} + +test "a resource map lookup keeps the value it found after the key is replaced" { + try expectCheckedOut( + \\resource R { m: [string: string] = ["k": "x"], out: string = "", done: bool = false } + \\rule r() when resource R { + \\ if get(R).done == false { + \\ get_mut(R).done = true + \\ get_mut(R).m.insert("k", "old") + \\ let o = get(R).m["k"] + \\ get_mut(R).m.insert("k", "new") + \\ if let v = o { + \\ get_mut(R).out = v + \\ } + \\ } + \\} + , 1, "old"); +} + +test "a closure keeps a captured loop element its collection has dropped" { + try expectCheckedOut( + \\resource R { names: string[] = ["alpha", "beta"], out: string = "", done: bool = false } + \\rule r() when resource R { + \\ if get(R).done == false { + \\ get_mut(R).done = true + \\ let mut c = |x: int| "" + \\ let mut first = true + \\ for s in get(R).names { + \\ if first { + \\ c = |x: int| s + \\ first = false + \\ } + \\ } + \\ let a = get_mut(R).names.pop() + \\ let b = get_mut(R).names.pop() + \\ get_mut(R).out = c(0) + \\ } + \\} + , 1, "alpha"); +} + +test "a local assigned a resource string holds its own reference" { + try expectCheckedOut( + \\resource R { a: string = "", b: string = "", out: string = "", stage: int = 0 } + \\rule copy() when resource R { + \\ if get(R).stage == 1 { + \\ get_mut(R).stage = 2 + \\ get_mut(R).out = get(R).b + \\ } + \\} + \\rule r() when resource R { + \\ if get(R).stage == 0 { + \\ get_mut(R).stage = 1 + \\ get_mut(R).a = "aa" + \\ get_mut(R).b = "bb" + \\ let mut s = get(R).a + \\ s = get(R).b + \\ } + \\} + , 2, "bb"); +} + +test "a local assigned an awaited resource string holds its own reference" { + try expectUncheckedOut( + \\resource R { a: string = "", b: string = "", out: string = "", stage: int = 0 } + \\async fn g() -> string { + \\ await wait(0.05s) + \\ return get(R).b + \\} + \\rule copy() when resource R { + \\ if get(R).stage == 2 { + \\ get_mut(R).stage = 3 + \\ get_mut(R).out = get(R).b + \\ } + \\} + \\async rule r() when resource R { + \\ if get(R).stage == 0 { + \\ get_mut(R).stage = 1 + \\ get_mut(R).a = "aa" + \\ get_mut(R).b = "bb" + \\ let mut s = get(R).a + \\ s = await g() + \\ get_mut(R).stage = 2 + \\ } + \\} + , 6, "bb"); +} + +test "a task local keeps a resource string across a suspension" { + try expectUncheckedOut( + \\resource R { name: string = "", out: string = "", n: int = 0 } + \\async rule r() when resource R { + \\ if get(R).n == 0 { + \\ get_mut(R).name = "old" + \\ let s = get(R).name + \\ await wait(0.05s) + \\ get_mut(R).out = s + \\ } + \\} + \\rule rename() when resource R { + \\ get_mut(R).n = get(R).n + 1 + \\ if get(R).n == 1 { + \\ get_mut(R).name = "new" + \\ } + \\} + , 5, "old"); +} + +test "a timer snapshot keeps a resource string until the timer fires" { + try expectUncheckedOut( + \\resource R { name: string = "", out: string = "", armed: bool = true } + \\rule sched() when resource R { + \\ if get(R).armed { + \\ get_mut(R).armed = false + \\ get_mut(R).name = "old" + \\ let s = get(R).name + \\ after(0.05s) { + \\ get_mut(R).name = "new" + \\ get_mut(R).out = s + \\ } + \\ } + \\} + , 5, "old"); +} + +test "a race winner's returned resource string survives until the race resumes" { + try expectUncheckedOut( + \\resource R { name: string = "", out: string = "", n: int = 0 } + \\async fn pick() -> string { + \\ race { + \\ { + \\ return get(R).name + \\ } + \\ { + \\ await wait(1.0s) + \\ return "late" + \\ } + \\ } + \\ return "none" + \\} + \\rule setup() when resource R { + \\ get_mut(R).n = get(R).n + 1 + \\ if get(R).n == 1 { + \\ get_mut(R).name = "old" + \\ } + \\} + \\async rule r() when resource R { + \\ if get(R).n == 1 { + \\ let s = await pick() + \\ get_mut(R).out = s + \\ } + \\} + \\rule rename() when resource R { + \\ if get(R).n == 1 { + \\ get_mut(R).name = "new" + \\ } + \\} + , 3, "old"); +} + +test "a loop variable releases each resource element it held" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try checkCleanProgram(gpa, + \\resource R { names: string[] = ["a", "b", "c"], n: int = 0 } + \\rule r() when resource R { + \\ for s in get(R).names { + \\ get_mut(R).n = get(R).n + 1 + \\ } + \\} + ); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 3), readResourceIntNamed(&world, "R", "n")); +} + +test "a resource expression guard releases the fields it bound" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try checkCleanProgram(gpa, + \\resource A { s: string = "", ready: bool = false } + \\resource B { k: int = 1 } + \\resource Out { n: int = 0 } + \\rule setup() when resource A { + \\ if get(A).ready == false { + \\ get_mut(A).ready = true + \\ get_mut(A).s = "xy" + \\ } + \\} + \\rule gated() when resource A { ready } and resource B { k == 1 } and resource Out { + \\ get_mut(Out).n = get(Out).n + 1 + \\} + ); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 1), readResourceIntNamed(&world, "Out", "n")); +} + +test "a tick returns the arena references of its async drives" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try checkCleanProgram(gpa, + \\resource R { name: string = "x", out: string = "" } + \\async rule r() when resource R { + \\ get_mut(R).out = get(R).name + \\ await wait(0.05s) + \\} + ); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(usize, 0), interp.deferred_decrefs.items.len); +} + +test "a rule body returns its arena references at its reset" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try checkCleanProgram(gpa, + \\resource R { name: string = "x", out: string = "" } + \\rule r() when resource R { + \\ get_mut(R).out = get(R).name + \\} + ); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + var report: RuntimeReport = .{}; + try interp.execBody(&world, interp.rule_descs[0], null, null, &report); + try std.testing.expectEqual(@as(usize, 0), interp.deferred_decrefs.items.len); +} + +test "an interpreter returns the arena references still held when it is torn down" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try checkCleanProgram(gpa, + \\resource R { name: string = "", out: string = "", done: bool = false } + \\rule r() when resource R { + \\ if get(R).done == false { + \\ get_mut(R).done = true + \\ get_mut(R).name = "old" + \\ get_mut(R).out = get(R).name + \\ get_mut(R).name = "new" + \\ } + \\} + ); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + interp.suppress_body_store_resets = true; + _ = try interp.runFor(&world, 1); + try std.testing.expect(interp.deferred_decrefs.items.len > 0); +} + +/// The async walk of the two tests below: `swap` replaces the array at tick 2, +/// while `walk` waits on its first element. +const async_walk_source = + \\resource R { xs: int[] = [1, 2, 3], sum: int = 0, tick: int = 0 } + \\async rule walk() when resource R { + \\ for x in get(R).xs { + \\ await wait(0.05s) + \\ get_mut(R).sum = get(R).sum + x + \\ } + \\} + \\rule swap() when resource R { + \\ get_mut(R).tick = get(R).tick + 1 + \\ if get(R).tick == 2 { + \\ get_mut(R).xs = [100] + \\ } + \\} +; + +test "an async for suspended at teardown releases the array it iterates" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try checkCleanProgram(gpa, async_walk_source); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + _ = try interp.runFor(&world, 3); + try std.testing.expectEqual(@as(i64, 0), readResourceIntNamed(&world, "R", "sum")); +} + +test "an async for keeps iterating a resource array reassigned while it waits" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try checkCleanProgram(gpa, async_walk_source); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const report = try interp.runFor(&world, 12); + try std.testing.expectEqual(@as(u64, 0), report.runtime_errors); + try std.testing.expectEqual(@as(i64, 6), readResourceIntNamed(&world, "R", "sum")); +} + +test "a sync for keeps iterating a resource array its body reassigns" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try checkCleanProgram(gpa, + \\resource R { xs: int[] = [1, 2, 3], sum: int = 0, done: bool = false } + \\rule r() when resource R { + \\ if get(R).done == false { + \\ get_mut(R).done = true + \\ for x in get(R).xs { + \\ get_mut(R).xs = [7] + \\ get_mut(R).sum = get(R).sum + x + \\ } + \\ } + \\} + ); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const report = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 0), report.runtime_errors); + try std.testing.expectEqual(@as(i64, 6), readResourceIntNamed(&world, "R", "sum")); +} + test "a sync for over an arena array fails loud when its body shrinks it" { const gpa = std.testing.allocator; var world = World.init(); diff --git a/src/etch/value.zig b/src/etch/value.zig index 662a3056..d2757239 100644 --- a/src/etch/value.zig +++ b/src/etch/value.zig @@ -116,10 +116,9 @@ pub const Value = union(enum) { /// name (interned `StringId`) and the variant's declaration-order index. /// Value-typed: compared by `(type_name, variant)` equality. enum_value: EnumValue, - /// A borrowed view over a resource `string` field's persistent-heap bytes. The read - /// path returns this without incref'ing the block — safe for the rule body because - /// the resource (hence the bytes) outlives it (`etch-memory-model.md` §11). - /// Self-contained `{ptr,len}` so + /// A view over a resource `string` field's persistent-heap bytes. The read takes + /// no reference; every holder that keeps the value counts one + /// (`etch-memory-model.md` §4.4). Self-contained `{ptr,len}` so /// `readBytesAsValue` can build it with no allocator and no interpreter store; /// `ptr == 0` ⇔ the empty string. Additive — does not disturb `string_id` (AST /// pool) / `string_run` (rule-arena) semantics. @@ -134,9 +133,8 @@ pub const Value = union(enum) { /// `persistent` `type_array` block whose payload is the owned /// `ArrayListUnmanaged(Value)`). Mirrors `.string_persistent`'s persistent-vs- /// rule-arena split against `.array_ref`: the zone is known at the tag, no - /// runtime discriminant, drop dispatched by `type_id`. The read path returns - /// it without incref — safe for the rule body because the resource (hence the - /// block) outlives it. Never `0` for a live field (the empty collection is a + /// runtime discriminant, drop dispatched by `type_id`. Counted like + /// `.string_persistent`. Never `0` for a live field (the empty collection is a /// real empty block allocated with the resource's store buffer). String /// elements are stored as owned `.string_persistent`; POD elements inline. array_persistent: u64, diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 299bbe46..f8ba9338 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2577, - else => 2579, + .windows => 2594, + else => 2596, }; } From c0a1fafb128c03e2bcf30d239d221a86ea24effa Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Thu, 24 Sep 2026 01:20:03 +0200 Subject: [PATCH 042/141] docs(brief): record S5/G8 quater, the caches, the tag split and (A) The two cache corrections and their measured cost on run 35926601770, the two known windows classes red at b3fd9517, the tag split and its four counter-factuals, the sync-for shrink check, and (A) with its seventeen witnesses and twenty counter-factuals, predicted before they ran. B3 carries its answer. CLAUDE.md read PR #81 as unmerged in two rows, and drew from that a conclusion now false; both corrected. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 +- briefs/m1.d-phase-1-debt.md | 257 ++++++++++++++++++++++++++++++++++++ 2 files changed, 259 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8dbed557..5e8bf1d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,9 +10,9 @@ knowledge base — see § Quick links spec. | Field | Value | |---|---| | Phase | 1 (Etch ↔ ECS) | -| Current milestone | **M1.D — Phase 1 debt. S5 CLOSED at G7, PR #82 open.** S5's subject is the fifteen entries M1.D ITSELF created, under a rule the milestone did not have: *a defect found while working closes in the same session, or it is a STOP and an arbitration, never a new number*. **Measured on the delivered lot (`sha256 6da1d093…`, 648 lines): of those fifteen, TEN are closed — `M1.D.40` by S4, and `44`, `45`, `38`, `39`, `46`, `48`, `36`, `37`, `35` by S5 — and FIVE leave the milestone**: `34`, `41`, `42`, `43`, `47`, four architecture decisions and one restructure, none of them accumulated debt. **S5 minted ZERO new entries**, which is what it existed to demonstrate against a milestone that minted nine. Floor 2379 → **2395** / 2393 over seven commits. **THREE ENTRIES' OWN TEXT WAS CONTRADICTED BY THE TREE**, which is this milestone's purpose turned on its own output: `M1.D.47` does not overlap `44`/`45` at all — the digest input path holds ZERO `ResolvedType` references across its four functions, and the premise was a HOMONYM, *zone* (a value's memory lifetime) against `@storage(.sparse)` (the ECS storage mode, which `registry.zig` declares out of the hash by `ARCH-005`); `M1.D.35`'s recorded blocker was dissolved by `M1.D.21` one session earlier in the same milestone; and `M1.D.41`'s sixteen renames are UNSATISFIABLE, seven subjects collapsing onto one reserved name. **THE STANDING RESULT: a counter-measure written down stops nothing until an instrument executes it.** `tools/weld_lint/dead_tests.zig:920` names the `b.path(spec.path)` trap in the repository's own words and the linter resolves that shape; my own extraction walked into it anyway and under-counted `M1.D.41` by exactly three — tenth instance of the selector family and the FIRST where the parade existed before the error. Four errors of mine in S5, every one caught by an instrument and none by re-reading, plus a fifth in the closing tally itself (a count of what the SESSION closed, published under a heading naming what the MILESTONE created). And three blind probes in one gate whose first cause was ONE CHARACTER — `wait(1s)` is not a valid duration literal, so every async case was silently unparsed and BOTH negative controls read zero. The control shape that ended it — both negatives firing and both INT controls silent in the same execution — is now the form of every probe. Sessions S1–S4 are PR #81, unmerged, and their record stands in `briefs/m1.d-phase-1-debt.md`. M1.A — ECS access enforcement — CLEARED before all of it and is tagged `v0.11.19-ecs-access-enforcement`. | +| Current milestone | **M1.D — Phase 1 debt. S5 CLOSED at G7, PR #82 open.** S5's subject is the fifteen entries M1.D ITSELF created, under a rule the milestone did not have: *a defect found while working closes in the same session, or it is a STOP and an arbitration, never a new number*. **Measured on the delivered lot (`sha256 6da1d093…`, 648 lines): of those fifteen, TEN are closed — `M1.D.40` by S4, and `44`, `45`, `38`, `39`, `46`, `48`, `36`, `37`, `35` by S5 — and FIVE leave the milestone**: `34`, `41`, `42`, `43`, `47`, four architecture decisions and one restructure, none of them accumulated debt. **S5 minted ZERO new entries**, which is what it existed to demonstrate against a milestone that minted nine. Floor 2379 → **2395** / 2393 over seven commits. **THREE ENTRIES' OWN TEXT WAS CONTRADICTED BY THE TREE**, which is this milestone's purpose turned on its own output: `M1.D.47` does not overlap `44`/`45` at all — the digest input path holds ZERO `ResolvedType` references across its four functions, and the premise was a HOMONYM, *zone* (a value's memory lifetime) against `@storage(.sparse)` (the ECS storage mode, which `registry.zig` declares out of the hash by `ARCH-005`); `M1.D.35`'s recorded blocker was dissolved by `M1.D.21` one session earlier in the same milestone; and `M1.D.41`'s sixteen renames are UNSATISFIABLE, seven subjects collapsing onto one reserved name. **THE STANDING RESULT: a counter-measure written down stops nothing until an instrument executes it.** `tools/weld_lint/dead_tests.zig:920` names the `b.path(spec.path)` trap in the repository's own words and the linter resolves that shape; my own extraction walked into it anyway and under-counted `M1.D.41` by exactly three — tenth instance of the selector family and the FIRST where the parade existed before the error. Four errors of mine in S5, every one caught by an instrument and none by re-reading, plus a fifth in the closing tally itself (a count of what the SESSION closed, published under a heading naming what the MILESTONE created). And three blind probes in one gate whose first cause was ONE CHARACTER — `wait(1s)` is not a valid duration literal, so every async case was silently unparsed and BOTH negative controls read zero. The control shape that ended it — both negatives firing and both INT controls silent in the same execution — is now the form of every probe. Sessions S1–S4 are PR #81, merged as `c3e6d316`, and their record stands in `briefs/m1.d-phase-1-debt.md`. M1.A — ECS access enforcement — CLEARED before all of it and is tagged `v0.11.19-ecs-access-enforcement`. | | Last released tag | `v0.11.19-ecs-access-enforcement` (M1.A). M1.D carries no tag: a debt milestone ships none, on the hotfix precedent. | -| Active branch | `phase-1/debt/m1d-created`, head = PR #82's tip (PR #82, **open**, not merged — the merge and the tag are Guy's). PR #81 (`phase-1/debt/phase-1-debt`, M1.D sessions S1–S4) precedes it and is also unmerged, so **two debt PRs are open at once and #82 branches from `main`, not from #81** — it therefore carries none of #81's work and the two do not conflict by construction. *Written at the close of the session it names, which is the only moment it can be true.* | +| Active branch | `phase-1/debt/m1d-created`, head = PR #82's tip (PR #82, **open**, not merged — the merge and the tag are Guy's). PR #81 (`phase-1/debt/phase-1-debt`, M1.D sessions S1–S4) was merged on 2026-09-21 as `c3e6d316`, which is the base PR #82 branches from. *Written at the close of the session it names, which is the only moment it can be true.* | | Next planned milestone | M1.2.0 — Kinesis core: the skeleton system and the `BoneRef` addressing `ARCH-033` requires before `AnimationModule` freezes. **Unchanged by M1.D** — which does move plan rows, sixteen of them closed and seven minted, but delivers no feature and therefore no plan ROW of its own. The former wording here (*« delivers no program line »*) is the same false premise corrected in the milestone row above. | | CI matrix | `{ubuntu-24.04, windows-2025, ubuntu-24.04-arm} × {Debug, ReleaseSafe} × {f32, f64}` **plus one `ubuntu-24.04 / ReleaseFast / f32` cell** — **13 cells**, every one pinned `-Dcpu=baseline` (`ARCH-031` rule 6, third axis). `zig build lint` and `zig build forge-determinism` both run on the cell path; before M1.1.14 the first ran in NO workflow and the second in none either. **The thirteenth cell is M1.1.15.1/H1's and is deliberately NOT an axis**: `std.debug.assert` is compiled to nothing in ReleaseFast, so with {Debug, ReleaseSafe} alone every assert in the tree was verified in exactly the two modes where its breach costs nothing. A release-stripped assert is MODE-dependent and neither platform- nor precision-dependent, so one cell detects the whole class where completing the axis would cost 50 % of the matrix for the same detection — the reason is written in `ci.yml` at the cell so nobody completes it by symmetry. Cache restored to every cell, keyed by os · mode · precision · cpu · zig version · zon hash · sha, with an all-or-nothing size guard on BOTH save steps. Since M1.1.15.2, `zig build bindgen-check` runs on the `ubuntu-24.04 / Debug` cell AND again under `-Dphysics_f64=true` — one STEP and not a fourteenth cell, the answer being a property of the source; the f64 step exists because the single-cell arbitration rested on "no emitted type follows `Real`", which became a claim about the physics service the moment it entered the manifest. | | Determinism instrument | `zig build forge-determinism` — canonical scenario, 1000 frames, one worker, no RNG, **NINE elements** since the review: the eighth and ninth are a kinematic character on a riser and three mesh ramps forming a closed bowl, plus a lone box that sleeps inside the compared window, whose surface cosines bracket `cos(max_slope)` on both sides so a wrong cosine costs METRES of trajectory. **Eight witnesses committed** under `src/modules/forge/forge_3d/tests/determinism/witnesses/` with `SHA256SUMS.txt` and a `PROVENANCE.txt` carrying run URL, cell, CPU pinning, PR-head sha, cross-mode result, the REPORTED `zig version`, and a per-file generator mode. Regeneration is gated on a `Witness-regen:` trailer in the PR head commit. **Replayed by M1.1.21.1 at N workers and by M1.A on a rebuilt DAG** — it is an instrument, not a test. | diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index 01979c98..707ff986 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -5671,6 +5671,256 @@ log stays in a file, passes its exit, completion and insertion checks. Two skips fewer on macOS (19 → 17): the PSNR gate left the suite and `capture.zig` was deleted. +### S5/G8 quater — the two cache corrections, the tag split, and (A) + +Guy's ruling after G8 ter: two CI corrections, then `M1.D.47` decided on +`etch-memory-model.md` (sha256 `9c97b7d0…`) — §4.4, every copy of a handle +increfs, a returned handle transfers its reference, a persistent handle an arena +value holds increfs and returns its reference at the arena's reset through a +deferred-decrement list — with a prerequisite before any decref: the three +headerless values move off `.string_persistent`. Then (A) over the whole +enumerated perimeter, Zig-side transient holders included. Stop there. + +#### The caches, saved from `main` only (`b3fd9517`) + +The final save's `github.head_ref == 'phase-1/debt/phase-1-debt'` clause is +removed and not replaced. A cell saves on `push`, and both workflows trigger +`push` on `main` alone, so no cache condition and no key names a branch, +hard-coded or by pattern: `.github/` carries no `head_ref` and no `ref_name`, +and the only branch it names is `main`, in the two triggers. A pull request +restores by prefix. + +The smoke, vertical-slice and bench caches had a key made of operating system, +mode, CPU, Zig version and the `build.zig.zon` hash, so a restored entry was +never replaced and the three jobs rebuilt everything that had changed since it +was written, forever. Each now keys on the commit with a prefix fallback, its +restore and save split, the save on `push` only — the cells' form. + +`CLAUDE.md` still gave the Release cells 55 minutes; they have had 75 since +`c3e6d316`, and Debug 35. + +Predictions written before the push, measured on run +`35926601770` (PR event, `b3fd9517`): + +| Prediction | Measured | +|---|---| +| `Save Zig cache (final)` skipped on every cell | skipped on all 13 | +| Release cells restore `main`'s `…-c3e6d316…` entry by prefix | all 7 do | +| about 145 compile steps built, 16 cached, on `ubuntu-24.04 / ReleaseSafe / f32` | 145 built, 16 cached | +| smoke and vertical slice: `Cache not found`, save skipped, cold build | not observed: both jobs need `build-and-test` and were skipped, two cells having failed (below) | +| bench, both OSes: `Cache not found`, save skipped | both, as predicted | + +**The cost, measured and not compensated.** A pull request now pays, on every +Release cell, the compile steps whose closure reaches a file changed since +`main`'s last save. On this run, at `M1.D` S5's divergence from `c3e6d316`: + +| Cell | Job | `zig build` | `zig build test` | compile steps built / cached | +|---|---|---|---|---| +| `ubuntu-24.04 / ReleaseSafe / f32` | 39.2 | 6.6 | 31.6 | 145 / 16 | +| `ubuntu-24.04 / ReleaseSafe / f64` | 32.2 | 5.3 | 25.8 | 142 / 19 | +| `ubuntu-24.04 / ReleaseFast / f32` | 28.7 | 4.0 | 24.0 | 145 / 16 | +| `ubuntu-24.04-arm / ReleaseSafe / f32` | 39.4 | 5.0 | 33.3 | 142 / 19 | +| `ubuntu-24.04-arm / ReleaseSafe / f64` | 41.4 | 5.8 | 34.4 | 142 / 19 | +| `windows-2025 / ReleaseSafe / f32` | 56.2 | 11.5 | 44.2 | 142 / 19 | +| `windows-2025 / ReleaseSafe / f64` | 55.5 | 8.7 | 45.5 | 145 / 16 | + +The same kind of cell on a branch restoring its OWN previous commit — PR #81 at +`a3f08f3b`, a two-file documentation diff over `5749acab`: 1.0 to 9.3 minutes, 0 +to 8 of 156 compile steps built. So the price of the correction is the +difference between those two rows, paid by every pull request once per run and +growing with its divergence from `main`. Test execution itself is not in it: it +stays at 0.2 to 0.3 minutes per cell. + +For PR #82 the correction changes NOTHING on the cells, and the previous run is +the proof: the clause named PR #81's branch, so PR #82 was already restoring +`main`'s entry — run `35885695416` at `35635771`, Release cells 34.2 to 56.1 +minutes, 142 to 145 built. What the correction removes is the warm lineage a +branch could build for itself; it does not make this pull request slower. + +The Debug cells restore nothing in either regime — their restore step is +skipped by design — and build 158 of 161 steps both times. + +Bench (cold, no entry under the new prefix): `bench-ecs` smoke 5.2 min on +ubuntu and 8.0 on windows, the crossover sweep 10.4 and 6.6, against 4.6 / 11.0 +and 6.0 / 6.8 on the frozen entry at `35635771`. The frozen entry was a stale +tree, so it saved little, and the differences carry both signs. + +Two consequences follow from the key and are not defects of this change: until +`main` pushes, nothing saves at all — the smoke, slice and bench lineages start +at the next merge — and the ISO week is in the prefix of every `ci.yml` key, +so in a new week no pull request restores a cell, smoke or slice cache until +`main` has pushed in that week. The bench key carries no week. + +#### Two windows cells red at `b3fd9517`, both known classes + +The diff is two workflow files and `CLAUDE.md`, no Zig, so neither is +attributed to it. + +`windows-2025 / ReleaseSafe / f32` hung — `M1.D.19`: `test runner failed to +respond` once, no failed assertion, `2520/2550 tests passed (30 skipped)` +against a windows floor of 2571, **21 tests lost**, the hung step `0edd5447…`, +56.2 minutes against 75. The `f64` sibling passed in 55.5. + +The `windows-2025 / Debug / f32` cell failed on +`error.Win32ThreadSafetyTimeout` (`win32_thread_safety_test.zig:96`), +`2540/2571 tests passed (30 skipped, 1 failed)` — collected equals the windows +floor exactly, so no test was lost, and the hang signature is absent. 9.0 +minutes, the `Debug / f64` sibling green. Third occurrence of the class +`CLAUDE.md` records as needing a number. + +#### The tag split (`5ad26b59`) — the prerequisite + +Three values reached `.string_persistent` with no block header behind them: the +event store's copy of an escaping string, a captured event filter's copy, and an +`active_extensions` element, a view of the world's own name. A decref on any of +them reads eight bytes before the allocation. They are now `.string_view`: a +view, never counted. `Value.eql` compares a persistent string and a view by +bytes across the two tags, so a set holding one and inserting the other still +deduplicates. + +Four witnesses and four counter-factuals, predictions written before the batch, +each landing exactly: T1 to T3 each reddened its own witness alone; T4 — the byte pre-check moved behind the tag test — reddened the set +witness alone, `expected 1, found 2`. + +#### The adjacent defect (`74b44e29`) + +Found while enumerating (A)'s holders, closed here. The sync `for` arms snapshot +the length and re-index at each step, so a `pop` in the body of a loop over an +array reads past the end: a safety panic in Debug and ReleaseSafe, an +out-of-bounds read in ReleaseFast. The async loops already re-check; the two +array arms now do and fail the body. The two map arms need none, no map method +removing an entry. The resource case is a checked program; the arena case is +reachable only past the checker, which gives a local array no methods — and the +interpreter runs such programs. C19 and C20 below. + +#### (A) — every holder counts (`062908db`) + +The enumeration, written before the code, listed the arena +containers as a class of their own, each write retaining. Measured at the code +they are not: a struct field, an array element, a map entry, a set member or an +optional payload built from an evaluation result holds the count that result +already took, in the same list and drained at the same reset. A second retain +at the write is a second count of one copy, and no program can tell it apart — +so it was not written. Two container writes read a value no evaluation counted, +and count it themselves: the payload of a resource map lookup (the lookup's +result is the optional, not the value inside it) and a closure's captured +locals (a local can hold a loop element no evaluation ever produced). C1 shows +the containers are covered: with the evaluation uncounted, the arena-struct +witness reads freed bytes. + +The holders, as delivered: +- **Evaluation**: `evalExpr` counts every result as an arena value; a call's + result transfers, the callee's own evaluation having counted it. This is what + covers the Zig-side transients: the left operand of `+` while the right one + runs, a sync `for`'s iterable while its body runs, a receiver, an index. +- **Locals** own one reference per handle: `put` retains and releases what it + replaces, `clear` and `deinit` release, an assignment goes through + `replaceHeld` at both of its sites, a snapshot through `put`. +- **An async `for` frame** holds the collection it iterates, released on every + pop and at a task's teardown; **a task** holds the result it parks. +- **Drains**: at each arena reset, at the end of each tick — an async drive + never resets the arena — and at teardown. + +Seventeen witnesses. Four run past the checker, which refuses the holding with +`E0223`: a task local across a suspension, a timer snapshot, an awaited +assignment, and a race winner's returned string. Every victim is a counted +block. A resource string's default is an immortal one, on which a count is a +no-op and no witness could see anything, so each string witness writes its +field at run time first; a collection default's container and elements are +counted from creation. + +Twenty counter-factuals, predictions written before the batch, run in a +worktree pinned to `5b3b1dd1` — the tree of `062908db`, whose message was +amended afterwards to state the result: + +| CF | Removed | Predicted | Observed | +|---|---|---|---| +| C1 | `evalExpr`'s count | concatenation and arena-struct witnesses read freed bytes; interpreter-teardown witness finds nothing held; sync-for witness crashes | exactly: `\xaa\xaa\xaa!` and `\xaa\xaa\xaa`, `expect` false, segfault at `0xaaaaaaaaaaaaaaca` | +| C2 | the Locals class, both assignment sites included | task-local and timer-snapshot witnesses read freed bytes | exactly | +| C3 | `replaceHeld` at `execAssign` | the assigned-local witness reads freed bytes, `aa` leaks | exactly, 1 leak | +| C4 | `replaceHeld` at an awaited assignment | the awaited-local witness reads freed bytes, `aa` leaks | exactly, 1 leak | +| C5 | `put`'s release of the value it replaces | loop-variable and closure witnesses leak, and pre-existing tests iterating a resource collection of strings | exactly those two, plus `resource [string: int] insert/get/contains/len/iteration persists`; 4 leaks, no garbage | +| C6 | `deinit`'s release | every witness whose scope ends holding a handle leaks (the map, closure, both assignment, task-local, snapshot, race and loop witnesses), plus pre-existing tests of that shape; concatenation, struct and guard witnesses green | exactly the eight, plus the map-iteration test and two `hot_reload_test` cases (8 allocations each); every red a leak | +| C7 | the guards' `clear` | the guard witness leaks | exactly, 1 leak | +| C8 | `cloneLocalsInto` through `put` | the timer-snapshot witness reads freed bytes | exactly | +| C9 | the async `for` frame's reference, both halves | the suspended-at-teardown witness stays green; the reassigned-while-waiting witness crashes | exactly: segfault | +| C10 | the `for` frame's release | both async-for witnesses leak, plus pre-existing async `for` tests over a resource collection | exactly, plus `resource int[] iterated by an async for-in across a suspend` | +| C11 | `AsyncTask.deinit`'s release of `for` frames | the suspended-at-teardown witness leaks | exactly | +| C12 | a task's reference on its parked result, both halves | the race witness reads freed bytes | exactly | +| C13 | the task's release of its result | the race witness leaks | exactly | +| C14 | the map lookup's count | the map witness reads freed bytes | exactly | +| C15 | the closure capture's count | the closure witness reads freed bytes | exactly | +| C16 | the tick-end drain | `a tick returns …` `expected 0, found 1` | exactly | +| C17 | the reset's drain | `a rule body returns …` `expected 0, found 1` | exactly | +| C18 | the teardown drain | the interpreter-teardown witness leaks | exactly, 1 leak | +| C19 | the arena sync-for check | index out of bounds | exactly: `index out of bounds: index 2, len 1` | +| C20 | the resource sync-for check | index out of bounds | exactly: `index out of bounds: index 2, len 1` | + +Every prediction about the CODE held. Three things did not, and none is a +witness: +- **A prediction about the harness was false.** It said a crash kills + the test binary, so the tests declared after the crashing one do not run. + Zig 0.16's build runner restarts the binary after a crash and runs the rest: + C1 reads `2575/2596 tests passed (17 skipped, 3 failed, 1 crashed)`, the sum + exactly the suite. +- **C2's first run measured nothing.** The counter-factual's own edit left + `clear`'s `gpa` unused, a compile error, and 39 steps failed before a test + ran. Re-run with `_ = gpa;`. +- **The log summariser dropped a name.** Its pattern stopped at the first + apostrophe, so `a race winner's …` vanished from C1 to C6. Found by a count: + C6's step reported ten leaks where nine tests were named. Every log was + re-read with the corrected pattern before any verdict above was written. + +#### Stale contracts corrected + +`value.zig` said a borrowed resource string was safe for the rule body "because +the resource outlives it", and said it of collections too; `ecs_bridge.zig` +said it of the collection read. Both now say the read takes no reference and +every holder that keeps it counts one. The `ForIter` and resource-map lookup +comments followed. + +#### Tests removed or changed + +None removed or weakened. Nineteen added: seventeen (A) witnesses, two for the +shrink check. + +#### Found outside the gate + +`CLAUDE.md` read PR #81 as unmerged in two rows, and its Active-branch row +drew from that a conclusion now false — that PR #82 carries none of #81's work. +`main` has carried #81 since 2026-09-21 as `c3e6d316`, and PR #82 branches from +it. Both sentences corrected. + +#### What G8 quater stops before + +Decision 2 — struct event fields refused at declaration, `parseEmitStmt` and +the event filter buffering fields as `parseStructLiteral` does; decision 3 — +prefab hooks checked at the source and at load; decision 4 — the zone effects +of string comparison and interpolation, inside `M1.D.47`; the third axis, +`let v: int = [1, 2, 3]`, executed before it is corrected; a codegen panic that +interrupts the rule and not the process (§9.1); and the four leftovers — +`TIME_LITERAL` refused out of range, `asset_cook` keeping edited settings, +`type` as an `asset_field` name once the assets carrying the key are counted, +unknown escapes refused. Then `M1.D.41` with `M1.D.42`, `M1.D.34`, and the S5 +closure. + +#### Gates + +On `062908db`, each exit code read before any filtering: +- `zig build test`, Debug, ReleaseSafe and `-Dphysics_f64=true`: exit 0, + `327/327 steps; 2579/2596 tests passed (17 skipped)` in all three. +- `zig build lint`: exit 0, conservation OK at 2596. +- `zig fmt --check .`: exit 0. +- `bindgen-verify` (`9/9 steps`), `vk-gen-check` (`10/10`), + `test-codegen-diff` (`1/1`), `verify-synth-100`, `forge-asm-inventory + -Dphysics_f64=true`: exit 0. +- `forge-determinism`: exit 0, `none within K=60`, both trace verdicts OK. +- `ecs-access-counterproof`: exit 0, `8/8 steps`. +- `shaders-check`: exit 0, `checked 6 shader(s): ok`. + +**Floor 2577 → 2579 → 2596, windows 2575 → 2577 → 2594**, re-derived from the +suite at each commit. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree @@ -5841,6 +6091,13 @@ type, so a zone consulted only for arena-capable types misses it. The design choices — reference per holder or deferred release, struct event fields, prefab hook bodies — are returned to Guy. +**Answered after G8 ter**, on `etch-memory-model.md` (sha256 `9c97b7d0…`): +every holder counts, and a handle an arena value holds returns its reference at +the arena's reset through a deferred-decrement list — delivered at G8 quater as +(A). Struct event fields refused at declaration, prefab hooks checked at the +source and at load, and the zone effects of string comparison and interpolation +are the next gates. + ## Notes ### A comparison refuses the fingerprint of nothing before it concludes From 958c24be36ddd6b8c4846869bdb61f2e4875371d Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Thu, 24 Sep 2026 02:24:43 +0200 Subject: [PATCH 043/141] docs(brief): record the cold smoke and slice runs of G8 quater Measured on run 35933216948 at c0a1fafb, the run the previous one skipped, plus that run green with the windows floor measured. Co-Authored-By: Claude Opus 5.5 --- briefs/m1.d-phase-1-debt.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index 707ff986..51eb5c3b 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -5710,6 +5710,12 @@ Predictions written before the push, measured on run | smoke and vertical slice: `Cache not found`, save skipped, cold build | not observed: both jobs need `build-and-test` and were skipped, two cells having failed (below) | | bench, both OSes: `Cache not found`, save skipped | both, as predicted | +The smoke and slice rows were observed on the next run, `35933216948` at +`c0a1fafb`: `Cache not found` and save skipped on both, as predicted. Cold, the +smoke job took 6.75 minutes, its `zig build` 4.85, against 8.2 and 6.52 on the +frozen entry at `35635771`; the vertical slice 1.1 against 1.6. The frozen +entries cost more to restore than they saved. + **The cost, measured and not compensated.** A pull request now pays, on every Release cell, the compile steps whose closure reaches a file changed since `main`'s last save. On this run, at `M1.D` S5's divergence from `c3e6d316`: @@ -5918,6 +5924,9 @@ On `062908db`, each exit code read before any filtering: - `ecs-access-counterproof`: exit 0, `8/8 steps`. - `shaders-check`: exit 0, `checked 6 shader(s): ok`. +CI at `c0a1fafb`, run `35933216948`: every job green. The windows cells collect +2594 (`2564/2594 tests passed (30 skipped)`), the declared floor. + **Floor 2577 → 2579 → 2596, windows 2575 → 2577 → 2594**, re-derived from the suite at each commit. From bb8192936164e119d4e94dd9fb2dd11ce1de03d4 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Thu, 24 Sep 2026 08:07:05 +0200 Subject: [PATCH 044/141] docs(brief): record the M1.D.16 measurement before the tool Where a verified-green verdict can be read, which tree a pull request run tests, why fingerprint cannot serve as the digest, and the hole in the doc allow-list. Stopped before writing the tool. Co-Authored-By: Claude Opus 5.5 --- briefs/m1.d-phase-1-debt.md | 58 +++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index 51eb5c3b..02ac63e6 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -5930,6 +5930,64 @@ CI at `c0a1fafb`, run `35933216948`: every job green. The windows cells collect **Floor 2577 → 2579 → 2596, windows 2575 → 2577 → 2594**, re-derived from the suite at each commit. +### S5/G9 — `M1.D.16` measured before the tool, and stopped there + +Guy's ruling: a commit that touches only documentation launches no build, and +the decision that says so lives in a tested Zig tool of the repository, the +YAML only calling it. First question, asked before any code: how the tool will +know that a tree has already been verified green, and where that fact is kept. +Measured read-only, against the API and the tree. + +**The tested tree is not always the head tree.** A `pull_request` run checks out +`refs/pull/82/merge` (run `35933216948`: `HEAD is now at 0571619 Merge c0a1fafb… +into c3e6d316…`), while the required `ci-gate` check run attaches to the head +sha, and nothing in the run, its jobs, its check suite or its artifacts records +the merge sha. The two trees coincide when `main` is an ancestor of the head — +measured for both PR #82 runs (`05716198` has the tree of `c0a1fafb`) and for PR +#81, whose squash commit `c3e6d316` carries the tree of its green head +`a3f08f3b`. They differ as soon as `main` moves. + +**The digest cannot be `fingerprint`.** `census.fingerprint` hashes the Zig token +stream of one file and drops whitespace and every comment, where CI judges both +(`zig fmt --check`, the comment rules of `zig build lint`), and three files embed +their own source. It reads only `.zig` files, where a build also reads 321 `.etch` +files and `.glsl`, `.spv`, `.zon` and `.bin` inputs. What serves is git's own +addressing: `(mode, blob sha, path)` over the tracked files of a commit, read from +the objects, 1112 rows, 0.02 to 0.04 s, independent of the checkout's line endings. +The path belongs in it: moving a file changes a build. + +**The current doc allow-list is unsound, found here.** `*.md` exempts +`tests/etch/ebnf_examples.md`, which `ebnf_examples_test.zig:41` embeds and which +the suite asserts on (82 blocks, `count >= 82`): a PR editing that file alone would +skip the matrix, and `ci-gate` reads `skipped` as green. It is the only such +reader; no other Markdown file, brief or `CLAUDE.md` is read by any job. And +`assets/shaders/*.glsl`, outside the allow-list, is read by the hot-reload test. + +**Where the verdict can be read.** Four stores were measured: +- the `ci-gate` check run on a head sha — the record the ruleset itself reads + (ruleset `16130427`: one required check, `ci-gate`, app 15368, strict, squash + only, linear history, no bypass). It persists, needs no write, and its latest + attempt is what the API returns, which is the same-sha exoneration `M1.D.19` + admits; +- the Actions cache — a PR run reads `main`'s entries (measured) and, per the + documentation, writes only its own scope; best effort, and the repository sits + at 10 691 435 251 bytes of a 10 737 418 240 cap, 44 MiB of headroom; +- commit statuses — unused, and writing one needs a permission the workflow does + not grant (`contents: read` only, measured in the job log); +- artifacts — listable across every branch, expired ones included, so a + pull request would read another's; `download-artifact` is off the allow-list. + +On Forgejo there are no check runs: Actions writes each job's verdict as a commit +status on the PR head, context ` / (pull_request)`, and a PR run +tests the head, not a merge. The runner cache is PR-isolated since v10.0.1 and +unquotaed, and whether `actions/cache@v5` and `lookup-only` work against it is not +established. Codeberg's hosted runners cap a job at 10 minutes. + +**What stays unmeasured**: whether a `contents: read` token may read check runs +(every read above was made from a workstation); Zig 0.16's handling of the `.paths` +of a path dependency, which names `README.md` and `LICENSE`; and the cost of +building the tool in the `changes` job, which today installs no Zig. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree From 754fdeefd2f55715e8f9b379fbf312937515cefb Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Thu, 24 Sep 2026 08:27:33 +0200 Subject: [PATCH 045/141] chore(ci): probe whether the changes token can read check runs The verdict an unchanged tree inherits lives in the ci-gate check run of a head sha, so the decision job must read it from CI and not only from a workstation. The changes job gets checks: read and asks for the previous head's ci-gate with its token and without one; a control job with contents: read only asks the same. Temporary: it goes with the tool. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 41 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2b956ab..b43554b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,9 @@ jobs: changes: runs-on: ubuntu-24.04 timeout-minutes: 5 + permissions: + contents: read + checks: read outputs: code: ${{ steps.detect.outputs.code }} week: ${{ steps.week.outputs.week }} @@ -36,6 +39,24 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 + - name: Probe check-run read access + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PREV: ${{ github.event.before }} + run: | + set +e + url="https://api.github.com/repos/${{ github.repository }}/commits/$PREV/check-runs?check_name=ci-gate" + for auth in token none; do + hdr=() + [ "$auth" = token ] && hdr=(-H "Authorization: Bearer $GH_TOKEN") + code="$(curl -sS -o body.json -D head.txt -w '%{http_code}' "${hdr[@]}" -H 'Accept: application/vnd.github+json' "$url")" + echo "auth=$auth prev=$PREV http=$code" + grep -i -E '^x-ratelimit-limit|^x-accepted-github-permissions' head.txt | tr -d '\r' + grep -o -E '"(total_count|status|conclusion)": *("[a-z_]*"|[0-9]+)' body.json | head -3 + done + true + - name: Detect non-doc changes id: detect shell: bash @@ -66,6 +87,26 @@ jobs: echo "code=$code" >> "$GITHUB_OUTPUT" echo "--- verdict: code=$code ---" + token-probe-control: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + steps: + - name: Probe check-run read access with contents read only + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PREV: ${{ github.event.before }} + run: | + set +e + url="https://api.github.com/repos/${{ github.repository }}/commits/$PREV/check-runs?check_name=ci-gate" + code="$(curl -sS -o body.json -D head.txt -w '%{http_code}' -H "Authorization: Bearer $GH_TOKEN" -H 'Accept: application/vnd.github+json' "$url")" + echo "auth=token-contents-only prev=$PREV http=$code" + grep -i -E '^x-ratelimit-limit|^x-accepted-github-permissions' head.txt | tr -d '\r' + grep -o -E '"(total_count|status|conclusion)": *("[a-z_]*"|[0-9]+)' body.json | head -3 + true + build-and-test: needs: changes if: needs.changes.outputs.code == 'true' From a92f45a24351e0ca58210ea93c5b702730d0a8c0 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Thu, 24 Sep 2026 08:42:58 +0200 Subject: [PATCH 046/141] fix(ci): skip a run whose code was already verified green M1.D.16. The changes job no longer diffs the pull request against main and classifies paths; it asks tools/ci_verdict whether the code of this tree is identical to a tree already verified green, and the YAML acts on the answer. The digest is git's own addressing, (mode, type, blob sha, path) over the tracked files, minus briefs/ and CLAUDE.md, read from the objects. A head inherits the ci-gate verdict of a commit only if the base is an ancestor of that commit and of the head, the tested tree is the head tree, and the two digests are equal. The candidates are the pull request's commits, newest first, then the base, which main's own run verified. A red or still-running verdict on the same code decides a run; so does any doubt. A push always runs. The verdict is read from the ci-gate check run of the GitHub Actions app through curl. A test of the tool scans every tracked Zig file, manifest and workflow and reddens when one reads an excluded path; it saw the two old exclusion lists this replaces. tests/etch/ebnf_examples.md, which the old *.md pattern exempted while a test embeds it, is in the digest. bench.yml consumes the same tool against a new bench-gate job, and loses its paths-ignore list. The token probe is removed: the changes token read the previous head's ci-gate (HTTP 200, 5000/h). Floor 2596 -> 2624, windows 2594 -> 2622. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/bench.yml | 72 ++++++-- .github/workflows/ci.yml | 84 ++------- build.zig | 8 + tools/ci_verdict/guard.zig | 206 +++++++++++++++++++++ tools/ci_verdict/main.zig | 315 ++++++++++++++++++++++++++++++++ tools/ci_verdict/tests.zig | 8 + tools/ci_verdict/verdict.zig | 324 +++++++++++++++++++++++++++++++++ tools/weld_lint/dead_tests.zig | 4 +- 8 files changed, 942 insertions(+), 79 deletions(-) create mode 100644 tools/ci_verdict/guard.zig create mode 100644 tools/ci_verdict/main.zig create mode 100644 tools/ci_verdict/tests.zig create mode 100644 tools/ci_verdict/verdict.zig diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index 537f581c..f6bd4809 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -4,20 +4,8 @@ name: Bench on: push: branches: [main] - paths-ignore: - - '**.md' - - 'briefs/**' - - 'docs/**' - - 'LICENSE' - - '.gitignore' pull_request: branches: [main] - paths-ignore: - - '**.md' - - 'briefs/**' - - 'docs/**' - - 'LICENSE' - - '.gitignore' concurrency: group: bench-${{ github.ref }} @@ -31,7 +19,41 @@ env: ZIG_CPU: "baseline" jobs: + changes: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + checks: read + outputs: + code: ${{ steps.detect.outputs.code }} + reason: ${{ steps.detect.outputs.reason }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: weldengine/setup-zig@v0.1.0 + with: + version: ${{ env.ZIG_VERSION }} + use-cache: false + + - name: Decide whether this tree needs a run + id: detect + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + EVENT: ${{ github.event_name }} + BASE: ${{ github.event.pull_request.base.sha }} + HEAD: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + zig run tools/ci_verdict/main.zig -- decide --event "$EVENT" --base "$BASE" --head "$HEAD" \ + --tested HEAD --repo "${{ github.repository }}" --check bench-gate >> "$GITHUB_OUTPUT" + bench-ecs-smoke: + needs: changes + if: needs.changes.outputs.code == 'true' strategy: fail-fast: false matrix: @@ -80,3 +102,29 @@ jobs: path: bench/results/ecs_hybrid_crossover.md if-no-files-found: error retention-days: 90 + + # The verdict `changes` inherits: green when the bench ran green or was skipped + # because the same code already was. + bench-gate: + needs: [changes, bench-ecs-smoke] + if: always() + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Aggregate the bench results + shell: bash + env: + REASON: ${{ needs.changes.outputs.reason }} + run: | + set -euo pipefail + changes="${{ needs.changes.result }}" + bench="${{ needs.bench-ecs-smoke.result }}" + echo "changes=$changes bench-ecs-smoke=$bench" + echo "::notice::$REASON" + if [ "$changes" != "success" ]; then + echo "::error::changes job did not succeed ($changes)"; exit 1 + fi + case "$bench" in + success|skipped) : ;; + *) echo "::error::bench-ecs-smoke is $bench"; exit 1 ;; + esac diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b43554b0..033fad1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,7 @@ jobs: checks: read outputs: code: ${{ steps.detect.outputs.code }} + reason: ${{ steps.detect.outputs.reason }} week: ${{ steps.week.outputs.week }} steps: - name: Compute the lineage window @@ -39,73 +40,24 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 - - name: Probe check-run read access - shell: bash - env: - GH_TOKEN: ${{ github.token }} - PREV: ${{ github.event.before }} - run: | - set +e - url="https://api.github.com/repos/${{ github.repository }}/commits/$PREV/check-runs?check_name=ci-gate" - for auth in token none; do - hdr=() - [ "$auth" = token ] && hdr=(-H "Authorization: Bearer $GH_TOKEN") - code="$(curl -sS -o body.json -D head.txt -w '%{http_code}' "${hdr[@]}" -H 'Accept: application/vnd.github+json' "$url")" - echo "auth=$auth prev=$PREV http=$code" - grep -i -E '^x-ratelimit-limit|^x-accepted-github-permissions' head.txt | tr -d '\r' - grep -o -E '"(total_count|status|conclusion)": *("[a-z_]*"|[0-9]+)' body.json | head -3 - done - true - - name: Detect non-doc changes - id: detect - shell: bash - run: | - set -euo pipefail - if [ "${{ github.event_name }}" = "pull_request" ]; then - base="${{ github.event.pull_request.base.sha }}" - head="${{ github.event.pull_request.head.sha }}" - else - base="${{ github.event.before }}" - head="${{ github.sha }}" - fi - if [ -z "$base" ] || [ "$base" = "0000000000000000000000000000000000000000" ]; then - echo "base unknown -> code=true" - echo "code=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - files="$(git diff --name-only "$base" "$head")" - echo "--- changed files ---"; echo "$files" - code=false - while IFS= read -r f; do - [ -z "$f" ] && continue - case "$f" in - *.md|briefs/*|docs/*|LICENSE|.gitignore) : ;; - *) code=true ;; - esac - done <<< "$files" - echo "code=$code" >> "$GITHUB_OUTPUT" - echo "--- verdict: code=$code ---" + - uses: weldengine/setup-zig@v0.1.0 + with: + version: ${{ env.ZIG_VERSION }} + use-cache: false - token-probe-control: - runs-on: ubuntu-24.04 - timeout-minutes: 5 - permissions: - contents: read - steps: - - name: Probe check-run read access with contents read only + - name: Decide whether this tree needs a run + id: detect shell: bash env: - GH_TOKEN: ${{ github.token }} - PREV: ${{ github.event.before }} + GITHUB_TOKEN: ${{ github.token }} + EVENT: ${{ github.event_name }} + BASE: ${{ github.event.pull_request.base.sha }} + HEAD: ${{ github.event.pull_request.head.sha }} run: | - set +e - url="https://api.github.com/repos/${{ github.repository }}/commits/$PREV/check-runs?check_name=ci-gate" - code="$(curl -sS -o body.json -D head.txt -w '%{http_code}' -H "Authorization: Bearer $GH_TOKEN" -H 'Accept: application/vnd.github+json' "$url")" - echo "auth=token-contents-only prev=$PREV http=$code" - grep -i -E '^x-ratelimit-limit|^x-accepted-github-permissions' head.txt | tr -d '\r' - grep -o -E '"(total_count|status|conclusion)": *("[a-z_]*"|[0-9]+)' body.json | head -3 - true + set -euo pipefail + zig run tools/ci_verdict/main.zig -- decide --event "$EVENT" --base "$BASE" --head "$HEAD" \ + --tested HEAD --repo "${{ github.repository }}" --check ci-gate >> "$GITHUB_OUTPUT" build-and-test: needs: changes @@ -692,9 +644,8 @@ jobs: # A doc-only pull request therefore merges in seconds, while one carrying code # stays gated on the whole matrix and both smokes. # - # `skipped` counts as green because the fast path above is what produces it. - # That makes the allow-list load-bearing: a path added to it stops being - # verified, so it is widened only for files no build reads. + # `skipped` counts as green because `changes` skips only a tree whose code was + # already verified green (tools/ci_verdict). ci-gate: needs: [changes, build-and-test, runtime-smoke-test, vertical-slice-smoke] if: always() @@ -703,6 +654,8 @@ jobs: steps: - name: Aggregate required results shell: bash + env: + REASON: ${{ needs.changes.outputs.reason }} run: | set -euo pipefail changes="${{ needs.changes.result }}" @@ -710,6 +663,7 @@ jobs: rst="${{ needs.runtime-smoke-test.result }}" vss="${{ needs.vertical-slice-smoke.result }}" echo "changes=$changes build-and-test=$bt runtime-smoke-test=$rst vertical-slice-smoke=$vss" + echo "::notice::$REASON" if [ "$changes" != "success" ]; then echo "::error::changes job did not succeed ($changes)"; exit 1 fi diff --git a/build.zig b/build.zig index 01773a69..2378513e 100644 --- a/build.zig +++ b/build.zig @@ -2682,6 +2682,14 @@ pub fn build(b: *std.Build) void { }); const bindgen_tests = b.addTest(.{ .root_module = bindgen_test_module }); test_step.dependOn(&b.addRunArtifact(bindgen_tests).step); + + const ci_verdict_test_module = b.createModule(.{ + .root_source_file = b.path("tools/ci_verdict/tests.zig"), + .target = b.graph.host, + .optimize = .Debug, + }); + const ci_verdict_tests = b.addTest(.{ .root_module = ci_verdict_test_module }); + test_step.dependOn(&b.addRunArtifact(ci_verdict_tests).step); } /// Passes this build's target, CPU, mode and physics precision to a nested diff --git a/tools/ci_verdict/guard.zig b/tools/ci_verdict/guard.zig new file mode 100644 index 00000000..dafbbf8b --- /dev/null +++ b/tools/ci_verdict/guard.zig @@ -0,0 +1,206 @@ +//! No tracked file may read a path the digest leaves out: an `@embedFile`, a +//! `.path(` call or a file-opening call in Zig, a string in a manifest, a line of a +//! workflow. + +const std = @import("std"); +const verdict = @import("verdict.zig"); + +/// A read of an excluded path: the file, its line, and the excluded name reached. +pub const Finding = struct { + file: []const u8, + line: usize, + target: []const u8, +}; + +/// Methods whose string arguments name a file to read, build from or open. +const readers = [_][]const u8{ + "path", + "openFile", + "openDir", + "readFile", + "readFileAlloc", + "readFileAllocOptions", + "statFile", + "access", + "copyFile", + "readLink", +}; + +/// Appends a finding for every excluded path `source`, the Zig file `file`, reads. +/// A literal resolves against the file's directory and against the repository +/// root, which is the working directory of every build and test step. +pub fn scanZig(gpa: std.mem.Allocator, file: []const u8, source: [:0]const u8, out: *std.ArrayList(Finding)) !void { + var tokens: std.ArrayList(std.zig.Token) = .empty; + defer tokens.deinit(gpa); + var tok = std.zig.Tokenizer.init(source); + while (true) { + const t = tok.next(); + if (t.tag == .eof) break; + try tokens.append(gpa, t); + } + const ts = tokens.items; + var i: usize = 0; + while (i + 2 < ts.len) : (i += 1) { + const embed = ts[i].tag == .builtin and std.mem.eql(u8, text(source, ts[i]), "@embedFile"); + const reader = ts[i].tag == .period and ts[i + 1].tag == .identifier and isReader(text(source, ts[i + 1])); + if (embed and ts[i + 1].tag == .l_paren and ts[i + 2].tag == .string_literal) { + try checkLiteral(gpa, file, source, ts[i + 2], out); + } else if (reader and ts[i + 2].tag == .l_paren) { + var depth: usize = 1; + var j = i + 3; + while (j < ts.len and depth > 0) : (j += 1) { + switch (ts[j].tag) { + .l_paren => depth += 1, + .r_paren => depth -= 1, + .string_literal => if (depth == 1) try checkLiteral(gpa, file, source, ts[j], out), + else => {}, + } + } + } + } +} + +/// Appends a finding for every string literal of the manifest `source` that names +/// an excluded path. +pub fn scanZon(gpa: std.mem.Allocator, file: []const u8, source: [:0]const u8, out: *std.ArrayList(Finding)) !void { + var tok = std.zig.Tokenizer.init(source); + while (true) { + const t = tok.next(); + if (t.tag == .eof) break; + if (t.tag == .string_literal) try checkLiteral(gpa, file, source, t, out); + } +} + +/// Appends a finding for every line of the workflow `source`, outside comments, +/// that names an excluded path. +pub fn scanWorkflow(gpa: std.mem.Allocator, file: []const u8, source: []const u8, out: *std.ArrayList(Finding)) !void { + var lines = std.mem.splitScalar(u8, source, '\n'); + var n: usize = 0; + while (lines.next()) |raw| { + n += 1; + const trimmed = std.mem.trimStart(u8, raw, " \t"); + if (std.mem.startsWith(u8, trimmed, "#")) continue; + const line = if (std.mem.indexOf(u8, raw, " #")) |c| raw[0..c] else raw; + for (verdict.excluded_prefixes ++ verdict.excluded_files) |name| { + if (std.mem.indexOf(u8, line, name) != null) { + try out.append(gpa, .{ .file = file, .line = n, .target = name }); + } + } + } +} + +/// Scans every tracked Zig source, manifest and workflow outside the excluded +/// paths. Runs from the repository root. +pub fn scanTree(gpa: std.mem.Allocator, arena: std.mem.Allocator, io: std.Io, out: *std.ArrayList(Finding)) !void { + const listed = try std.process.run(arena, io, .{ .argv = &.{ "git", "ls-files", "-z" }, .stdout_limit = .limited(16 << 20) }); + switch (listed.term) { + .exited => |code| if (code != 0) return error.GitFailed, + else => return error.GitFailed, + } + var it = std.mem.splitScalar(u8, listed.stdout, 0); + while (it.next()) |path| { + if (path.len == 0 or verdict.isExcluded(path)) continue; + const zig = std.mem.endsWith(u8, path, ".zig"); + const zon = std.mem.endsWith(u8, path, ".zon"); + const yml = std.mem.startsWith(u8, path, ".github/workflows/") and + (std.mem.endsWith(u8, path, ".yml") or std.mem.endsWith(u8, path, ".yaml")); + if (!zig and !zon and !yml) continue; + const source = try std.Io.Dir.cwd().readFileAllocOptions(io, path, arena, .limited(16 << 20), .of(u8), 0); + if (zig) try scanZig(gpa, path, source, out); + if (zon) try scanZon(gpa, path, source, out); + if (yml) try scanWorkflow(gpa, path, source, out); + } +} + +fn isReader(name: []const u8) bool { + for (readers) |r| if (std.mem.eql(u8, r, name)) return true; + return false; +} + +fn text(source: []const u8, t: std.zig.Token) []const u8 { + return source[t.loc.start..t.loc.end]; +} + +fn checkLiteral(gpa: std.mem.Allocator, file: []const u8, source: []const u8, t: std.zig.Token, out: *std.ArrayList(Finding)) !void { + const lit = std.zig.string_literal.parseAlloc(gpa, text(source, t)) catch return; + defer gpa.free(lit); + const dir = std.fs.path.dirnamePosix(file) orelse "."; + for ([_][]const u8{ dir, "." }) |base| { + const resolved = try std.fs.path.resolvePosix(gpa, &.{ base, lit }); + defer gpa.free(resolved); + if (verdict.isExcluded(resolved)) { + const line = std.mem.count(u8, source[0..t.loc.start], "\n") + 1; + const kept = verdict.excluded_prefixes ++ verdict.excluded_files; + const target = for (kept) |k| { + if (std.mem.startsWith(u8, resolved, k)) break k; + } else ""; + try out.append(gpa, .{ .file = file, .line = line, .target = target }); + return; + } + } +} + +fn findings(comptime kind: enum { zig, zon, yml }, file: []const u8, source: [:0]const u8) !usize { + const gpa = std.testing.allocator; + var out: std.ArrayList(Finding) = .empty; + defer out.deinit(gpa); + switch (kind) { + .zig => try scanZig(gpa, file, source, &out), + .zon => try scanZon(gpa, file, source, &out), + .yml => try scanWorkflow(gpa, file, source, &out), + } + return out.items.len; +} + +test "an embed of an excluded path is found through its relative spelling" { + try std.testing.expectEqual(@as(usize, 1), try findings(.zig, "tests/etch/a.zig", + \\const x = @embedFile("../../briefs/m1.md"); + )); +} + +test "an embed of a kept Markdown file is not a finding" { + try std.testing.expectEqual(@as(usize, 0), try findings(.zig, "tests/etch/ebnf_examples_test.zig", + \\const examples_md = @embedFile("ebnf_examples.md"); + )); +} + +test "a build path and a file read of an excluded path are found" { + try std.testing.expectEqual(@as(usize, 1), try findings(.zig, "build.zig", + \\const p = b.path("CLAUDE.md"); + )); + try std.testing.expectEqual(@as(usize, 1), try findings(.zig, "tools/x/main.zig", + \\const s = try std.Io.Dir.cwd().readFileAlloc(io, "briefs/a.md", gpa, .unlimited); + )); +} + +test "a string naming an excluded path outside a read is not a finding" { + try std.testing.expectEqual(@as(usize, 0), try findings(.zig, "tools/x/main.zig", + \\const msg = "see briefs/a.md and CLAUDE.md"; + \\const y = foo("briefs/a.md"); + )); +} + +test "a manifest string naming an excluded path is found" { + try std.testing.expectEqual(@as(usize, 1), try findings(.zon, "build.zig.zon", + \\.{ .paths = .{ "build.zig", "CLAUDE.md" } } + )); +} + +test "a workflow line naming an excluded path is found, a comment is not" { + try std.testing.expectEqual(@as(usize, 1), try findings(.yml, ".github/workflows/ci.yml", + \\# briefs/ are documentation + \\ - run: cat briefs/a.md + \\ - run: zig build # not CLAUDE.md + )); +} + +test "no tracked file reads a path left out of the digest" { + const gpa = std.testing.allocator; + var arena_state = std.heap.ArenaAllocator.init(gpa); + defer arena_state.deinit(); + var out: std.ArrayList(Finding) = .empty; + defer out.deinit(gpa); + try scanTree(gpa, arena_state.allocator(), std.testing.io, &out); + for (out.items) |f| std.debug.print("{s}:{d}: reads {s}, which the digest leaves out\n", .{ f.file, f.line, f.target }); + try std.testing.expectEqual(@as(usize, 0), out.items.len); +} diff --git a/tools/ci_verdict/main.zig b/tools/ci_verdict/main.zig new file mode 100644 index 00000000..b69ae86d --- /dev/null +++ b/tools/ci_verdict/main.zig @@ -0,0 +1,315 @@ +//! `ci_verdict` — decides whether a workflow run may skip its jobs because the +//! same code was already verified green. +//! +//! ci_verdict decide --event --base --head --tested +//! --repo --check [--api ] +//! ci_verdict digest +//! +//! `decide` prints `code=true|false`, `reason=…` and, on a skip, `inherited=` +//! on stdout, in the shape `$GITHUB_OUTPUT` takes. Any doubt decides a run. The +//! token, if any, is read from `GITHUB_TOKEN`. + +const std = @import("std"); +const verdict = @import("verdict.zig"); + +const usage = + \\usage: ci_verdict decide --event --base --head --tested + \\ --repo --check [--api ] + \\ ci_verdict digest + \\ +; + +/// Newest commits of the pull request considered before the walk gives up. +const max_candidates = 100; + +pub fn main(init: std.process.Init) !u8 { + const arena = init.arena.allocator(); + const argv = try init.minimal.args.toSlice(arena); + var buf: [4096]u8 = undefined; + var w = std.Io.File.stdout().writer(init.io, &buf); + const out = &w.interface; + defer out.flush() catch {}; + + if (argv.len < 2) { + try out.writeAll(usage); + return 2; + } + if (std.mem.eql(u8, argv[1], "digest") and argv.len == 3) { + const d = digestOf(arena, init.io, argv[2]) catch |err| { + std.debug.print("ci_verdict: {s}\n", .{@errorName(err)}); + return 1; + }; + try out.print("{x}\n", .{&d}); + return 0; + } + if (std.mem.eql(u8, argv[1], "decide")) { + const opts = parseDecide(argv[2..]) catch { + try out.writeAll(usage); + return 2; + }; + const token = init.environ_map.get("GITHUB_TOKEN"); + const d = decideFor(arena, init.io, opts, token); + switch (d) { + .run => |why| try out.print("code=true\nreason={s}\n", .{why}), + .inherit => |sha| try out.print("code=false\nreason=the same code was verified green at {s}\ninherited={s}\n", .{ sha, sha }), + } + std.debug.print("ci_verdict: {s}\n", .{switch (d) { + .run => |why| why, + .inherit => |sha| sha, + }}); + return 0; + } + try out.writeAll(usage); + return 2; +} + +const DecideOptions = struct { + event: []const u8 = "", + base: []const u8 = "", + head: []const u8 = "", + tested: []const u8 = "", + repo: []const u8 = "", + check: []const u8 = "", + api: []const u8 = "https://api.github.com", +}; + +fn parseDecide(args: []const [:0]const u8) error{Usage}!DecideOptions { + var o: DecideOptions = .{}; + var i: usize = 0; + while (i < args.len) : (i += 2) { + if (i + 1 >= args.len) return error.Usage; + if (!std.mem.startsWith(u8, args[i], "--")) return error.Usage; + const key = args[i][2..]; + var matched = false; + inline for (@typeInfo(DecideOptions).@"struct".fields) |f| { + if (std.mem.eql(u8, key, f.name)) { + @field(o, f.name) = args[i + 1]; + matched = true; + } + } + if (!matched) return error.Usage; + } + if (o.event.len == 0 or o.tested.len == 0 or o.repo.len == 0 or o.check.len == 0) return error.Usage; + for (o.check) |c| if (!(std.ascii.isAlphanumeric(c) or c == '-' or c == '_')) return error.Usage; + return o; +} + +fn decideFor(arena: std.mem.Allocator, io: std.Io, o: DecideOptions, token: ?[]const u8) verdict.Decision { + if (!std.mem.eql(u8, o.event, "pull_request")) return .{ .run = "not a pull request" }; + if (!isSha(o.base) or !isSha(o.head)) return .{ .run = "the event names no base or head" }; + var repo: GitRepo = .{ .arena = arena, .io = io }; + const facts = gather(arena, &repo, o) catch return .{ .run = "git could not describe the tree" }; + var reader: GitHubVerdicts = .{ .arena = arena, .io = io, .options = o, .token = token }; + return verdict.decide(facts, &reader); +} + +/// The facts `verdict.decide` needs, read from `repo`: the pull request's commits +/// newest first, then the base, which `main`'s own run verified. +fn gather(arena: std.mem.Allocator, repo: anytype, o: DecideOptions) !verdict.Facts { + const head_digest = try repo.digest(o.head); + var candidates: std.ArrayList(verdict.Candidate) = .empty; + var it = std.mem.tokenizeScalar(u8, try repo.revList(o.base, o.head), '\n'); + while (it.next()) |sha| { + if (std.mem.eql(u8, sha, o.head)) continue; + if (candidates.items.len == max_candidates) break; + const descends = try repo.isAncestor(o.base, sha); + const same = descends and std.mem.eql(u8, &(try repo.digest(sha)), &head_digest); + try candidates.append(arena, .{ .sha = sha, .descends_from_base = descends, .same_digest = same }); + } + const base_same = std.mem.eql(u8, &(try repo.digest(o.base)), &head_digest); + try candidates.append(arena, .{ .sha = o.base, .descends_from_base = true, .same_digest = base_same }); + return .{ + .event = .pull_request, + .head_descends_from_base = try repo.isAncestor(o.base, o.head), + .tested_is_head = std.mem.eql(u8, try repo.tree(o.tested), try repo.tree(o.head)), + .candidates = candidates.items, + }; +} + +const GitRepo = struct { + arena: std.mem.Allocator, + io: std.Io, + + pub fn digest(self: *GitRepo, rev: []const u8) !verdict.Digest { + return digestOf(self.arena, self.io, rev); + } + + pub fn tree(self: *GitRepo, rev: []const u8) ![]const u8 { + return git(self.arena, self.io, &.{ "rev-parse", try std.fmt.allocPrint(self.arena, "{s}^{{tree}}", .{rev}) }); + } + + pub fn revList(self: *GitRepo, base: []const u8, head: []const u8) ![]const u8 { + return git(self.arena, self.io, &.{ "rev-list", try std.fmt.allocPrint(self.arena, "{s}..{s}", .{ base, head }) }); + } + + pub fn isAncestor(self: *GitRepo, a: []const u8, b: []const u8) !bool { + return isAncestorOf(self.arena, self.io, a, b); + } +}; + +const GitHubVerdicts = struct { + arena: std.mem.Allocator, + io: std.Io, + options: DecideOptions, + token: ?[]const u8, + + pub fn of(self: *GitHubVerdicts, sha: []const u8) !verdict.CheckVerdict { + const url = try std.fmt.allocPrint(self.arena, "{s}/repos/{s}/commits/{s}/check-runs?check_name={s}&filter=latest&per_page=100", .{ + self.options.api, self.options.repo, sha, self.options.check, + }); + var argv: std.ArrayList([]const u8) = .empty; + try argv.appendSlice(self.arena, &.{ "curl", "-sS", "--fail", "--max-time", "30" }); + try argv.appendSlice(self.arena, &.{ "-H", "Accept: application/vnd.github+json" }); + try argv.appendSlice(self.arena, &.{ "-H", "X-GitHub-Api-Version: 2022-11-28" }); + if (self.token) |t| try argv.appendSlice(self.arena, &.{ "-H", try std.fmt.allocPrint(self.arena, "Authorization: Bearer {s}", .{t}) }); + try argv.append(self.arena, url); + const r = try std.process.run(self.arena, self.io, .{ .argv = argv.items, .stdout_limit = .limited(4 << 20) }); + switch (r.term) { + .exited => |code| if (code != 0) return error.CurlFailed, + else => return error.CurlFailed, + } + return verdict.checkRunVerdict(self.arena, r.stdout, self.options.check, verdict.github_actions_app_id); + } +}; + +fn digestOf(arena: std.mem.Allocator, io: std.Io, rev: []const u8) !verdict.Digest { + const listed = try gitRaw(arena, io, &.{ "ls-tree", "-r", "-z", "--full-tree", rev }); + return verdict.treeDigest(listed); +} + +fn isAncestorOf(arena: std.mem.Allocator, io: std.Io, a: []const u8, b: []const u8) !bool { + const r = try std.process.run(arena, io, .{ .argv = &.{ "git", "merge-base", "--is-ancestor", a, b } }); + return switch (r.term) { + .exited => |code| switch (code) { + 0 => true, + 1 => false, + else => error.GitFailed, + }, + else => error.GitFailed, + }; +} + +/// `git ` with its output trimmed of the final newline. +fn git(arena: std.mem.Allocator, io: std.Io, args: []const []const u8) ![]const u8 { + return std.mem.trimEnd(u8, try gitRaw(arena, io, args), "\n"); +} + +fn gitRaw(arena: std.mem.Allocator, io: std.Io, args: []const []const u8) ![]const u8 { + const argv = try std.mem.concat(arena, []const u8, &.{ &.{"git"}, args }); + const r = try std.process.run(arena, io, .{ .argv = argv, .stdout_limit = .limited(64 << 20) }); + switch (r.term) { + .exited => |code| if (code != 0) return error.GitFailed, + else => return error.GitFailed, + } + return r.stdout; +} + +fn isSha(s: []const u8) bool { + if (s.len != 40) return false; + for (s) |c| if (!std.ascii.isHex(c)) return false; + return !std.mem.eql(u8, s, "0000000000000000000000000000000000000000"); +} + +test "only a full non-zero sha names a commit" { + try std.testing.expect(isSha("c0a1fafb128c03e2bcf30d239d221a86ea24effa")); + try std.testing.expect(!isSha("0000000000000000000000000000000000000000")); + try std.testing.expect(!isSha("c0a1fafb")); + try std.testing.expect(!isSha("")); +} + +test "decide refuses an incomplete or unsafe command line" { + const ok = [_][:0]const u8{ "--event", "pull_request", "--tested", "HEAD", "--repo", "o/r", "--check", "ci-gate" }; + _ = try parseDecide(&ok); + const no_check = [_][:0]const u8{ "--event", "pull_request", "--tested", "HEAD", "--repo", "o/r" }; + try std.testing.expectError(error.Usage, parseDecide(&no_check)); + const bad_check = [_][:0]const u8{ "--event", "pull_request", "--tested", "HEAD", "--repo", "o/r", "--check", "ci gate&x=1" }; + try std.testing.expectError(error.Usage, parseDecide(&bad_check)); + const dangling = [_][:0]const u8{"--event"}; + try std.testing.expectError(error.Usage, parseDecide(&dangling)); +} + +test "a push always decides a run" { + const d = decideFor(std.testing.allocator, std.testing.io, .{ .event = "push", .tested = "HEAD", .repo = "o/r", .check = "ci-gate" }, null); + try std.testing.expect(d == .run); +} + +/// A repository of named commits, each with a digest byte, a tree, and the +/// commits it descends from. +const FakeRepo = struct { + commits: []const struct { sha: []const u8, digest: u8, tree: []const u8 = "t", ancestors: []const []const u8 = &.{} }, + listed: []const u8, + + fn find(self: *FakeRepo, sha: []const u8) !usize { + for (self.commits, 0..) |c, i| if (std.mem.eql(u8, c.sha, sha)) return i; + return error.NoSuchCommit; + } + + pub fn digest(self: *FakeRepo, rev: []const u8) !verdict.Digest { + return @splat(self.commits[try self.find(rev)].digest); + } + + pub fn tree(self: *FakeRepo, rev: []const u8) ![]const u8 { + return self.commits[try self.find(rev)].tree; + } + + pub fn revList(self: *FakeRepo, base: []const u8, head: []const u8) ![]const u8 { + _ = .{ base, head }; + return self.listed; + } + + pub fn isAncestor(self: *FakeRepo, a: []const u8, b: []const u8) !bool { + if (std.mem.eql(u8, a, b)) return true; + for (self.commits[try self.find(b)].ancestors) |x| if (std.mem.eql(u8, x, a)) return true; + return false; + } +}; + +const pr_options: DecideOptions = .{ .event = "pull_request", .base = "B", .head = "H", .tested = "M", .repo = "o/r", .check = "ci-gate" }; + +test "the base closes the candidate list" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + var repo: FakeRepo = .{ .listed = "H\nC\n", .commits = &.{ + .{ .sha = "B", .digest = 1 }, + .{ .sha = "C", .digest = 2, .ancestors = &.{"B"} }, + .{ .sha = "H", .digest = 1, .ancestors = &.{ "C", "B" } }, + .{ .sha = "M", .digest = 1 }, + } }; + const f = try gather(arena_state.allocator(), &repo, pr_options); + try std.testing.expectEqual(@as(usize, 2), f.candidates.len); + try std.testing.expectEqualStrings("C", f.candidates[0].sha); + try std.testing.expect(!f.candidates[0].same_digest); + try std.testing.expectEqualStrings("B", f.candidates[1].sha); + try std.testing.expect(f.candidates[1].same_digest); + try std.testing.expect(f.head_descends_from_base and f.tested_is_head); +} + +test "a commit outside the base is a candidate that cannot be inherited" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + var repo: FakeRepo = .{ .listed = "H\nX\n", .commits = &.{ + .{ .sha = "B", .digest = 2 }, + .{ .sha = "X", .digest = 1 }, + .{ .sha = "H", .digest = 1, .ancestors = &.{ "X", "B" } }, + .{ .sha = "M", .digest = 1, .tree = "other" }, + } }; + const f = try gather(arena_state.allocator(), &repo, pr_options); + try std.testing.expect(!f.candidates[0].descends_from_base and !f.candidates[0].same_digest); + try std.testing.expect(!f.tested_is_head); +} + +test "the candidate list is bounded" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var listed: std.ArrayList(u8) = .empty; + for (0..max_candidates + 50) |_| try listed.appendSlice(arena, "C\n"); + var repo: FakeRepo = .{ .listed = listed.items, .commits = &.{ + .{ .sha = "B", .digest = 1 }, + .{ .sha = "C", .digest = 1, .ancestors = &.{"B"} }, + .{ .sha = "H", .digest = 1, .ancestors = &.{"B"} }, + .{ .sha = "M", .digest = 1 }, + } }; + const f = try gather(arena, &repo, pr_options); + try std.testing.expectEqual(@as(usize, max_candidates + 1), f.candidates.len); +} diff --git a/tools/ci_verdict/tests.zig b/tools/ci_verdict/tests.zig new file mode 100644 index 00000000..45d09eb2 --- /dev/null +++ b/tools/ci_verdict/tests.zig @@ -0,0 +1,8 @@ +//! Test root for `ci_verdict`. The `comptime` block references each file, which +//! is what makes the compiler analyse, and therefore run, its tests. + +comptime { + _ = @import("verdict.zig"); + _ = @import("guard.zig"); + _ = @import("main.zig"); +} diff --git a/tools/ci_verdict/verdict.zig b/tools/ci_verdict/verdict.zig new file mode 100644 index 00000000..276b101b --- /dev/null +++ b/tools/ci_verdict/verdict.zig @@ -0,0 +1,324 @@ +//! Whether a pull request's tree may inherit the green verdict of an earlier +//! commit instead of running the matrix again. + +const std = @import("std"); + +/// Path prefixes no CI job reads. The digest leaves them out. +pub const excluded_prefixes = [_][]const u8{"briefs/"}; +/// Whole paths no CI job reads. The digest leaves them out. +pub const excluded_files = [_][]const u8{"CLAUDE.md"}; + +/// The GitHub Actions app, the only producer of a check run the ruleset accepts. +pub const github_actions_app_id: u64 = 15368; + +/// Most check-run queries one decision may make before it gives up and runs. +pub const max_queries = 10; + +/// Whether the digest leaves `path` out. +pub fn isExcluded(path: []const u8) bool { + for (excluded_prefixes) |p| if (std.mem.startsWith(u8, path, p)) return true; + for (excluded_files) |f| if (std.mem.eql(u8, path, f)) return true; + return false; +} + +/// The digest of a tree's kept records. +pub const Digest = [std.crypto.hash.sha2.Sha256.digest_length]u8; + +/// SHA-256 over the records of `git ls-tree -r -z --full-tree ` whose path +/// `isExcluded` keeps, in the order git lists them. +pub fn treeDigest(ls_tree_z: []const u8) error{MalformedTree}!Digest { + var h = std.crypto.hash.sha2.Sha256.init(.{}); + var rest = ls_tree_z; + while (rest.len > 0) { + const end = std.mem.indexOfScalar(u8, rest, 0) orelse return error.MalformedTree; + const record = rest[0 .. end + 1]; + rest = rest[end + 1 ..]; + const tab = std.mem.indexOfScalar(u8, record, '\t') orelse return error.MalformedTree; + if (!wellFormedHeader(record[0..tab])) return error.MalformedTree; + const path = record[tab + 1 .. record.len - 1]; + if (path.len == 0) return error.MalformedTree; + if (isExcluded(path)) continue; + h.update(record); + } + return h.finalResult(); +} + +/// ` `: six octal digits, a word, forty hex digits. +fn wellFormedHeader(header: []const u8) bool { + var it = std.mem.splitScalar(u8, header, ' '); + const mode = it.next() orelse return false; + const kind = it.next() orelse return false; + const object = it.next() orelse return false; + if (it.next() != null) return false; + if (mode.len != 6) return false; + for (mode) |c| if (c < '0' or c > '7') return false; + if (kind.len == 0) return false; + if (object.len != 40) return false; + for (object) |c| if (!std.ascii.isHex(c)) return false; + return true; +} + +/// What a candidate's check run says about its code. +pub const CheckVerdict = enum { + /// Completed with `success`. + green, + /// Completed with a conclusion other than `success`, `cancelled`, `skipped` or `stale`. + red, + /// Not completed yet. + pending, + /// No verdict produced: no such check run, or one that was cancelled, skipped or stale. + none, +}; + +const CheckRuns = struct { + check_runs: []const struct { + id: u64, + name: []const u8, + status: []const u8, + conclusion: ?[]const u8 = null, + app: ?struct { id: u64 } = null, + }, +}; + +/// The verdict of the latest check run named `name` and produced by `app_id`, in +/// the body of a GitHub `commits/{sha}/check-runs` response. +pub fn checkRunVerdict(gpa: std.mem.Allocator, body: []const u8, name: []const u8, app_id: u64) !CheckVerdict { + const parsed = try std.json.parseFromSlice(CheckRuns, gpa, body, .{ .ignore_unknown_fields = true }); + defer parsed.deinit(); + var latest: ?usize = null; + for (parsed.value.check_runs, 0..) |run, i| { + if (!std.mem.eql(u8, run.name, name)) continue; + const app = run.app orelse continue; + if (app.id != app_id) continue; + if (latest == null or run.id > parsed.value.check_runs[latest.?].id) latest = i; + } + const run = parsed.value.check_runs[latest orelse return .none]; + if (!std.mem.eql(u8, run.status, "completed")) return .pending; + const conclusion = run.conclusion orelse return .pending; + if (std.mem.eql(u8, conclusion, "success")) return .green; + for ([_][]const u8{ "cancelled", "skipped", "stale" }) |c| { + if (std.mem.eql(u8, conclusion, c)) return .none; + } + return .red; +} + +/// The workflow event, as far as the decision tells events apart. +pub const Event = enum { pull_request, other }; + +/// A commit whose verdict the head may inherit. +pub const Candidate = struct { + sha: []const u8, + /// The pull request's base is an ancestor of this commit. + descends_from_base: bool, + /// This commit's digest equals the head's. + same_digest: bool, +}; + +/// What git says about the head and its candidates. +pub const Facts = struct { + event: Event, + /// The base is an ancestor of the head. + head_descends_from_base: bool, + /// The tree the jobs check out is the head's tree. + tested_is_head: bool, + /// The pull request's commits other than the head, newest first, then the base. + candidates: []const Candidate, +}; + +/// Run the jobs, for the reason given, or inherit the verdict of the commit named. +pub const Decision = union(enum) { + run: []const u8, + inherit: []const u8, +}; + +/// Decides for the head described by `facts`. `verdicts.of(sha)` returns the +/// `CheckVerdict` of a candidate, or an error, which decides a run. +pub fn decide(facts: Facts, verdicts: anytype) Decision { + if (facts.event != .pull_request) return .{ .run = "not a pull request" }; + if (!facts.head_descends_from_base) return .{ .run = "the head does not contain the base" }; + if (!facts.tested_is_head) return .{ .run = "the tested tree is not the head tree" }; + var queries: usize = 0; + for (facts.candidates) |c| { + if (!c.descends_from_base or !c.same_digest) continue; + if (queries == max_queries) return .{ .run = "the query budget is spent" }; + queries += 1; + const v = verdicts.of(c.sha) catch return .{ .run = "a verdict could not be read" }; + switch (v) { + .green => return .{ .inherit = c.sha }, + .red => return .{ .run = "the same code was verified red" }, + .pending => return .{ .run = "the same code is still being verified" }, + .none => continue, + } + } + return .{ .run = "no commit with the same code was verified green" }; +} + +inline fn lsRecord(comptime mode: []const u8, comptime kind: []const u8, comptime path: []const u8) []const u8 { + return mode ++ " " ++ kind ++ " " ++ "0123456789abcdef0123456789abcdef01234567" ++ "\t" ++ path ++ "\x00"; +} + +test "the exclusion keeps every path a job reads" { + try std.testing.expect(isExcluded("briefs/m1.d-phase-1-debt.md")); + try std.testing.expect(isExcluded("briefs/artifacts/repro.zig")); + try std.testing.expect(isExcluded("CLAUDE.md")); + try std.testing.expect(!isExcluded("tests/etch/ebnf_examples.md")); + try std.testing.expect(!isExcluded("src/core/ecs/README.md")); + try std.testing.expect(!isExcluded("README.md")); + try std.testing.expect(!isExcluded("briefsx/a.md")); + try std.testing.expect(!isExcluded("src/CLAUDE.md")); +} + +test "a change under an excluded path leaves the digest unchanged" { + const a = lsRecord("100644", "blob", "CLAUDE.md") ++ lsRecord("100644", "blob", "build.zig"); + const b = lsRecord("100644", "blob", "build.zig"); + try std.testing.expectEqual(try treeDigest(b), try treeDigest(a)); +} + +test "a change under a kept path moves the digest" { + const a = lsRecord("100644", "blob", "tests/etch/ebnf_examples.md"); + const b = lsRecord("100755", "blob", "tests/etch/ebnf_examples.md"); + const c = lsRecord("100644", "blob", "tests/etch/other.md"); + try std.testing.expect(!std.mem.eql(u8, &(try treeDigest(a)), &(try treeDigest(b)))); + try std.testing.expect(!std.mem.eql(u8, &(try treeDigest(a)), &(try treeDigest(c)))); +} + +test "a malformed ls-tree record is refused" { + try std.testing.expectError(error.MalformedTree, treeDigest("100644 blob abc\tpath\x00")); + try std.testing.expectError(error.MalformedTree, treeDigest(lsRecord("100644", "blob", "a")[0..20])); + try std.testing.expectError(error.MalformedTree, treeDigest("no tab here\x00")); +} + +test "the latest check run of the named app decides the verdict" { + const gpa = std.testing.allocator; + const body = + \\{"total_count":3,"check_runs":[ + \\ {"id":7,"name":"ci-gate","status":"completed","conclusion":"failure","app":{"id":15368}}, + \\ {"id":9,"name":"ci-gate","status":"completed","conclusion":"success","app":{"id":15368}}, + \\ {"id":11,"name":"ci-gate","status":"completed","conclusion":"failure","app":{"id":1}} + \\]} + ; + try std.testing.expectEqual(CheckVerdict.green, try checkRunVerdict(gpa, body, "ci-gate", github_actions_app_id)); +} + +test "a check run's conclusion maps to one verdict" { + const gpa = std.testing.allocator; + const cases = [_]struct { status: []const u8, conclusion: []const u8, want: CheckVerdict }{ + .{ .status = "completed", .conclusion = "\"success\"", .want = .green }, + .{ .status = "completed", .conclusion = "\"failure\"", .want = .red }, + .{ .status = "completed", .conclusion = "\"timed_out\"", .want = .red }, + .{ .status = "completed", .conclusion = "\"neutral\"", .want = .red }, + .{ .status = "completed", .conclusion = "\"cancelled\"", .want = .none }, + .{ .status = "completed", .conclusion = "\"skipped\"", .want = .none }, + .{ .status = "in_progress", .conclusion = "null", .want = .pending }, + .{ .status = "queued", .conclusion = "null", .want = .pending }, + }; + for (cases) |c| { + const body = try std.fmt.allocPrint(gpa, + \\{{"check_runs":[{{"id":1,"name":"ci-gate","status":"{s}","conclusion":{s},"app":{{"id":15368}}}}]}} + , .{ c.status, c.conclusion }); + defer gpa.free(body); + try std.testing.expectEqual(c.want, try checkRunVerdict(gpa, body, "ci-gate", github_actions_app_id)); + } +} + +test "no check run of that name and app is no verdict" { + const gpa = std.testing.allocator; + const body = + \\{"check_runs":[{"id":1,"name":"build","status":"completed","conclusion":"success","app":{"id":15368}}, + \\ {"id":2,"name":"ci-gate","status":"completed","conclusion":"success","app":{"id":99}}]} + ; + try std.testing.expectEqual(CheckVerdict.none, try checkRunVerdict(gpa, body, "ci-gate", github_actions_app_id)); +} + +const FakeVerdicts = struct { + by_sha: []const struct { sha: []const u8, v: CheckVerdict }, + asked: usize = 0, + + pub fn of(self: *FakeVerdicts, sha: []const u8) error{NotFound}!CheckVerdict { + self.asked += 1; + for (self.by_sha) |e| if (std.mem.eql(u8, e.sha, sha)) return e.v; + return error.NotFound; + } +}; + +fn pr(candidates: []const Candidate) Facts { + return .{ .event = .pull_request, .head_descends_from_base = true, .tested_is_head = true, .candidates = candidates }; +} + +test "a docs-only head inherits the newest green commit with the same code" { + var fake: FakeVerdicts = .{ .by_sha = &.{ .{ .sha = "b", .v = .green }, .{ .sha = "a", .v = .green } } }; + const d = decide(pr(&.{ + .{ .sha = "b", .descends_from_base = true, .same_digest = true }, + .{ .sha = "a", .descends_from_base = true, .same_digest = true }, + }), &fake); + try std.testing.expectEqualStrings("b", d.inherit); + try std.testing.expectEqual(@as(usize, 1), fake.asked); +} + +test "a commit that ran nothing is looked past" { + var fake: FakeVerdicts = .{ .by_sha = &.{ .{ .sha = "c", .v = .none }, .{ .sha = "b", .v = .green } } }; + const d = decide(pr(&.{ + .{ .sha = "c", .descends_from_base = true, .same_digest = true }, + .{ .sha = "b", .descends_from_base = true, .same_digest = true }, + }), &fake); + try std.testing.expectEqualStrings("b", d.inherit); +} + +test "a red verdict on the same code is never looked past" { + var fake: FakeVerdicts = .{ .by_sha = &.{ .{ .sha = "c", .v = .red }, .{ .sha = "b", .v = .green } } }; + const d = decide(pr(&.{ + .{ .sha = "c", .descends_from_base = true, .same_digest = true }, + .{ .sha = "b", .descends_from_base = true, .same_digest = true }, + }), &fake); + try std.testing.expect(d == .run); +} + +test "a verdict still being produced decides a run" { + var fake: FakeVerdicts = .{ .by_sha = &.{ .{ .sha = "c", .v = .pending }, .{ .sha = "b", .v = .green } } }; + const d = decide(pr(&.{ + .{ .sha = "c", .descends_from_base = true, .same_digest = true }, + .{ .sha = "b", .descends_from_base = true, .same_digest = true }, + }), &fake); + try std.testing.expect(d == .run); +} + +test "a candidate with other code or outside the base is never asked" { + var fake: FakeVerdicts = .{ .by_sha = &.{ .{ .sha = "c", .v = .green }, .{ .sha = "b", .v = .green } } }; + const d = decide(pr(&.{ + .{ .sha = "c", .descends_from_base = true, .same_digest = false }, + .{ .sha = "b", .descends_from_base = false, .same_digest = true }, + }), &fake); + try std.testing.expect(d == .run); + try std.testing.expectEqual(@as(usize, 0), fake.asked); +} + +test "each of the three conditions alone decides a run" { + var fake: FakeVerdicts = .{ .by_sha = &.{.{ .sha = "b", .v = .green }} }; + const cand = [_]Candidate{.{ .sha = "b", .descends_from_base = true, .same_digest = true }}; + var f = pr(&cand); + f.event = .other; + try std.testing.expect(decide(f, &fake) == .run); + f = pr(&cand); + f.head_descends_from_base = false; + try std.testing.expect(decide(f, &fake) == .run); + f = pr(&cand); + f.tested_is_head = false; + try std.testing.expect(decide(f, &fake) == .run); + try std.testing.expectEqual(@as(usize, 0), fake.asked); + try std.testing.expectEqualStrings("b", decide(pr(&cand), &fake).inherit); +} + +test "an unreadable verdict decides a run" { + var fake: FakeVerdicts = .{ .by_sha = &.{} }; + const d = decide(pr(&.{.{ .sha = "b", .descends_from_base = true, .same_digest = true }}), &fake); + try std.testing.expect(d == .run); +} + +test "the query budget bounds the walk" { + var cands: [max_queries + 1]Candidate = undefined; + for (&cands) |*c| c.* = .{ .sha = "x", .descends_from_base = true, .same_digest = true }; + var fake: FakeVerdicts = .{ .by_sha = &.{.{ .sha = "x", .v = .none }} }; + const d = decide(pr(&cands), &fake); + try std.testing.expect(d == .run); + try std.testing.expectEqual(@as(usize, max_queries), fake.asked); +} diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index f8ba9338..3242a1a2 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2594, - else => 2596, + .windows => 2622, + else => 2624, }; } From fb7be4b0fe5623dbd19162c2d775aedeb13529fc Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Thu, 24 Sep 2026 09:50:53 +0200 Subject: [PATCH 047/141] docs(brief): record the ci_verdict tool and its measured cost The token measured in CI, the PR #81 cache deleted, the tool and its fourteen counter-factuals, the cost of the changes job, and the gain predicted for this very commit before it is pushed. Co-Authored-By: Claude Opus 5.5 --- briefs/m1.d-phase-1-debt.md | 136 ++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index 02ac63e6..4126cdee 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -5988,6 +5988,142 @@ established. Codeberg's hosted runners cap a job at 10 minutes. of a path dependency, which names `README.md` and `LICENSE`; and the cost of building the tool in the `changes` job, which today installs no Zig. +### S5/G9 bis — the tool (`a92f45a2`), and stopped before the cache + +Guy's ruling on the measurement: the digest is git's addressing, the verdict is +read from the `ci-gate` check run, the three-condition rule stands with *any +doubt, everything runs* and a push to `main` always running in full; `checks: +read` on the `changes` job alone, measured first; `briefs/` and `CLAUDE.md` +excluded, with a test that reddens on a read of either; HTTP through `curl`; +`bench.yml` on the same tool. The spec is his: `engine-platform.md`, sha256 +`877ebe87…` — no cache condition names a branch, saves on `push` and +`pull_request`, isolation left to the forge. Stop after the tool. + +#### The token, measured in CI first (`754fdeef`) + +A probe step in `changes` and a control job, both asking for the previous head's +`ci-gate` check run: + +| Job token | HTTP | Rate limit | Answer | +|---|---|---|---| +| `contents: read`, `checks: read` (`changes`) | 200 | 5000/h | `completed`, `success` | +| none, from the runner | 200 | 60/h | the same | +| `contents: read` only (control) | 200 | 5000/h | the same | + +The control passes too: the repository is public. The endpoint declares +`x-accepted-github-permissions: checks=read`, which a private repository would +enforce, so `checks: read` stays as granted, on `changes` only. The token is what +buys the rate limit. The probe left with the tool. + +#### The PR #81 cache, deleted + +Nine entries under `refs/pull/81/merge`, 4 291 298 373 bytes, each deleted by id. +The list then holds the 20 entries of `main`, 6 400 136 878 bytes; the usage +endpoint lagged the list by several minutes. + +#### `tools/ci_verdict` + +- **The digest**: SHA-256 over the records of `git ls-tree -r -z --full-tree`, + mode, type, blob sha and path, minus `briefs/` and `CLAUDE.md`, read from the + objects. +- **The rule**: a head inherits the `ci-gate` verdict of a candidate if the base + is an ancestor of the candidate and of the head, the tested tree is the head's + tree, and the digests are equal. Candidates are the pull request's commits, + newest first, **then the base**: `main`'s push run verified exactly its tree, and + without it a documentation-only pull request, which the old classifier passed + in seconds, would run everything. The newest candidate with the same code + decides: green inherits, red or still running decides a run, and a commit that + produced no verdict (no check run, cancelled, skipped, stale) is looked past. + Ten queries at most, a hundred candidates at most. Only a check run of the + GitHub Actions app counts, the one the ruleset requires. A push, a missing base, + a git or `curl` failure: a run. +- **The guard**: every tracked `.zig`, `.zon` and workflow file; an `@embedFile`, + a `.path(` call or a file-opening call whose literal resolves, against its file + or the repository root, into an excluded path; a manifest string; a workflow + line outside comments. On first run it found exactly the two lists the tool + replaces — `bench.yml`'s `paths-ignore` and the `case` of `ci.yml` — which is its + capacity to fire shown on the real tree. Its bound: a path computed at run time + (the witness `.bin` embeds) and a system command's arguments are not resolved. + +Decisions on real commits, run from the workstation before the push: +`958c24be` and `bb819293`, documentation over `c0a1fafb`, inherit `c0a1fafb` +(the second through `958c24be`, which ran nothing); `754fdeef`, a workflow +change, and the code commits `c0a1fafb` and `062908db` run. + +Twenty-eight tests. Fourteen counter-factuals, predictions written before the +batch, in a worktree pinned to `a92f45a2` — each landed exactly as predicted: + +| CF | Removed | Red | +|---|---|---| +| T1 | the exclusion widened to every `.md`, the old hole | the exclusion, the kept-path digest, the kept-Markdown embed, and the tree guard at `ebnf_examples_test.zig:41` and both `build.zig.zon` (`README.md`) | +| T2 | the path out of the digest | the kept-path digest | +| T3 | a red verdict looked past | the red-verdict test | +| T4 | a pending verdict looked past | the pending-verdict test | +| T5 | the candidate's ancestry | the never-asked test | +| T6 | the tested-tree condition | the three-conditions test | +| T7 | the head's ancestry | the three-conditions test | +| T8 | the app id | the latest-run test and the other-app test | +| T9 | the latest run, taken as the first | the latest-run test | +| T10 | `path` from the readers | the build-path test | +| T11 | resolution against the file's directory | the relative-embed test | +| T12 | comment lines in workflows | the workflow test | +| T13 | the base as last candidate | the candidate-list tests | +| T14 | a digest computed outside the base | the outside-base test | + +**The cost, measured on run `35966120397`**: the `changes` job took 17 s against +8 s before — setup-zig 5 s from the cached tarball, compiling and running the tool +4 s (2.5 s cold on the workstation). About 10 s on the critical path of every run, +against a matrix of 28 to 56 minutes that a documentation commit now skips. On +that code commit the tool decided a run: `no commit with the same code was verified +green`. + +**The gain, predicted before the push of this record** — a documentation-only +commit over `a92f45a2`, whose run `35966120397` is green on every job: in CI, +`changes` answers `code=false`, inherited from `a92f45a2`; the thirteen cells, both +smokes and the witness job are skipped; `ci-gate` is green with the reason as a +notice. In Bench, `changes` inherits `a92f45a2`'s `bench-gate`, the bench is skipped +and `bench-gate` is green. + +`bench.yml` now runs its own `changes` against a new `bench-gate` job, which is +green when the bench ran green or was skipped by inheritance, and it loses its +`paths-ignore`. One consequence is behaviour: a documentation-only push to `main` +now runs the bench, where `paths-ignore` skipped it, since a push always runs. + +#### For the migration, not now + +Codeberg's hosted runners cap a job at 10 minutes, where these cells take 28 to +56. Bascule C1.10 will have to meet it: self-hosted runners, whose cache is local +to each runner unless they share an external cache server. + +#### Tests removed or changed + +None removed. Twenty-eight added, `tools/ci_verdict`. + +#### What G9 bis stops before + +Geste 2, the pull-request cache without a branch name, and the measurement of the +repository total over two consecutive runs of PR #82; S3 only if the cap does not +hold; then decisions 2 to 4, the third axis, the codegen panic and the four +leftovers. + +#### Gates + +On `a92f45a2`, each exit code read before any filtering: +- `zig build test`, Debug, ReleaseSafe and `-Dphysics_f64=true`: exit 0, + `329/329 steps; 2607/2624 tests passed (17 skipped)` in all three. +- `zig build lint`: exit 0, conservation OK at 2624. +- `zig fmt --check .`: exit 0. +- `bindgen-verify` (`9/9 steps`), `vk-gen-check` (`10/10`), `test-codegen-diff` + (`1/1`), `verify-synth-100`, `forge-asm-inventory -Dphysics_f64=true`: exit 0. +- `forge-determinism`: exit 0, `none within K=60`, both trace verdicts OK. +- `ecs-access-counterproof`: exit 0, `8/8 steps`. +- `shaders-check`: exit 0, `checked 6 shader(s): ok`. + +CI at `a92f45a2`, run `35966120397`: every job green, the Release cells 32.1 to +56.1 minutes; Bench run `35966120443` green, `bench-gate` included. + +**Floor 2596 → 2624, windows 2594 → 2622.** + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree From 4628db0e1004a73dbedaa24572fc7cf6d68f0dc3 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Thu, 24 Sep 2026 09:53:26 +0200 Subject: [PATCH 048/141] docs(brief): record the first inherited verdict fb7be4b0 inherited a92f45a2 in CI and in Bench, 32 s and 28 s. Co-Authored-By: Claude Opus 5.5 --- briefs/m1.d-phase-1-debt.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index 4126cdee..7384f8a3 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -6084,6 +6084,12 @@ smokes and the witness job are skipped; `ci-gate` is green with the reason as a notice. In Bench, `changes` inherits `a92f45a2`'s `bench-gate`, the bench is skipped and `bench-gate` is green. +Observed on `fb7be4b0`, exactly: CI run `35971967215` inherited `a92f45a2` and took +32 s end to end, every heavy job skipped and `ci-gate` green with the reason as its +annotation; Bench run `35971967095` inherited `a92f45a2` and took 28 s. This record +of it is itself a documentation commit, whose candidate is `fb7be4b0` — a verdict +that was inherited, which the rule accepts by transitivity of the digest. + `bench.yml` now runs its own `changes` against a new `bench-gate` job, which is green when the bench ran green or was skipped by inheritance, and it loses its `paths-ignore`. One consequence is behaviour: a documentation-only push to `main` From 5299f1d683ca46a70887ca03facc6b38a8c699c4 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Thu, 24 Sep 2026 09:56:05 +0200 Subject: [PATCH 049/141] docs(brief): record the chained inheritance of 4628db0e Co-Authored-By: Claude Opus 5.5 --- briefs/m1.d-phase-1-debt.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index 7384f8a3..d182c854 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -6089,6 +6089,8 @@ Observed on `fb7be4b0`, exactly: CI run `35971967215` inherited `a92f45a2` and t annotation; Bench run `35971967095` inherited `a92f45a2` and took 28 s. This record of it is itself a documentation commit, whose candidate is `fb7be4b0` — a verdict that was inherited, which the rule accepts by transitivity of the digest. +Observed on `4628db0e`: CI run `35972217748` and Bench run `35972217753` both +inherited `fb7be4b0`, in 30 s and 33 s. `bench.yml` now runs its own `changes` against a new `bench-gate` job, which is green when the bench ran green or was skipped by inheritance, and it loses its From 8a4847888a3a0108e221f71709f33129070ac000 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Thu, 24 Sep 2026 11:14:56 +0200 Subject: [PATCH 050/141] fix(ci): save the zig caches from pull requests too Every save ran on push only, which left each pull request run to rebuild what changed since main's last save. The saves now run on both events the workflows trigger on; no condition and no key names a branch, and the forge isolates a pull request's entries to its own later runs (engine-platform.md, sha256 877ebe87). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/bench.yml | 2 +- .github/workflows/ci.yml | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index f6bd4809..0ee6cb7a 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -89,7 +89,7 @@ jobs: run: zig build bench-ecs-hybrid -Doptimize=ReleaseSafe -Dcpu=${{ env.ZIG_CPU }} - name: Save Zig cache - if: always() && github.event_name == 'push' + if: always() uses: actions/cache/save@v5 with: path: .zig-cache diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 033fad1f..4ae4aafa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -237,7 +237,6 @@ jobs: - name: Save Zig cache (final) if: always() && matrix.mode != 'Debug' - && github.event_name == 'push' && steps.cache-size-final.outputs.save == 'true' uses: actions/cache/save@v5 with: @@ -335,7 +334,7 @@ jobs: zig build test-runtime-env -Dcpu=${{ env.ZIG_CPU }} --summary all - name: Save Zig cache - if: always() && github.event_name == 'push' + if: always() uses: actions/cache/save@v5 with: path: .zig-cache @@ -469,7 +468,7 @@ jobs: echo "=== end E6 blit validation ===" - name: Save Zig cache (Debug) - if: always() && github.event_name == 'push' + if: always() uses: actions/cache/save@v5 with: path: .zig-cache From 0242916ca577ab01e0c5596b3e2c84d12c399f6a Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Thu, 24 Sep 2026 12:23:10 +0200 Subject: [PATCH 051/141] docs(ci): state what the exclusion guard does not see The guard resolves literal paths only, so whoever adds an excluded path must know that a path computed at run time or passed to a system command escapes it. Measured at 4628db0e: no code reads briefs/ or CLAUDE.md; they are named only in comments and Markdown. Co-Authored-By: Claude Opus 5.5 --- tools/ci_verdict/verdict.zig | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/ci_verdict/verdict.zig b/tools/ci_verdict/verdict.zig index 276b101b..014d3ba4 100644 --- a/tools/ci_verdict/verdict.zig +++ b/tools/ci_verdict/verdict.zig @@ -3,7 +3,9 @@ const std = @import("std"); -/// Path prefixes no CI job reads. The digest leaves them out. +/// Path prefixes no CI job reads. The digest leaves them out. `guard.zig` checks +/// that claim for literal paths only: a path computed at run time or passed to a +/// system command is not seen. pub const excluded_prefixes = [_][]const u8{"briefs/"}; /// Whole paths no CI job reads. The digest leaves them out. pub const excluded_files = [_][]const u8{"CLAUDE.md"}; From f30a70300e77899e2d739818137b2044047b10bd Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Thu, 24 Sep 2026 13:04:09 +0200 Subject: [PATCH 052/141] docs(brief): record geste 2 over two runs, the cap does not hold Co-Authored-By: Claude Opus 5.5 --- briefs/m1.d-phase-1-debt.md | 51 +++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index d182c854..3502123b 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -6132,6 +6132,57 @@ CI at `a92f45a2`, run `35966120397`: every job green, the Release cells 32.1 to **Floor 2596 → 2624, windows 2594 → 2622.** +### S5/G9 ter — geste 2, measured over two runs: the cap does not hold + +Guy closed `M1.D.16` on the tool, and asked the limit of the guard written where a +path is excluded: `excluded_prefixes` now states that the guard checks literal paths +only, a path computed at run time or passed to a system command escaping it. The +claim it guards was measured at `4628db0e`: nothing under `src/`, `tests/`, +`build.zig`, `examples/` or `bench/` reads `briefs/` or `CLAUDE.md`. They are named +in comments of four Zig files, in a comment of one `.etch` fixture, in +`src/core/ecs/README.md` and in four Markdown reports of `bench/` — mentions, never +reads. + +Geste 2 (`8a484788`): every save runs on both events the workflows trigger on, no +condition and no key naming a branch. Two consecutive code runs of PR #82 were then +measured, the predictions of each written before it ran. + +| Moment | Entries | Bytes | `main`'s share | +|---|---|---|---| +| baseline, 09:13 | 20 | 6 400 136 878 | all | +| after run 1, `8a484788`, 10:21 | 15 | 10 333 863 843 | 3 entries, 1 263 583 520 | +| after run 2, `0242916c`, 10:44 | 12 | 10 408 799 999 | none | +| after run 2's re-run, 11:03 | 15 | 10 751 986 555 | none | + +Run 1 restored `main`'s entries in all seven Release cells, built 143 to 146 compile +steps of 162, took 40 to 57 minutes, and saved a pull-request lineage. **Two of its +predictions were refuted.** The entries it saved weigh 1.09 to 1.27 GB each, twice +`main`'s 0.55 to 0.64: the restored cache and the rebuilt steps add up, and nothing +prunes the cache. And `main`'s matrix entries were not spared for having just been +read: 17 of `main`'s 20 entries were evicted during that single run, six of the seven +matrix ones among them. Two of `main`'s three survivors were new, saved by the +nightly fuzz, which ran at the same time. + +Run 2 restored run 1's entries in every Release cell, built 1 to 9 compile steps, +and took 1.3 to 7 minutes — **the gain geste 2 exists for, measured**. It left `main` +with nothing: all twelve entries belong to PR #82, and they grew again, to 1.16 to +1.34 GB. After run 2's re-run saved the two smokes, the total stood 14.6 MB over the +10 737 418 240-byte cap. + +So one pull request's lineage alone, about 9 GB, fills the repository's 10 GB, and +`main`'s lineage — which every other pull request and the next push to `main` +restore from — does not survive it. Stopped here, before S3, as ruled. + +**Both windows ReleaseSafe cells of run 2 failed on the same test**, +`win32_thread_safety_test` `concurrent createWindow + destroyWindow`, with `failed +without output` 30.5 s after the log line before it — the test's 30-second budget, +returning while its eight threads still run. The diff was a comment in +`tools/ci_verdict`. Both passed at the same sha on the re-run, in 9 and 10 minutes. +That run was the first warm one, and a warm `zig build test` runs its test binaries +with no compilation between them; that this saturates the runner past the budget is +a hypothesis, not a measurement. Third and fourth occurrences of the class +`CLAUDE.md` records as needing a number, the first two on Debug. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree From c412ac6c78aa48c16c5aff4f2e196832b713dc38 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Thu, 24 Sep 2026 15:16:19 +0200 Subject: [PATCH 053/141] fix(ci): save the zig caches on push only again Measured over two runs of PR #82: a pull request's lineage holds main's cache plus everything it rebuilds, weighs twice main's, fills the 10 GB cap alone, and left main with no entry. Saves run on push again, which the workflows trigger on main only; no condition and no key names a branch (engine-platform.md, sha256 127a6bb4). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/bench.yml | 2 +- .github/workflows/ci.yml | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index 0ee6cb7a..f6bd4809 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -89,7 +89,7 @@ jobs: run: zig build bench-ecs-hybrid -Doptimize=ReleaseSafe -Dcpu=${{ env.ZIG_CPU }} - name: Save Zig cache - if: always() + if: always() && github.event_name == 'push' uses: actions/cache/save@v5 with: path: .zig-cache diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ae4aafa..033fad1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -237,6 +237,7 @@ jobs: - name: Save Zig cache (final) if: always() && matrix.mode != 'Debug' + && github.event_name == 'push' && steps.cache-size-final.outputs.save == 'true' uses: actions/cache/save@v5 with: @@ -334,7 +335,7 @@ jobs: zig build test-runtime-env -Dcpu=${{ env.ZIG_CPU }} --summary all - name: Save Zig cache - if: always() + if: always() && github.event_name == 'push' uses: actions/cache/save@v5 with: path: .zig-cache @@ -468,7 +469,7 @@ jobs: echo "=== end E6 blit validation ===" - name: Save Zig cache (Debug) - if: always() + if: always() && github.event_name == 'push' uses: actions/cache/save@v5 with: path: .zig-cache From db275303cf6d901d32df8481f00fc14b8ac0a427 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Thu, 24 Sep 2026 15:19:31 +0200 Subject: [PATCH 054/141] docs(brief): state what saving on push only costs before the run Co-Authored-By: Claude Opus 5.5 --- briefs/m1.d-phase-1-debt.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index 3502123b..b509defe 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -6183,6 +6183,38 @@ with no compilation between them; that this saturates the runner past the budget a hypothesis, not a measurement. Third and fourth occurrences of the class `CLAUDE.md` records as needing a number, the first two on Debug. +### S5/G9 quater — option 4: the saves run on push only, stated before the run + +Guy ruled option 4 on `engine-platform.md` (sha256 `127a6bb4…`), whose §8 prescribes +saving on push only. `c412ac6c` puts the four save steps — the matrix's final save, +the two smokes, the bench — back on `github.event_name == 'push'`; both workflows +trigger on push for `main` only, so no condition and no key names a branch. +`git diff 8a484788~1 -- .github/workflows/ci.yml .github/workflows/bench.yml` is +empty: the workflows are exactly what they were before geste 2. The fifteen entries +of `refs/pull/82/merge` were deleted by id; at 13:16:00Z the repository held **zero +entries**, `main` included, its last three having been evicted during geste 2. + +What that means, stated before the run rather than found at it: + +- **The next code run of PR #82 is fully cold.** Its restore reads the pull + request's scope and `main`'s, and both are empty. No commit of the pull request + shares `c412ac6c`'s digest (all 53 checked with `ci_verdict digest`), so the run + cannot inherit either. Expected: nearly all 162 compile steps built, Release + cells 45 to 60 minutes. +- **None of the four save steps runs** on that run, nor on any pull-request run. +- **`setup-zig` still writes into the pull request's scope.** It caches its + downloaded tarball whatever `use-cache` says, and no input turns that off, so the + run saves one tarball per platform: about 55 MB for `x86_64-linux`, 91 MB for + `x86_64-windows`, 51 MB for `aarch64-linux`. +- **`main`'s matrix lineage refills only at the next push to `main`, which is the + merge, Guy's act.** Until then every code run of every pull request is cold. The + nightly fuzz refills `main`'s scope with its own entries and the tarballs at its + next scheduled run — `schedule`, on `main`, through `setup-zig`'s cache — and + never with a matrix entry. +- Once `main` is warm again, the accepted cost is the one ruled: a code commit on a + pull request rebuilds what it changed since `main`, 28 to 56 minutes; a docs + commit inherits in about 30 seconds through `ci_verdict`. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree From 6b2dd2710ae83cff196fbce66843fb8018ee0422 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Thu, 24 Sep 2026 17:14:44 +0200 Subject: [PATCH 055/141] docs(brief): record the run saving on push only, cold as stated Co-Authored-By: Claude Opus 5.5 --- briefs/m1.d-phase-1-debt.md | 39 +++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index b509defe..89c18833 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -6215,6 +6215,45 @@ What that means, stated before the run rather than found at it: pull request rebuilds what it changed since `main`, 28 to 56 minutes; a docs commit inherits in about 30 seconds through `ci_verdict`. +#### Verified on the run + +CI run `36005034982` and Bench run `36005035043`, both at `db275303` (this record's +commit, over `c412ac6c`), with a re-run of the failed jobs at the same sha. `changes` +answered `code=true` in both. + +- **Nothing was saved by our steps.** Every save step that ran its job was + skipped: the seven matrix final saves, the two bench smokes, and on attempt 2 + `runtime-smoke-test` and `vertical-slice-smoke`, which attempt 1 skipped whole + behind the failed matrix. The six Debug cells restore and save nothing by mode. +- **Nothing was restored.** Every restore that ran reported `Cache not found for + input keys`, the pull request's scope and `main`'s both empty: seven Release + cells, two bench smokes, two application smokes. +- **Cold, as predicted in steps, not in minutes.** 159 of 162 compile steps built + in every cell measured. Release cells took 35.3 to 57.8 minutes. **The predicted + floor of 45 is refuted**: `ubuntu-24.04 / ReleaseSafe` 35.3 (f64) and 41.3 (f32), + `ubuntu-24.04-arm / ReleaseSafe` 36.2 and 37.4. `ReleaseFast` 46.9; Windows + ReleaseSafe 52.8 and 57.8 on attempt 1, 42.3 and 43.1 on attempt 2. +- **The bench smokes ran at their 20-minute budget when cold**: 19m46s on + `ubuntu-24.04`, fourteen seconds under it, and 18m53s on `windows-2025`. Until + `main` is warm again, a bench smoke on a code commit has no headroom. +- **`setup-zig` wrote exactly what was predicted**: three tarball entries in + `refs/pull/82/merge`, 55 481 366 + 91 486 638 + 51 215 030 = 198 183 034 bytes. + The first job on each platform missed and saved, and every later job of the run + hit that entry: the bench's `changes` job saved the Linux tarball at 13:21:08 and + CI's `changes` restored it 32 seconds later. +- **After the run and its re-run: 3 entries, 198 183 034 bytes**, all three those + tarballs, all in the pull request's scope, nothing under `refs/heads/main`. + +**Both windows ReleaseSafe cells of attempt 1 failed on `M1.D.19`**, and passed at +the same sha on attempt 2, collecting the declared 2622 each. The diff carries no +Zig. f64: the signature once, `2587/2617 (30 skipped)`, five tests lost, hung step +`06b3583a…`. f32: the signature three times, `2585/2614 (29 skipped)`, eight tests +lost, hung steps `9fc23c50…`, `06b3583a…` and `110fa824…`. Zero failed assertions and +no `failed without output` in either. **The same identity, `06b3583a…`, hung in both +cells of one attempt**, under different seeds (`0x21372d02`, `0x55397372`). + +Stopped here, before decisions 2 to 4 of G9, as ruled. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree From a0023692996c80ea37dcfe3cc39beda0b0bf7d86 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Thu, 24 Sep 2026 18:53:47 +0200 Subject: [PATCH 056/141] fix(ci): give the bench smoke 30 minutes, enough for a cold run A budget separates a slow run from a hung one, so it covers the cold run. From an empty cache the Ubuntu smoke took 19m46s against 20, and a cold run at b71017ef was cancelled at 20m0s; the crossover sweep alone is 12 to 14 minutes whatever the cache. The fuzz budget comment stated a 3-min build; it measures 594 s cold and 489 s warm, so the figure goes. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/bench.yml | 2 +- .github/workflows/nightly-fuzz.yml | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index f6bd4809..ad0dffde 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -59,7 +59,7 @@ jobs: matrix: os: [ubuntu-24.04, windows-2025] runs-on: ${{ matrix.os }} - timeout-minutes: 20 + timeout-minutes: 30 steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/nightly-fuzz.yml b/.github/workflows/nightly-fuzz.yml index 9a9928c7..710d63fc 100644 --- a/.github/workflows/nightly-fuzz.yml +++ b/.github/workflows/nightly-fuzz.yml @@ -28,9 +28,8 @@ jobs: matrix: os: [ubuntu-24.04, windows-2025] runs-on: ${{ matrix.os }} - # 1 h fuzz + ReleaseSafe build (~3 min on the 2-vCPU Windows runner) + - # overhead. 90 min leaves headroom without risking a runaway hang - # masquerading as a pass. + # 1 h fuzz + ReleaseSafe build + overhead. 90 min leaves headroom without + # risking a runaway hang masquerading as a pass. timeout-minutes: 90 steps: - uses: actions/checkout@v6 From ae9ee61211ccd1cc4e04c0c68592305fbb133cf8 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Thu, 24 Sep 2026 18:53:50 +0200 Subject: [PATCH 057/141] docs: record the bench budget, its declarants and a shared hang Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 ++-- briefs/m1.d-phase-1-debt.md | 71 +++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5e8bf1d6..26581167 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,8 +120,8 @@ commit, so a milestone still in review has no row here. - **THE NUMBER `M1.D.17` DENOTES TWO DIFFERENT DEBTS, and this is the SECOND such collision (measured at M1.D close).** `engine-phase-1-plan.md` numbers `M1.D.17` the Etch hot-reload debt — a `component` reloaded with a changed layout reusing its id — CLOSED at M1.D/S4/G6. The entry immediately below, carried only here, is a DIFFERENT debt under the same number, and the plan carries it under none at all: measured on the delivered lot (`sha256 761adec9…`, 646 lines), zero occurrences of `buildSchemaRemap` or `.sav`. M1.E already corrected a first `M1.D.17`/`M1.D.18` collision on chunk fragmentation, so this is not that one. **Deliberately NOT renumbered here**: the debt table belongs to `engine-phase-1-plan.md`, and a repo file renaming a corpus entry is how a third reading is born. Owner: Guy, with the plan. - **M1.D.17 (this file's sense) — the `.sav` schema check is blind to a size-preserving field addition (opened at M1.1.15.2).** `loader.buildSchemaRemap` compares SIZE and ALIGNMENT, and `RigidBody.authority` landed in existing trailing padding — 32 bytes before and after. It bites nothing today because no image carries a `RigidBody` (measured: zero `.etch` name the type, nothing registers it, no cook reaches it) and `.solver` is the zero value, pinned by a test. It will bite at the first image that does, and nothing will announce it. Owner: `engine-scene-serialization.md`, not a physics milestone. - **M1.D.18 — a table component under sustained churn grows its chunk count until the DISPATCH FAILS (opened at M1.B/G11, measured, mechanism named)**. `Archetype.removeSwap` compacts INSIDE one chunk only — `chunk_idx` is fixed and the last slot OF THAT CHUNK fills the hole — and `archetype.zig` has NO release path: no `chunks.pop`, no `swapRemove`, no `entity_count == 0` test, no shrink. So the count follows the CUMULATIVE number of adds and never the live population. Measured by `bench/ecs_hybrid_crossover.zig` at `payload=64B`, fraction 1.0, churn 60/carrier/s: **128 chunks at the first tick, 8200 within the window**, for 20 000 entities over 8 archetypes — 2.4 entities per chunk where the payload allows ~156 — after which `jobs.Scheduler.dispatchBatch` returns `error.TooManyChunks` at its `workers × 8192` capacity. **The consequence is a HARD dispatch failure, not slowness**, and the population that reaches it is any durable churning load — precisely the load `@storage(.sparse)` exists to serve, whose range count is CONSTANT in the same report row. **Invisible to C0.1, which never churns.** NOT fix-as-you-go and the reason is structural, not convenience: the remedy is inter-chunk compaction or a partial-chunk free list, and **moving an entity between chunks invalidates the `chunk_ptr` of every live `ComponentRef`** — the type M1.B/G5 built, whose table arm deliberately holds a chunk pointer — so the fix touches a contract this milestone just froze, and it interacts with `chunkAt(i)`'s stability during a dispatch. That is a milestone, not a commit. Owner: the chunk-lifecycle owner; Guy carries the corpus side. **THE BLOCKER THIS ENTRY RECORDS NO LONGER EXISTS, measured at M1.D/S5/G6**: `M1.D.21` removed `chunk_ptr` in S2/G1 of that same milestone, one session BEFORE this sentence was written, so `ComponentRef` is `{entity, component_id, mutable}` and re-resolves at every access. Second deferral condition satisfied by work from elsewhere, after `M1.D.12`. And the half of the remedy that is REUSE — not compaction — landed at S5/G7 as `Archetype.first_partial`: it moves NO entity, because `removeSwap` leaves a dense prefix and free space at the tail, so its invalidation set is empty. Its benefit is deliberately UNMEASURED and no figure is offered, the instrument being absent (`DynamicQuery` exposes neither `chunkAt` nor `chunkCount`). -- **`M1.D.14` gets its SEVENTH and EIGHTH measurements, and its first treatment (M1.B/G11)**. That plan row already carries this debt by name — *"le job `bench-ecs-smoke (windows-2025)` a une queue de durée qui franchit son budget `timeout-minutes: 10`"* — with six measurements at near-constant code (5m08, 6m52, 8m19, 9m21, 9m28, 9m38), factor 1.9, two cancellations and two green re-runs at the SAME SHA, and it names raising the budget as one of two options while taking neither. **M1.B adds 9m37 (passed, 23 seconds of headroom) and 11m28 (cancelled) and TAKES that option**: 10 → 20 minutes, with the measurement written at the site. M1.B also made the job heavier, measured rather than assumed — the step runs `zig build bench-ecs`, whose run step depends on the install step, so it compiles EVERY installed artifact, verified by deleting `zig-out/bin/` and watching `ecs-hybrid-crossover-bench` reappear beside `ecs-benchmark`. What the raise does NOT remove is the runner's intrinsic variance, which `M1.D.14`'s own analysis already establishes as the cause, nor the standing question of whether the Windows bench belongs in the PR matrix. *An earlier draft of this entry opened a parallel record under a new number; a debt treated under any name but its own stays open in the document that carries it.* -- **A CELL OF THE CI MATRIX HANGS, TEN TIMES MEASURED, AND THE CLASS HAS NO HOME IN THE CORPUS (opened at M1.B/G11, needs a number)**. Distinct from `M1.D.14`, which carries the DURATION of the bench job: this is the PENDING of a matrix cell that gates merges. **Cell:** `build-and-test (windows-2025, ReleaseSafe)`, both precisions. **Signature:** `error: test runner failed to respond for ~1m`, with **zero** occurrences of the sibling class `failed without output` — the two have never been co-present. **Count: TEN**, all on that cell, every one exonerated by a green re-run at the SAME SHA (three recorded before M1.B, two at M1.B/G10-G11, three at M1.B/P3-P4, one at M1.E/G12 on `6a2bb8e`, one at M1.E on `387ab97`). **The ninth, at M1.E/G12 on `6a2bb8e`, is the most informative the class has produced and it is the LARGEST BY AN ORDER**: `2164/2196 tests passed (32 skipped)` against a declared windows floor of 2250 gives **54 TESTS LOST**, where the previous counts were 1, 5, 6, 8 and 14. It ran 38.1 min against a 55-minute budget, so it is NOT the sibling timeout recorded below it — that one has every step green and no lost test — and the log carries ZERO `error: '…' failed:` lines, so no assertion fired. Fifty-four tests is a whole step of substance rather than a straggler, which narrows what can be hanging: the class is not a slow tail on a small step. **THE THIRTEENTH, at M1.A on `565012c`, hangs TWICE IN ONE RUN and the new discriminant reads BOTH**: signature present twice, sibling absent, zero assertions, `2253/2285 tests passed (32 skipped)` against a declared floor of 2288 — **THREE tests lost across two hung steps** — 52.6 minutes against 55 with conclusion `failure`. The two `failed command:` lines name `fecde19354ea9ccbd9ecb5d20cdb00ac` and `2fe9c3b586059afa4881744abc5715ef`: **two different executables in ONE build**, which is the within-run twin of the within-SHA comparison the twelfth produced. Four distinct identities are now recorded across three attempts and no two agree. The series of losses is 1, 5, 6, 8, 14, 54, 2, 1, 3. **THE TWELFTH, at M1.A on `e054b26`, ties the smallest loss and adds a collateral the class did not carry**: `2255/2287 tests passed (32 skipped)` against a declared floor of 2288 gives **ONE test lost**, signature present once, sibling class absent, zero `error: '…' failed:` lines, and **53.0 minutes against a 55-minute budget with conclusion `failure` and not `cancelled`** — which excludes `M1.D.27` on both of its discriminants at once rather than on duration alone. The series of losses is now 1, 5, 6, 8, 14, 54, 2, 1. **The collateral is that two debts met on one job**: the run carries `Zig cache is 11222302720 bytes, over the 10737418240 cap — SKIPPING the final save`, which is `M1.D.10`'s mechanism, and a skipped final save leaves the NEXT run on that cell colder, which is `M1.D.27`'s trigger. Neither debt is new; their interaction is recorded nowhere else. **IT FAILED ITS FIRST SAME-SHA RE-RUN, and that re-run produced a THIRD DISCRIMINANT this entry records as impossible.** Attempt 2: signature once, sibling absent, zero assertions, `2256/2287 (31 skipped)` against 2288 — **one test lost again**, where every previous pair of failures at one SHA had lost DIFFERENT counts, which is the shape an implicated test would produce. Refuted by measurement: this entry states that *"naming the step from the log does not work"*, which is true of the step's SOURCE name and FALSE of its identity — **the `failed command:` line immediately following the `failed to respond` message carries the hung step's build-cache hash**, and the two attempts differ (`01a58047…` against `c810df3f…`). Two DIFFERENT executables hung at one SHA, so no test is implicated, and the equal loss explains itself: both hung steps sit in the contiguous family whose neighbours all report `0 pass, 1 skip (1 total)`, where a hang costs exactly one test whichever member it hits. *The count was never the discriminant; the identity is, and it is one grep away in every log this class has already produced.* **THE ELEVENTH, at M1.A on `3f22cf6`, is the smallest loss the class has produced and the cleanest measurement of it**: `2245/2277 tests passed (32 skipped)` against a declared floor of 2279 gives **2 TESTS LOST**, with the signature present once, the sibling class absent, zero `error: '…' failed:` lines, and 52.7 minutes against a 55-minute budget — so `M1.D.27` is excluded by duration and by conclusion (`fail`, not `cancelled`). The series of losses is now 1, 5, 6, 8, 14, 54 and 2, which continues to refute any single implicated test. Cleared by a re-run at the SAME SHA. **THE TENTH, at M1.E on `387ab97`, is the first to hang TWICE IN ONE RUN**: two `failed to respond` twelve minutes apart (19:49:14 and 20:01:15 UTC) on two DIFFERENT test executables, and `301/304 steps succeeded (2 failed)` accounts for exactly those two. **And the loss stopped following the step count**: `2217/2249 tests passed (32 skipped)` against the same 2250 floor gives **ONE test lost for TWO hung steps**, so at least one of the two cost no test at all — where the counts so far had been 1, 5, 6, 8, 14 and 54, always for a single step. That asymmetry is NOT explained here: the natural reading, a runner that blocks after its last result is already reported, is plausible and unmeasured, and this class has already paid for two hypotheses issued on a plausible reading. What it does strengthen is the `0664f28` discriminant — two different steps, in one run, on a commit whose diff is comments and two Markdown files. It ran 40.2 min, SHORTER than the sibling timeout because it aborted on the hang rather than finishing, carries ZERO `error: '…' failed:`, and exonerated on the FIRST re-run. **AT ONE SHA (`0664f28`) THE CELL FAILED THREE TIMES AND LOST THREE DIFFERENT COUNTS — 14, 1 and 5 tests — hence three different step sets.** That is a discriminant the class did not previously have, and it is the strongest evidence yet that no single test is implicated: a test that hangs deterministically blocks the same step every time and loses the same number. **And the frequency moved**: the five occurrences preceding that SHA each exonerated on the FIRST same-SHA re-run, where this one took two — f64 green on re-run 1, f32 failing again with a third lost count and green only on re-run 2. Two collateral facts from the same investigation: `pre-push` runs `zig build test -Doptimize=ReleaseSafe` (`lefthook.yml:31`), so the failing cell's MODE is green on the dev machine at every push; and pushing over an in-flight run marks that run `failure` with no evidence of its own (`aa7416c`), which is the recorded status-predicate hazard seen from the other side. **Two discriminants, and only one of them works today.** (1) `declared − collected` from the `Build Summary` line `--summary all` already produces: at the M1.B occurrence, `302/304 steps succeeded (1 failed); 2103/2135 tests passed` against a declared windows floor of 2143 gives **8 tests lost**, so the hung step holds eight — a SIZE, computed and not inferred. (2) Naming the step from the log **does not work**: a hung step emits no output at all, so the per-step summary that would name it is exactly what is missing, and `--log-failed` returns the aggregate. Naming it needs either a per-step timeout that identifies its target or a step-by-step run. What DOES survive is a negative discriminant used at G11: a step that printed its own report AND its `failed command:` artifact has COMPLETED, which is how the M1.B/G10 scheduler-dispatch tests were exonerated without a re-run. **The corpus carries no foyer for this**: the signature returns zero occurrences across the corpus (measured), so ten occurrences on a merge-gating cell live only in a succession of briefs. +- **`M1.D.14` gets its SEVENTH and EIGHTH measurements, and its first treatment (M1.B/G11)**. That plan row already carries this debt by name — *"le job `bench-ecs-smoke (windows-2025)` a une queue de durée qui franchit son budget `timeout-minutes: 10`"* — with six measurements at near-constant code (5m08, 6m52, 8m19, 9m21, 9m28, 9m38), factor 1.9, two cancellations and two green re-runs at the SAME SHA, and it names raising the budget as one of two options while taking neither. **M1.B adds 9m37 (passed, 23 seconds of headroom) and 11m28 (cancelled) and TAKES that option**: 10 → 20 minutes. M1.B also made the job heavier, measured rather than assumed — the step runs `zig build bench-ecs`, whose run step depends on the install step, so it compiles EVERY installed artifact, verified by deleting `zig-out/bin/` and watching `ecs-hybrid-crossover-bench` reappear beside `ecs-benchmark`. What the raise does NOT remove is the runner's intrinsic variance, which `M1.D.14`'s own analysis already establishes as the cause, nor the standing question of whether the Windows bench belongs in the PR matrix. *An earlier draft of this entry opened a parallel record under a new number; a debt treated under any name but its own stays open in the document that carries it.* **M1.D/S5 takes the option a second time, 20 → 30 minutes**: the Ubuntu job took 19m46s from an empty cache and 14m19s restored from its own lineage, its crossover sweep being 12 to 14 minutes of run time whatever the cache, and a cold Ubuntu run at `b71017ef` (2026-09-21) was cancelled at the 20-minute budget. +- **A CELL OF THE CI MATRIX HANGS, TEN TIMES MEASURED, AND THE CLASS HAS NO HOME IN THE CORPUS (opened at M1.B/G11, needs a number)**. Distinct from `M1.D.14`, which carries the DURATION of the bench job: this is the PENDING of a matrix cell that gates merges. **Cell:** `build-and-test (windows-2025, ReleaseSafe)`, both precisions. **Signature:** `error: test runner failed to respond for ~1m`, with **zero** occurrences of the sibling class `failed without output` — the two have never been co-present. **Count: TEN**, all on that cell, every one exonerated by a green re-run at the SAME SHA (three recorded before M1.B, two at M1.B/G10-G11, three at M1.B/P3-P4, one at M1.E/G12 on `6a2bb8e`, one at M1.E on `387ab97`). **The ninth, at M1.E/G12 on `6a2bb8e`, is the most informative the class has produced and it is the LARGEST BY AN ORDER**: `2164/2196 tests passed (32 skipped)` against a declared windows floor of 2250 gives **54 TESTS LOST**, where the previous counts were 1, 5, 6, 8 and 14. It ran 38.1 min against a 55-minute budget, so it is NOT the sibling timeout recorded below it — that one has every step green and no lost test — and the log carries ZERO `error: '…' failed:` lines, so no assertion fired. Fifty-four tests is a whole step of substance rather than a straggler, which narrows what can be hanging: the class is not a slow tail on a small step. **THE THIRTEENTH, at M1.A on `565012c`, hangs TWICE IN ONE RUN and the new discriminant reads BOTH**: signature present twice, sibling absent, zero assertions, `2253/2285 tests passed (32 skipped)` against a declared floor of 2288 — **THREE tests lost across two hung steps** — 52.6 minutes against 55 with conclusion `failure`. The two `failed command:` lines name `fecde19354ea9ccbd9ecb5d20cdb00ac` and `2fe9c3b586059afa4881744abc5715ef`: **two different executables in ONE build**, which is the within-run twin of the within-SHA comparison the twelfth produced. Four distinct identities are now recorded across three attempts and no two agree. **At M1.D/S5 on `db275303`, ONE identity hung in TWO cells of one attempt**: `06b3583a…` in `windows-2025 / ReleaseSafe` f64 (seed `0x21372d02`, its only hang, five tests lost) and f32 (seed `0x55397372`, beside `9fc23c50…` and `110fa824…`, eight lost). The two cells build that executable identically — thirteen test-executable hashes appear in both logs — so it is the same binary hanging twice. Both cells passed at the same sha on attempt 2. The series of losses is 1, 5, 6, 8, 14, 54, 2, 1, 3. **THE TWELFTH, at M1.A on `e054b26`, ties the smallest loss and adds a collateral the class did not carry**: `2255/2287 tests passed (32 skipped)` against a declared floor of 2288 gives **ONE test lost**, signature present once, sibling class absent, zero `error: '…' failed:` lines, and **53.0 minutes against a 55-minute budget with conclusion `failure` and not `cancelled`** — which excludes `M1.D.27` on both of its discriminants at once rather than on duration alone. The series of losses is now 1, 5, 6, 8, 14, 54, 2, 1. **The collateral is that two debts met on one job**: the run carries `Zig cache is 11222302720 bytes, over the 10737418240 cap — SKIPPING the final save`, which is `M1.D.10`'s mechanism, and a skipped final save leaves the NEXT run on that cell colder, which is `M1.D.27`'s trigger. Neither debt is new; their interaction is recorded nowhere else. **IT FAILED ITS FIRST SAME-SHA RE-RUN, and that re-run produced a THIRD DISCRIMINANT this entry records as impossible.** Attempt 2: signature once, sibling absent, zero assertions, `2256/2287 (31 skipped)` against 2288 — **one test lost again**, where every previous pair of failures at one SHA had lost DIFFERENT counts, which is the shape an implicated test would produce. Refuted by measurement: this entry states that *"naming the step from the log does not work"*, which is true of the step's SOURCE name and FALSE of its identity — **the `failed command:` line immediately following the `failed to respond` message carries the hung step's build-cache hash**, and the two attempts differ (`01a58047…` against `c810df3f…`). Two DIFFERENT executables hung at one SHA, so no test is implicated, and the equal loss explains itself: both hung steps sit in the contiguous family whose neighbours all report `0 pass, 1 skip (1 total)`, where a hang costs exactly one test whichever member it hits. *The count was never the discriminant; the identity is, and it is one grep away in every log this class has already produced.* **THE ELEVENTH, at M1.A on `3f22cf6`, is the smallest loss the class has produced and the cleanest measurement of it**: `2245/2277 tests passed (32 skipped)` against a declared floor of 2279 gives **2 TESTS LOST**, with the signature present once, the sibling class absent, zero `error: '…' failed:` lines, and 52.7 minutes against a 55-minute budget — so `M1.D.27` is excluded by duration and by conclusion (`fail`, not `cancelled`). The series of losses is now 1, 5, 6, 8, 14, 54 and 2, which continues to refute any single implicated test. Cleared by a re-run at the SAME SHA. **THE TENTH, at M1.E on `387ab97`, is the first to hang TWICE IN ONE RUN**: two `failed to respond` twelve minutes apart (19:49:14 and 20:01:15 UTC) on two DIFFERENT test executables, and `301/304 steps succeeded (2 failed)` accounts for exactly those two. **And the loss stopped following the step count**: `2217/2249 tests passed (32 skipped)` against the same 2250 floor gives **ONE test lost for TWO hung steps**, so at least one of the two cost no test at all — where the counts so far had been 1, 5, 6, 8, 14 and 54, always for a single step. That asymmetry is NOT explained here: the natural reading, a runner that blocks after its last result is already reported, is plausible and unmeasured, and this class has already paid for two hypotheses issued on a plausible reading. What it does strengthen is the `0664f28` discriminant — two different steps, in one run, on a commit whose diff is comments and two Markdown files. It ran 40.2 min, SHORTER than the sibling timeout because it aborted on the hang rather than finishing, carries ZERO `error: '…' failed:`, and exonerated on the FIRST re-run. **AT ONE SHA (`0664f28`) THE CELL FAILED THREE TIMES AND LOST THREE DIFFERENT COUNTS — 14, 1 and 5 tests — hence three different step sets.** That is a discriminant the class did not previously have, and it is the strongest evidence yet that no single test is implicated: a test that hangs deterministically blocks the same step every time and loses the same number. **And the frequency moved**: the five occurrences preceding that SHA each exonerated on the FIRST same-SHA re-run, where this one took two — f64 green on re-run 1, f32 failing again with a third lost count and green only on re-run 2. Two collateral facts from the same investigation: `pre-push` runs `zig build test -Doptimize=ReleaseSafe` (`lefthook.yml:31`), so the failing cell's MODE is green on the dev machine at every push; and pushing over an in-flight run marks that run `failure` with no evidence of its own (`aa7416c`), which is the recorded status-predicate hazard seen from the other side. **Two discriminants, and only one of them works today.** (1) `declared − collected` from the `Build Summary` line `--summary all` already produces: at the M1.B occurrence, `302/304 steps succeeded (1 failed); 2103/2135 tests passed` against a declared windows floor of 2143 gives **8 tests lost**, so the hung step holds eight — a SIZE, computed and not inferred. (2) Naming the step from the log **does not work**: a hung step emits no output at all, so the per-step summary that would name it is exactly what is missing, and `--log-failed` returns the aggregate. Naming it needs either a per-step timeout that identifies its target or a step-by-step run. What DOES survive is a negative discriminant used at G11: a step that printed its own report AND its `failed command:` artifact has COMPLETED, which is how the M1.B/G10 scheduler-dispatch tests were exonerated without a re-run. **The corpus carries no foyer for this**: the signature returns zero occurrences across the corpus (measured), so ten occurrences on a merge-gating cell live only in a succession of briefs. - **`win32_thread_safety_test` TIMES OUT on `windows-2025 / Debug`, and it is a DISTINCT class from the hang above (second occurrence at M1.A, needs a number)**. Not `M1.D.19`: the hang signature is ABSENT, no test is lost (collected equals the floor exactly), and ONE assertion genuinely fires — `error.Win32ThreadSafetyTimeout` at `tests/platform/win32_thread_safety_test.zig:99`, a wall-clock bound on three threads doing 100 `createWindow`/`destroyWindow` pairs each against a 30 s budget. The job takes ~10 minutes, not the ~50 of a hang. **The decisive discriminant is stronger than the sibling-cell one and is general to every flake class: A DIFF WITH NO ZIG IN IT.** The second occurrence landed on a commit whose diff is two Markdown files and seven lines, on top of a commit where that same cell had passed — so no code changed between the green and the red, and the commit cannot be the cause. The sibling `Debug / f64` cell passing at the same SHA corroborates it and is weaker: Win32 windowing has nothing to do with the physics scalar, so that axis could not discriminate a real fault either. **It is NOT the class M1.1.9 retired from `crash_recovery.zig`**, and the difference is worth keeping: those assertions ran AFTER the blocking receive returned and therefore guarded nothing, while this loop BOUNDS a wait and abandons it — a correctly shaped hang guard. What fires is its BUDGET on a runner the suite itself saturates. Removing it would remove a real guard; raising it weakens the bound it exists to give. Owner: unassigned. @@ -178,7 +178,7 @@ commit, so a milestone still in review has no row here. - **M1.1.9 scope boundary (queries: raycast)**: only the raycast is implemented; the COMPLETE family's signatures freeze here because a comptime strategy interface cannot gain a method after M1.1.15 (§1.11.7) — the deferral rule, not zeal. `error.UnsupportedShape` is structurally UNREACHABLE through the query path today: `shape.supportShape` maps a box to `radius = 0` unconditionally and the store holds only sphere/box/capsule, so no `SupportShape` reaching a kernel from a body can be a rounded box. The latch is required by construction, E3 pins the error at kernel level, and the end-to-end path becomes exerciseable at M1.1.11 with Plane and MeshShape. Dated unreachability, not debt. A query takes `*const BodyManager` and therefore CANNOT wake anything, which makes "a sleeping body answers and stays asleep" structural rather than merely tested. The `0.003886328` far-from-origin figure recorded in the M1.1.8 brief is not reproducible from a rebuilt probe (both legs read `0.003882778`, on `main` itself): a frozen brief records what its own probe measured, and a future re-measurement should not chase it. - **Tier 0 IPC — bounded receive, unowned (opened at M1.1.9)**: `engine-zig-conventions.md` §13 line 897 requires an internal timeout ≤ 5 s with clean resource teardown for any test awaiting an external resource. `connection.recvFrame` has neither a non-blocking variant nor a deadline (`src/core/ipc/connection.zig:123` and `:157` are the only receive entries), and the IPC test targets are built by a loop that does not wire `test_watchdog` (only the `test_specs` loop does, `build.zig:618`), so a hang there never stalls the sibling IPC cases but never lets `zig build test` complete either. Closing §13 for real needs a bounded receive primitive in Tier 0 IPC. Owned by whoever next opens that surface; not a physics milestone. - **Three M1.1.8 leftovers, re-pointed (the entry said M1.1.15 owned them; M1.1.15 closed and did not)**: the wake fixpoint's ROUND COUNT is still unpinned; the production W4 wiring DID land at M1.1.15 and leaves the list; and `build`'s per-tick deferred-index buffer — `rigid/contact_constraint.zig:578`, allocated and freed every tick because `build` owns no state — is now MEASURED rather than merely named: an 11 000-body scene at rest allocates **5 280 times over 240 steady-state frames** (2 400 alloc + 2 880 remap, 22 per tick), where the same scene kept awake allocates ZERO, all 8 809 retained pairs landing in that list and regrowing it from empty each tick. The zero-allocation property C1.1 asks for therefore holds in the configuration the gate measures and FALLS in the one a shipped game runs. Instrument: `bench/physics_forge_3d_integration.zig`. Owner: unassigned — the orchestrator's scratch is still where it goes. -- **Windows bench job budget (`bench.yml` `timeout-minutes: 10`)**: marginal and WILL recur. Closed by EXPERIMENT at M1.1.11.1, not by argument: the job was cancelled at 9m28 on `bench-ecs-smoke (windows-2025)`, passed on rerun at 7m34 on the SAME commit, and `build-and-test (windows-2025, ReleaseSafe)` — which compiles the whole forge suite including the `i1024`/`i8192` tiers — passed in 42m7, so the code is not implicated. The runner is intrinsically at the edge: `build-and-test (windows-2025, Debug)` takes 9m19 for comparable work against a 10-minute budget that includes checkout, Zig setup and cache restore. Two options, neither taken here: raise the budget, or drop the Windows bench from the PR matrix. Whoever hits the next cancellation should read this entry before suspecting their change. +- **Windows bench job budget (`bench.yml`, 10 minutes at M1.1.11.1, 30 since M1.D/S5 — see `M1.D.14` above)**: marginal and WILL recur. Closed by EXPERIMENT at M1.1.11.1, not by argument: the job was cancelled at 9m28 on `bench-ecs-smoke (windows-2025)`, passed on rerun at 7m34 on the SAME commit, and `build-and-test (windows-2025, ReleaseSafe)` — which compiles the whole forge suite including the `i1024`/`i8192` tiers — passed in 42m7, so the code is not implicated. The runner is intrinsically at the edge: `build-and-test (windows-2025, Debug)` takes 9m19 for comparable work against a 10-minute budget that includes checkout, Zig setup and cache restore. Two options, neither taken here: raise the budget, or drop the Windows bench from the PR matrix. Whoever hits the next cancellation should read this entry before suspecting their change. - **Frictionless-slider residual (since M1.1.11.1, QUALIFIED at M1.1.14)**: a FRICTIONLESS, undamped slider on a flat mesh seam retains more speed than it started with. Measured at M1.1.12 as `5.0000005` of 5 at f32 — one ULP — and **exactly `5` at f64**; that measurement is dated and stands. At M1.1.13.1 the f32 figure moved to **5.000002**, four ULP, under the substepped solver. **RE-MEASURED AT M1.1.14 AFTER THE FLOAT ENVIRONMENT WAS PINNED: it PERSISTS, unchanged at f32 to the bit, so the unpinned environment was NOT its cause** — the first branch of the alternative M1.1.14's brief imposed. f64 now shows **3 ULP** where M1.1.12 saw none. ~~The inference that used to settle this — "a solver adding energy would add it at both precisions", concluding arithmetic BECAUSE f64 was at zero — is DEAD, killed by that f64 figure.~~ It is replaced by a RELATIVE discriminant with nine orders of margin: energy injected at a physical rate is precision-independent in relative terms, so reproducing the f32 excess at f64 would take `4 × 2^29 = 2^31` ULP (ULP at 5.0 being `2^-21` at f32 and `2^-50` at f64), against **three** measured. It is ROUNDING at the solver's working precision, not energy. The f64 residual's CAUSE is deliberately NOT attributed: the TGS Soft port and M1.1.14's explicit folds both sit between the two measurements and neither was measured against this scene. Pinned in ULP — a metre bound cannot discriminate at f64 — in `mesh_test.zig`; normative detail in `engine-physics-solver.md` §1.7.2. - **Tooling facts have no owner (opened at M1.1.12)**: `engine-development-workflow.md` carries NO tooling-facts section, so these facts propagate by manual recopy from brief to brief with nobody accountable — which is how one gets dropped. Three were added this milestone, all self-reported, and one of them was a harness violating a fact the brief it was written against already listed. Give the workflow doc the section, and have briefs cite it instead of copying it. Not a physics milestone. - **M1.D.8 — the ARM `zig build test` cache anomaly (opened at M1.1.14, measured, unattributed)**: `ubuntu-24.04-arm / ReleaseSafe` went from 61 s to **1402 s** of `zig build test` between two consecutive runs, `zig build` unchanged at 77 s. LOCATED and not guessed: **129 of 132 `compile test` steps RAN where the good run reused 131** — the cost is COMPILATION, not test execution, which is why it does not scale with precision and did not block the f64 axis. What collapsed the reuse is NOT established, and the obvious candidate is EXCLUDED by measurement: editing `mesh_test.zig` locally rebuilds 3 steps, not 129. 25 min of a 55-min budget, so it is a cost and not a failure. Owner: unassigned. diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index 89c18833..57f024d5 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -6254,6 +6254,77 @@ cells of one attempt**, under different seeds (`0x21372d02`, `0x55397372`). Stopped here, before decisions 2 to 4 of G9, as ruled. +### S5/G9 quinquies — the bench smoke budget, 20 → 30 + +Guy ruled that a budget separates SLOW from HUNG and must cover the measured cold +run with margin: `bench.yml:62` goes 20 → 30, and the two `ci.yml` smokes are checked +against their cold durations. Every `timeout-minutes` of the three workflows was +enumerated with `git grep` and set against the cold run of `db275303`, by an audit of +read-only agents whose load-bearing figures were re-read here from the API: + +| Job | Budget | Cold maximum measured | | +|---|---|---|---| +| `bench-ecs-smoke` | 20 → **30** | 19m46s ubuntu, 18m53s windows | raised | +| `runtime-smoke-test` | 20 | 8m06s; at most 8m34s among the green jobs of the last 40 CI runs | unchanged | +| `vertical-slice-smoke` | 20 | 1m37s; at most 2m03s among the green jobs of the last 40 | unchanged | +| `build-and-test`, Release | 75 | 57m45s, the attempt-1 windows cell with three hangs; 46m53s at most among green cells | unchanged | +| `build-and-test`, Debug | 35 | 18m02s, always cold by mode | unchanged | +| `fuzz` (nightly) | 90 | 70m45s on 2026-09-19, not on this run | unchanged | +| `changes`, `ci-gate`, `bench-gate` | 5 | 32 s at most | unchanged | +| `witness-generation` | 60 | unmeasured: its steps run only under a `Witness-regen:` trailer | unchanged | + +**The premise of the ruling holds for `ci.yml` and not for the bench, and the +conclusion holds anyway.** The bench's keys carry no ISO week, so the weekly rotation +never reaches it; it goes cold on a `build.zig.zon` or toolchain change, on eviction, +or when `main` holds nothing, which is every case measured so far. And a warm cache +does not give it headroom: its crossover sweep is 12 to 14 minutes of run time +whatever the cache. Ubuntu took 19m46s cold here, 14m19s restored from its own +lineage at `0242916c`, and 19m52s at `0e253c85` restored under the former key, which +carried no sha. +**A cold Ubuntu run at `b71017ef` (2026-09-21) was cancelled by the 20-minute budget**, +its sweep cut after 737 s; no record carried it until now. So S5/G9 quater's "until +`main` is warm again, a bench smoke on a code commit has no headroom" rests on a +refuted premise: warm or cold, 20 was at the wall. That run's true duration is not +known; with the longest sweep measured, 816 s, it would have taken about 21 to 22 +minutes — an estimate, not a measurement. + +Declarants of those budgets: `CLAUDE.md` had two. One was present tense and false +since M1.B (`timeout-minutes: 10`); the other said the measurement was written at the +site, which `c3e6d316` removed. Both are corrected, and the `M1.D.19` entry there +records the identity that hung in two cells. `nightly-fuzz.yml` stated a build of +"~3 min" beside its budget: measured 594 s cold and 489 s warm on windows, so the +figure is dropped. **Four corpus lines outside the repository are false and are left +to Guy**: `engine-development-workflow.md:871` (`bench.yml porte timeout-minutes: +10`), `engine-development-workflow.md:614` (the runtime smoke costing 30 to 60 s under +a 5-minute budget, measured 8 minutes against 20), `engine-phase-1-plan.md:585` +(`M1.D.14`'s margin "écrite au site"), and `engine-phase-1-plan.md:591` (`M1.D.19`'s +"zéro recouvrement"). + +**Found while working, outside this gate, and stopped for arbitration**, against +`engine-platform.md` §8 (sha256 `127a6bb4…`): + +- `bench.yml`'s keys carry no ISO week, where §8 puts it "before every other axis, so + that every rung of the ladder carries it"; and its save has no all-or-nothing size + guard, where §8 applies the guard to every save point. The two `ci.yml` smoke saves + have no size guard either: the only one is the matrix's. +- `nightly-fuzz.yml` passes no `use-cache: false`, so `setup-zig` caches `.zig-cache` + there and saves it on `schedule` — against "one layer … at every site" and "saving + on `push` to `main` only". Its entries are keyed by run and accumulate: 1.37 GB of + them sat in `main`'s 6.4 GB at the baseline of geste 2. +- `vertical-slice-smoke` is a Debug job that restores and saves; whether §8's "the + `Debug` cells stay cold" covers the smokes or only the matrix is not settled. + +For `M1.D.19`, not in this gate: its signature is Zig's own response timeout, +`@max(unit_test_timeout_ns, 60 s)` (`std/Build/Step/Run.zig:1844-1846`), armed only +while no test is active — the runner not answering, not a test running long. The +repository configures no test timeout. + +**Predicted before the run** (the new tree is in the digest, so it runs whole, and +`main` still holds nothing): `changes` answers `code=true`; every restore misses and +the cells build 159 of 162 steps; the bench smokes run 18 to 21 minutes and pass +under 30; no save by our steps, and `setup-zig` hits its three tarballs, so the +repository still holds exactly 3 entries, 198 183 034 bytes, afterwards. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree From 9917af7e82889dd1a7a4c00d3939d9a70d9fc831 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Thu, 24 Sep 2026 21:26:47 +0200 Subject: [PATCH 058/141] docs(brief): record the bench budget run and three windows hangs Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- briefs/m1.d-phase-1-debt.md | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 26581167..cacdff68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,7 +121,7 @@ commit, so a milestone still in review has no row here. - **M1.D.17 (this file's sense) — the `.sav` schema check is blind to a size-preserving field addition (opened at M1.1.15.2).** `loader.buildSchemaRemap` compares SIZE and ALIGNMENT, and `RigidBody.authority` landed in existing trailing padding — 32 bytes before and after. It bites nothing today because no image carries a `RigidBody` (measured: zero `.etch` name the type, nothing registers it, no cook reaches it) and `.solver` is the zero value, pinned by a test. It will bite at the first image that does, and nothing will announce it. Owner: `engine-scene-serialization.md`, not a physics milestone. - **M1.D.18 — a table component under sustained churn grows its chunk count until the DISPATCH FAILS (opened at M1.B/G11, measured, mechanism named)**. `Archetype.removeSwap` compacts INSIDE one chunk only — `chunk_idx` is fixed and the last slot OF THAT CHUNK fills the hole — and `archetype.zig` has NO release path: no `chunks.pop`, no `swapRemove`, no `entity_count == 0` test, no shrink. So the count follows the CUMULATIVE number of adds and never the live population. Measured by `bench/ecs_hybrid_crossover.zig` at `payload=64B`, fraction 1.0, churn 60/carrier/s: **128 chunks at the first tick, 8200 within the window**, for 20 000 entities over 8 archetypes — 2.4 entities per chunk where the payload allows ~156 — after which `jobs.Scheduler.dispatchBatch` returns `error.TooManyChunks` at its `workers × 8192` capacity. **The consequence is a HARD dispatch failure, not slowness**, and the population that reaches it is any durable churning load — precisely the load `@storage(.sparse)` exists to serve, whose range count is CONSTANT in the same report row. **Invisible to C0.1, which never churns.** NOT fix-as-you-go and the reason is structural, not convenience: the remedy is inter-chunk compaction or a partial-chunk free list, and **moving an entity between chunks invalidates the `chunk_ptr` of every live `ComponentRef`** — the type M1.B/G5 built, whose table arm deliberately holds a chunk pointer — so the fix touches a contract this milestone just froze, and it interacts with `chunkAt(i)`'s stability during a dispatch. That is a milestone, not a commit. Owner: the chunk-lifecycle owner; Guy carries the corpus side. **THE BLOCKER THIS ENTRY RECORDS NO LONGER EXISTS, measured at M1.D/S5/G6**: `M1.D.21` removed `chunk_ptr` in S2/G1 of that same milestone, one session BEFORE this sentence was written, so `ComponentRef` is `{entity, component_id, mutable}` and re-resolves at every access. Second deferral condition satisfied by work from elsewhere, after `M1.D.12`. And the half of the remedy that is REUSE — not compaction — landed at S5/G7 as `Archetype.first_partial`: it moves NO entity, because `removeSwap` leaves a dense prefix and free space at the tail, so its invalidation set is empty. Its benefit is deliberately UNMEASURED and no figure is offered, the instrument being absent (`DynamicQuery` exposes neither `chunkAt` nor `chunkCount`). - **`M1.D.14` gets its SEVENTH and EIGHTH measurements, and its first treatment (M1.B/G11)**. That plan row already carries this debt by name — *"le job `bench-ecs-smoke (windows-2025)` a une queue de durée qui franchit son budget `timeout-minutes: 10`"* — with six measurements at near-constant code (5m08, 6m52, 8m19, 9m21, 9m28, 9m38), factor 1.9, two cancellations and two green re-runs at the SAME SHA, and it names raising the budget as one of two options while taking neither. **M1.B adds 9m37 (passed, 23 seconds of headroom) and 11m28 (cancelled) and TAKES that option**: 10 → 20 minutes. M1.B also made the job heavier, measured rather than assumed — the step runs `zig build bench-ecs`, whose run step depends on the install step, so it compiles EVERY installed artifact, verified by deleting `zig-out/bin/` and watching `ecs-hybrid-crossover-bench` reappear beside `ecs-benchmark`. What the raise does NOT remove is the runner's intrinsic variance, which `M1.D.14`'s own analysis already establishes as the cause, nor the standing question of whether the Windows bench belongs in the PR matrix. *An earlier draft of this entry opened a parallel record under a new number; a debt treated under any name but its own stays open in the document that carries it.* **M1.D/S5 takes the option a second time, 20 → 30 minutes**: the Ubuntu job took 19m46s from an empty cache and 14m19s restored from its own lineage, its crossover sweep being 12 to 14 minutes of run time whatever the cache, and a cold Ubuntu run at `b71017ef` (2026-09-21) was cancelled at the 20-minute budget. -- **A CELL OF THE CI MATRIX HANGS, TEN TIMES MEASURED, AND THE CLASS HAS NO HOME IN THE CORPUS (opened at M1.B/G11, needs a number)**. Distinct from `M1.D.14`, which carries the DURATION of the bench job: this is the PENDING of a matrix cell that gates merges. **Cell:** `build-and-test (windows-2025, ReleaseSafe)`, both precisions. **Signature:** `error: test runner failed to respond for ~1m`, with **zero** occurrences of the sibling class `failed without output` — the two have never been co-present. **Count: TEN**, all on that cell, every one exonerated by a green re-run at the SAME SHA (three recorded before M1.B, two at M1.B/G10-G11, three at M1.B/P3-P4, one at M1.E/G12 on `6a2bb8e`, one at M1.E on `387ab97`). **The ninth, at M1.E/G12 on `6a2bb8e`, is the most informative the class has produced and it is the LARGEST BY AN ORDER**: `2164/2196 tests passed (32 skipped)` against a declared windows floor of 2250 gives **54 TESTS LOST**, where the previous counts were 1, 5, 6, 8 and 14. It ran 38.1 min against a 55-minute budget, so it is NOT the sibling timeout recorded below it — that one has every step green and no lost test — and the log carries ZERO `error: '…' failed:` lines, so no assertion fired. Fifty-four tests is a whole step of substance rather than a straggler, which narrows what can be hanging: the class is not a slow tail on a small step. **THE THIRTEENTH, at M1.A on `565012c`, hangs TWICE IN ONE RUN and the new discriminant reads BOTH**: signature present twice, sibling absent, zero assertions, `2253/2285 tests passed (32 skipped)` against a declared floor of 2288 — **THREE tests lost across two hung steps** — 52.6 minutes against 55 with conclusion `failure`. The two `failed command:` lines name `fecde19354ea9ccbd9ecb5d20cdb00ac` and `2fe9c3b586059afa4881744abc5715ef`: **two different executables in ONE build**, which is the within-run twin of the within-SHA comparison the twelfth produced. Four distinct identities are now recorded across three attempts and no two agree. **At M1.D/S5 on `db275303`, ONE identity hung in TWO cells of one attempt**: `06b3583a…` in `windows-2025 / ReleaseSafe` f64 (seed `0x21372d02`, its only hang, five tests lost) and f32 (seed `0x55397372`, beside `9fc23c50…` and `110fa824…`, eight lost). The two cells build that executable identically — thirteen test-executable hashes appear in both logs — so it is the same binary hanging twice. Both cells passed at the same sha on attempt 2. The series of losses is 1, 5, 6, 8, 14, 54, 2, 1, 3. **THE TWELFTH, at M1.A on `e054b26`, ties the smallest loss and adds a collateral the class did not carry**: `2255/2287 tests passed (32 skipped)` against a declared floor of 2288 gives **ONE test lost**, signature present once, sibling class absent, zero `error: '…' failed:` lines, and **53.0 minutes against a 55-minute budget with conclusion `failure` and not `cancelled`** — which excludes `M1.D.27` on both of its discriminants at once rather than on duration alone. The series of losses is now 1, 5, 6, 8, 14, 54, 2, 1. **The collateral is that two debts met on one job**: the run carries `Zig cache is 11222302720 bytes, over the 10737418240 cap — SKIPPING the final save`, which is `M1.D.10`'s mechanism, and a skipped final save leaves the NEXT run on that cell colder, which is `M1.D.27`'s trigger. Neither debt is new; their interaction is recorded nowhere else. **IT FAILED ITS FIRST SAME-SHA RE-RUN, and that re-run produced a THIRD DISCRIMINANT this entry records as impossible.** Attempt 2: signature once, sibling absent, zero assertions, `2256/2287 (31 skipped)` against 2288 — **one test lost again**, where every previous pair of failures at one SHA had lost DIFFERENT counts, which is the shape an implicated test would produce. Refuted by measurement: this entry states that *"naming the step from the log does not work"*, which is true of the step's SOURCE name and FALSE of its identity — **the `failed command:` line immediately following the `failed to respond` message carries the hung step's build-cache hash**, and the two attempts differ (`01a58047…` against `c810df3f…`). Two DIFFERENT executables hung at one SHA, so no test is implicated, and the equal loss explains itself: both hung steps sit in the contiguous family whose neighbours all report `0 pass, 1 skip (1 total)`, where a hang costs exactly one test whichever member it hits. *The count was never the discriminant; the identity is, and it is one grep away in every log this class has already produced.* **THE ELEVENTH, at M1.A on `3f22cf6`, is the smallest loss the class has produced and the cleanest measurement of it**: `2245/2277 tests passed (32 skipped)` against a declared floor of 2279 gives **2 TESTS LOST**, with the signature present once, the sibling class absent, zero `error: '…' failed:` lines, and 52.7 minutes against a 55-minute budget — so `M1.D.27` is excluded by duration and by conclusion (`fail`, not `cancelled`). The series of losses is now 1, 5, 6, 8, 14, 54 and 2, which continues to refute any single implicated test. Cleared by a re-run at the SAME SHA. **THE TENTH, at M1.E on `387ab97`, is the first to hang TWICE IN ONE RUN**: two `failed to respond` twelve minutes apart (19:49:14 and 20:01:15 UTC) on two DIFFERENT test executables, and `301/304 steps succeeded (2 failed)` accounts for exactly those two. **And the loss stopped following the step count**: `2217/2249 tests passed (32 skipped)` against the same 2250 floor gives **ONE test lost for TWO hung steps**, so at least one of the two cost no test at all — where the counts so far had been 1, 5, 6, 8, 14 and 54, always for a single step. That asymmetry is NOT explained here: the natural reading, a runner that blocks after its last result is already reported, is plausible and unmeasured, and this class has already paid for two hypotheses issued on a plausible reading. What it does strengthen is the `0664f28` discriminant — two different steps, in one run, on a commit whose diff is comments and two Markdown files. It ran 40.2 min, SHORTER than the sibling timeout because it aborted on the hang rather than finishing, carries ZERO `error: '…' failed:`, and exonerated on the FIRST re-run. **AT ONE SHA (`0664f28`) THE CELL FAILED THREE TIMES AND LOST THREE DIFFERENT COUNTS — 14, 1 and 5 tests — hence three different step sets.** That is a discriminant the class did not previously have, and it is the strongest evidence yet that no single test is implicated: a test that hangs deterministically blocks the same step every time and loses the same number. **And the frequency moved**: the five occurrences preceding that SHA each exonerated on the FIRST same-SHA re-run, where this one took two — f64 green on re-run 1, f32 failing again with a third lost count and green only on re-run 2. Two collateral facts from the same investigation: `pre-push` runs `zig build test -Doptimize=ReleaseSafe` (`lefthook.yml:31`), so the failing cell's MODE is green on the dev machine at every push; and pushing over an in-flight run marks that run `failure` with no evidence of its own (`aa7416c`), which is the recorded status-predicate hazard seen from the other side. **Two discriminants, and only one of them works today.** (1) `declared − collected` from the `Build Summary` line `--summary all` already produces: at the M1.B occurrence, `302/304 steps succeeded (1 failed); 2103/2135 tests passed` against a declared windows floor of 2143 gives **8 tests lost**, so the hung step holds eight — a SIZE, computed and not inferred. (2) Naming the step from the log **does not work**: a hung step emits no output at all, so the per-step summary that would name it is exactly what is missing, and `--log-failed` returns the aggregate. Naming it needs either a per-step timeout that identifies its target or a step-by-step run. What DOES survive is a negative discriminant used at G11: a step that printed its own report AND its `failed command:` artifact has COMPLETED, which is how the M1.B/G10 scheduler-dispatch tests were exonerated without a re-run. **The corpus carries no foyer for this**: the signature returns zero occurrences across the corpus (measured), so ten occurrences on a merge-gating cell live only in a succession of briefs. +- **A CELL OF THE CI MATRIX HANGS, TEN TIMES MEASURED, AND THE CLASS HAS NO HOME IN THE CORPUS (opened at M1.B/G11, needs a number)**. Distinct from `M1.D.14`, which carries the DURATION of the bench job: this is the PENDING of a matrix cell that gates merges. **Cell:** `build-and-test (windows-2025, ReleaseSafe)`, both precisions. **Signature:** `error: test runner failed to respond for ~1m`, with **zero** occurrences of the sibling class `failed without output` — the two have never been co-present. **Count: TEN**, all on that cell, every one exonerated by a green re-run at the SAME SHA (three recorded before M1.B, two at M1.B/G10-G11, three at M1.B/P3-P4, one at M1.E/G12 on `6a2bb8e`, one at M1.E on `387ab97`). **The ninth, at M1.E/G12 on `6a2bb8e`, is the most informative the class has produced and it is the LARGEST BY AN ORDER**: `2164/2196 tests passed (32 skipped)` against a declared windows floor of 2250 gives **54 TESTS LOST**, where the previous counts were 1, 5, 6, 8 and 14. It ran 38.1 min against a 55-minute budget, so it is NOT the sibling timeout recorded below it — that one has every step green and no lost test — and the log carries ZERO `error: '…' failed:` lines, so no assertion fired. Fifty-four tests is a whole step of substance rather than a straggler, which narrows what can be hanging: the class is not a slow tail on a small step. **THE THIRTEENTH, at M1.A on `565012c`, hangs TWICE IN ONE RUN and the new discriminant reads BOTH**: signature present twice, sibling absent, zero assertions, `2253/2285 tests passed (32 skipped)` against a declared floor of 2288 — **THREE tests lost across two hung steps** — 52.6 minutes against 55 with conclusion `failure`. The two `failed command:` lines name `fecde19354ea9ccbd9ecb5d20cdb00ac` and `2fe9c3b586059afa4881744abc5715ef`: **two different executables in ONE build**, which is the within-run twin of the within-SHA comparison the twelfth produced. Four distinct identities are now recorded across three attempts and no two agree. **At M1.D/S5 on `db275303`, ONE identity hung in TWO cells of one attempt**: `06b3583a…` in `windows-2025 / ReleaseSafe` f64 (seed `0x21372d02`, its only hang, five tests lost) and f32 (seed `0x55397372`, beside `9fc23c50…` and `110fa824…`, eight lost). The two cells build that executable identically — thirteen test-executable hashes appear in both logs — so it is the same binary hanging twice. Both cells passed at the same sha on attempt 2. At `ae9ee612`, the next cold run, the f64 cell then failed THREE attempts in a row, on four identities none of which recurred (`ad84682e…`, `0a38a40e…`, `48f2ff74…`, `4738b31b…`). The series of losses is 1, 5, 6, 8, 14, 54, 2, 1, 3. **THE TWELFTH, at M1.A on `e054b26`, ties the smallest loss and adds a collateral the class did not carry**: `2255/2287 tests passed (32 skipped)` against a declared floor of 2288 gives **ONE test lost**, signature present once, sibling class absent, zero `error: '…' failed:` lines, and **53.0 minutes against a 55-minute budget with conclusion `failure` and not `cancelled`** — which excludes `M1.D.27` on both of its discriminants at once rather than on duration alone. The series of losses is now 1, 5, 6, 8, 14, 54, 2, 1. **The collateral is that two debts met on one job**: the run carries `Zig cache is 11222302720 bytes, over the 10737418240 cap — SKIPPING the final save`, which is `M1.D.10`'s mechanism, and a skipped final save leaves the NEXT run on that cell colder, which is `M1.D.27`'s trigger. Neither debt is new; their interaction is recorded nowhere else. **IT FAILED ITS FIRST SAME-SHA RE-RUN, and that re-run produced a THIRD DISCRIMINANT this entry records as impossible.** Attempt 2: signature once, sibling absent, zero assertions, `2256/2287 (31 skipped)` against 2288 — **one test lost again**, where every previous pair of failures at one SHA had lost DIFFERENT counts, which is the shape an implicated test would produce. Refuted by measurement: this entry states that *"naming the step from the log does not work"*, which is true of the step's SOURCE name and FALSE of its identity — **the `failed command:` line immediately following the `failed to respond` message carries the hung step's build-cache hash**, and the two attempts differ (`01a58047…` against `c810df3f…`). Two DIFFERENT executables hung at one SHA, so no test is implicated, and the equal loss explains itself: both hung steps sit in the contiguous family whose neighbours all report `0 pass, 1 skip (1 total)`, where a hang costs exactly one test whichever member it hits. *The count was never the discriminant; the identity is, and it is one grep away in every log this class has already produced.* **THE ELEVENTH, at M1.A on `3f22cf6`, is the smallest loss the class has produced and the cleanest measurement of it**: `2245/2277 tests passed (32 skipped)` against a declared floor of 2279 gives **2 TESTS LOST**, with the signature present once, the sibling class absent, zero `error: '…' failed:` lines, and 52.7 minutes against a 55-minute budget — so `M1.D.27` is excluded by duration and by conclusion (`fail`, not `cancelled`). The series of losses is now 1, 5, 6, 8, 14, 54 and 2, which continues to refute any single implicated test. Cleared by a re-run at the SAME SHA. **THE TENTH, at M1.E on `387ab97`, is the first to hang TWICE IN ONE RUN**: two `failed to respond` twelve minutes apart (19:49:14 and 20:01:15 UTC) on two DIFFERENT test executables, and `301/304 steps succeeded (2 failed)` accounts for exactly those two. **And the loss stopped following the step count**: `2217/2249 tests passed (32 skipped)` against the same 2250 floor gives **ONE test lost for TWO hung steps**, so at least one of the two cost no test at all — where the counts so far had been 1, 5, 6, 8, 14 and 54, always for a single step. That asymmetry is NOT explained here: the natural reading, a runner that blocks after its last result is already reported, is plausible and unmeasured, and this class has already paid for two hypotheses issued on a plausible reading. What it does strengthen is the `0664f28` discriminant — two different steps, in one run, on a commit whose diff is comments and two Markdown files. It ran 40.2 min, SHORTER than the sibling timeout because it aborted on the hang rather than finishing, carries ZERO `error: '…' failed:`, and exonerated on the FIRST re-run. **AT ONE SHA (`0664f28`) THE CELL FAILED THREE TIMES AND LOST THREE DIFFERENT COUNTS — 14, 1 and 5 tests — hence three different step sets.** That is a discriminant the class did not previously have, and it is the strongest evidence yet that no single test is implicated: a test that hangs deterministically blocks the same step every time and loses the same number. **And the frequency moved**: the five occurrences preceding that SHA each exonerated on the FIRST same-SHA re-run, where this one took two — f64 green on re-run 1, f32 failing again with a third lost count and green only on re-run 2. Two collateral facts from the same investigation: `pre-push` runs `zig build test -Doptimize=ReleaseSafe` (`lefthook.yml:31`), so the failing cell's MODE is green on the dev machine at every push; and pushing over an in-flight run marks that run `failure` with no evidence of its own (`aa7416c`), which is the recorded status-predicate hazard seen from the other side. **Two discriminants, and only one of them works today.** (1) `declared − collected` from the `Build Summary` line `--summary all` already produces: at the M1.B occurrence, `302/304 steps succeeded (1 failed); 2103/2135 tests passed` against a declared windows floor of 2143 gives **8 tests lost**, so the hung step holds eight — a SIZE, computed and not inferred. (2) Naming the step from the log **does not work**: a hung step emits no output at all, so the per-step summary that would name it is exactly what is missing, and `--log-failed` returns the aggregate. Naming it needs either a per-step timeout that identifies its target or a step-by-step run. What DOES survive is a negative discriminant used at G11: a step that printed its own report AND its `failed command:` artifact has COMPLETED, which is how the M1.B/G10 scheduler-dispatch tests were exonerated without a re-run. **The corpus carries no foyer for this**: the signature returns zero occurrences across the corpus (measured), so ten occurrences on a merge-gating cell live only in a succession of briefs. - **`win32_thread_safety_test` TIMES OUT on `windows-2025 / Debug`, and it is a DISTINCT class from the hang above (second occurrence at M1.A, needs a number)**. Not `M1.D.19`: the hang signature is ABSENT, no test is lost (collected equals the floor exactly), and ONE assertion genuinely fires — `error.Win32ThreadSafetyTimeout` at `tests/platform/win32_thread_safety_test.zig:99`, a wall-clock bound on three threads doing 100 `createWindow`/`destroyWindow` pairs each against a 30 s budget. The job takes ~10 minutes, not the ~50 of a hang. **The decisive discriminant is stronger than the sibling-cell one and is general to every flake class: A DIFF WITH NO ZIG IN IT.** The second occurrence landed on a commit whose diff is two Markdown files and seven lines, on top of a commit where that same cell had passed — so no code changed between the green and the red, and the commit cannot be the cause. The sibling `Debug / f64` cell passing at the same SHA corroborates it and is weaker: Win32 windowing has nothing to do with the physics scalar, so that axis could not discriminate a real fault either. **It is NOT the class M1.1.9 retired from `crash_recovery.zig`**, and the difference is worth keeping: those assertions ran AFTER the blocking receive returned and therefore guarded nothing, while this loop BOUNDS a wait and abandons it — a correctly shaped hang guard. What fires is its BUDGET on a runner the suite itself saturates. Removing it would remove a real guard; raising it weakens the bound it exists to give. Owner: unassigned. diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index 57f024d5..c4f2f34a 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -6325,6 +6325,32 @@ the cells build 159 of 162 steps; the bench smokes run 18 to 21 minutes and pass under 30; no save by our steps, and `setup-zig` hits its three tarballs, so the repository still holds exactly 3 entries, 198 183 034 bytes, afterwards. +#### Verified on the run + +CI run `36030674188` and Bench run `36030674209` at `ae9ee612`, the CI run re-run +twice for its one red cell. `changes` answered `code=true` in both. + +- **The bench smokes passed cold under the new budget**: 19m26s on `ubuntu-24.04`, + 17m43s on `windows-2025`, both restores missing, both saves skipped. **The + prediction's floor is refuted on windows**, 17m43s against 18. Ubuntu would have + passed under 20 as well, by 34 seconds: this run shows where a cold bench sits and + does not exercise the old budget's edge. +- Every Release restore missed, every cell built 159 of 162 steps, every save was + skipped; the Release cells took 38.3 to 57.0 minutes. +- **The repository still holds exactly 3 entries, 198 183 034 bytes**, the three + tarballs, as predicted. +- **`windows-2025 / ReleaseSafe / f64` failed all three attempts on `M1.D.19`**, each on + new identities: attempt 1 twice (`ad84682e…`, `0a38a40e…`, seed `0xdf3c4a57`), 2607 + collected against 2622, 57.0 minutes; attempt 2 once (`48f2ff74…`, `0x6555f342`), + 2618, 46.0 minutes; attempt 3 once (`4738b31b…`, `0xc155bdb2`), 2619, 42.2 minutes. + No failed assertion and no `failed without output` in any. Its f32 twin passed at + attempt 1 in 52.1 minutes. The diff carries no Zig. Across the two cold runs of + this gate and the last, the windows ReleaseSafe cells failed five executions of + eight. Re-running stopped at three attempts: the class is not this gate's, and + `ci-gate` at `ae9ee612` is red on it. + +Stopped here, as ruled: after the budget. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree From e526c7266bfc9a1d324ed23cbf6ca5baaf816500 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Fri, 25 Sep 2026 00:22:29 +0200 Subject: [PATCH 059/141] fix(ci): week, size guard and one cache layer at every site engine-platform.md section 8 (sha256 f56e1c51) at every cache site. The bench key carries the ISO week on every rung and its save, like the runtime smoke's, sits behind the all-or-nothing size guard the matrix already had, the cap declared once per workflow. The vertical slice is a Debug job and stays cold. The nightly fuzz passes use-cache: false; it keeps no corpus. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/bench.yml | 32 +++++++++++++++++++++--- .github/workflows/ci.yml | 39 ++++++++++++++++-------------- .github/workflows/nightly-fuzz.yml | 2 ++ 3 files changed, 51 insertions(+), 22 deletions(-) diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index ad0dffde..e38214a6 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -17,6 +17,7 @@ permissions: env: ZIG_VERSION: "0.16.0" ZIG_CPU: "baseline" + ZIG_CACHE_CAP: "10737418240" jobs: changes: @@ -28,7 +29,13 @@ jobs: outputs: code: ${{ steps.detect.outputs.code }} reason: ${{ steps.detect.outputs.reason }} + week: ${{ steps.week.outputs.week }} steps: + - name: Compute the lineage window + id: week + shell: bash + run: echo "week=$(date -u +%G-%V)" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@v6 with: fetch-depth: 0 @@ -75,9 +82,9 @@ jobs: uses: actions/cache/restore@v5 with: path: .zig-cache - key: zig-v2-${{ github.job }}-${{ matrix.os }}-ReleaseSafe-${{ env.ZIG_CPU }}-${{ env.ZIG_VERSION }}-${{ hashFiles('build.zig.zon') }}-${{ github.sha }} + key: zig-v2-${{ needs.changes.outputs.week }}-${{ github.job }}-${{ matrix.os }}-ReleaseSafe-${{ env.ZIG_CPU }}-${{ env.ZIG_VERSION }}-${{ hashFiles('build.zig.zon') }}-${{ github.sha }} restore-keys: | - zig-v2-${{ github.job }}-${{ matrix.os }}-ReleaseSafe-${{ env.ZIG_CPU }}-${{ env.ZIG_VERSION }}-${{ hashFiles('build.zig.zon') }}- + zig-v2-${{ needs.changes.outputs.week }}-${{ github.job }}-${{ matrix.os }}-ReleaseSafe-${{ env.ZIG_CPU }}-${{ env.ZIG_VERSION }}-${{ hashFiles('build.zig.zon') }}- - name: zig build bench-ecs -- --smoke run: zig build bench-ecs -Doptimize=ReleaseSafe -Dcpu=${{ env.ZIG_CPU }} -- --smoke @@ -88,12 +95,29 @@ jobs: - name: zig build bench-ecs-hybrid (crossover sweep) run: zig build bench-ecs-hybrid -Doptimize=ReleaseSafe -Dcpu=${{ env.ZIG_CPU }} + - name: Measure the Zig cache before the save + if: always() + id: cache-size + shell: bash + run: | + set -euo pipefail + limit="$ZIG_CACHE_CAP" + bytes=$(du -sk .zig-cache 2>/dev/null | cut -f1 || echo 0) + bytes=$((bytes * 1024)) + echo "zig cache: $bytes bytes (cap $limit)" + if [ "$bytes" -gt "$limit" ]; then + echo "::warning::Zig cache is $bytes bytes, over the $limit cap — SKIPPING the save." + echo "save=false" >> "$GITHUB_OUTPUT" + else + echo "save=true" >> "$GITHUB_OUTPUT" + fi + - name: Save Zig cache - if: always() && github.event_name == 'push' + if: always() && github.event_name == 'push' && steps.cache-size.outputs.save == 'true' uses: actions/cache/save@v5 with: path: .zig-cache - key: zig-v2-${{ github.job }}-${{ matrix.os }}-ReleaseSafe-${{ env.ZIG_CPU }}-${{ env.ZIG_VERSION }}-${{ hashFiles('build.zig.zon') }}-${{ github.sha }} + key: zig-v2-${{ needs.changes.outputs.week }}-${{ github.job }}-${{ matrix.os }}-ReleaseSafe-${{ env.ZIG_CPU }}-${{ env.ZIG_VERSION }}-${{ hashFiles('build.zig.zon') }}-${{ github.sha }} - name: Archive the crossover measurement uses: actions/upload-artifact@v6 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 033fad1f..fd758727 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,7 @@ permissions: env: ZIG_VERSION: "0.16.0" ZIG_CPU: "baseline" + ZIG_CACHE_CAP: "10737418240" jobs: changes: @@ -221,7 +222,7 @@ jobs: shell: bash run: | set -euo pipefail - limit=10737418240 + limit="$ZIG_CACHE_CAP" bytes=$(du -sk .zig-cache 2>/dev/null | cut -f1 || echo 0) bytes=$((bytes * 1024)) echo "zig cache (final): $bytes bytes (cap $limit)" @@ -334,8 +335,25 @@ jobs: set -euo pipefail zig build test-runtime-env -Dcpu=${{ env.ZIG_CPU }} --summary all + - name: Measure the Zig cache before the save + if: always() + id: cache-size + shell: bash + run: | + set -euo pipefail + limit="$ZIG_CACHE_CAP" + bytes=$(du -sk .zig-cache 2>/dev/null | cut -f1 || echo 0) + bytes=$((bytes * 1024)) + echo "zig cache: $bytes bytes (cap $limit)" + if [ "$bytes" -gt "$limit" ]; then + echo "::warning::Zig cache is $bytes bytes, over the $limit cap — SKIPPING the save." + echo "save=false" >> "$GITHUB_OUTPUT" + else + echo "save=true" >> "$GITHUB_OUTPUT" + fi + - name: Save Zig cache - if: always() && github.event_name == 'push' + if: always() && github.event_name == 'push' && steps.cache-size.outputs.save == 'true' uses: actions/cache/save@v5 with: path: .zig-cache @@ -362,17 +380,9 @@ jobs: - uses: weldengine/setup-zig@v0.1.0 with: version: ${{ env.ZIG_VERSION }} - # use-cache: false — the action caches .zig-cache itself; this workflow owns it + # use-cache: false — the action caches .zig-cache itself; a Debug job stays cold use-cache: false - - name: Restore Zig cache (Debug) - uses: actions/cache/restore@v5 - with: - path: .zig-cache - key: zig-v2-${{ needs.changes.outputs.week }}-${{ github.job }}-ubuntu-24.04-Debug-${{ env.ZIG_CPU }}-${{ env.ZIG_VERSION }}-${{ hashFiles('build.zig.zon') }}-${{ github.sha }} - restore-keys: | - zig-v2-${{ needs.changes.outputs.week }}-${{ github.job }}-ubuntu-24.04-Debug-${{ env.ZIG_CPU }}-${{ env.ZIG_VERSION }}-${{ hashFiles('build.zig.zon') }}- - - name: Install Mesa Vulkan (lavapipe) + validation layers + weston run: | set -euo pipefail @@ -468,13 +478,6 @@ jobs: grep -E "00067|pSignalSemaphores" vkblit.log || echo " not observed on lavapipe (expected — hardware-only)" echo "=== end E6 blit validation ===" - - name: Save Zig cache (Debug) - if: always() && github.event_name == 'push' - uses: actions/cache/save@v5 - with: - path: .zig-cache - key: zig-v2-${{ needs.changes.outputs.week }}-${{ github.job }}-ubuntu-24.04-Debug-${{ env.ZIG_CPU }}-${{ env.ZIG_VERSION }}-${{ hashFiles('build.zig.zon') }}-${{ github.sha }} - - name: Upload slice capture if: always() uses: actions/upload-artifact@v6 diff --git a/.github/workflows/nightly-fuzz.yml b/.github/workflows/nightly-fuzz.yml index 710d63fc..e4beccb0 100644 --- a/.github/workflows/nightly-fuzz.yml +++ b/.github/workflows/nightly-fuzz.yml @@ -39,6 +39,8 @@ jobs: - uses: weldengine/setup-zig@v0.1.0 with: version: 0.16.0 + # use-cache: false — the action would cache .zig-cache itself; saves run on push only + use-cache: false - name: zig build (ReleaseSafe) run: zig build -Doptimize=ReleaseSafe From cb8265fc3288906be9e296fe79007cb05e4a08ac Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Fri, 25 Sep 2026 00:22:32 +0200 Subject: [PATCH 060/141] docs(brief): record the cache rules applied at every site Co-Authored-By: Claude Opus 5.5 --- briefs/m1.d-phase-1-debt.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index c4f2f34a..abc3dc26 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -6351,6 +6351,36 @@ twice for its one red cell. `changes` answered `code=true` in both. Stopped here, as ruled: after the budget. +### S5/G9 sexies — §8 applied at every cache site + +Guy corrected the four corpus lines (`engine-phase-1-plan.md` sha256 `2b6ea55c…`, +`engine-development-workflow.md` `993e1956…`, `engine-platform.md` `f56e1c51…`) and +ruled the three gaps to §8, which now reads "every `Debug` job — matrix cell or smoke +— stays cold". + +- **`bench.yml`**: its `changes` job computes the ISO week, as `ci.yml`'s does, and + the week heads the key, the restore rung and the save key; the save is behind the + all-or-nothing size guard. +- **`runtime-smoke-test`**: its save is behind the same guard. +- **`vertical-slice-smoke`**: a `Debug` job, so it neither restores nor saves. +- **`nightly-fuzz.yml`**: `use-cache: false`. **The fuzz keeps no corpus**: it draws + from `DefaultPrng.init(0xCAFEBABE)` and persists no file, so nothing of it lives in + any cache but `setup-zig`'s toolchain tarball. +- The 10 GiB cap is declared once per workflow, `ZIG_CACHE_CAP`, read by both guards + of `ci.yml` and by the bench's. + +Enumerated after the change by parsing the three workflows: seven `setup-zig` sites, +all `use-cache: false`; three restores, all Release, all carrying the week on every +rung; three saves, all on `push` behind a guard; no cache step in a `Debug` job. + +**Predicted before the run**: `code=true` in both workflows; the bench restores under +`zig-v2-2026-39-bench-ecs-smoke-…` and misses, its guard prints the size under the +cap, its save is skipped, 17 to 21 minutes; the runtime smoke likewise; the vertical +slice has no cache step and passes in 1.5 to 2.5 minutes; the repository holds +exactly 3 entries, 198 183 034 bytes, afterwards. `nightly-fuzz.yml` is not exercised +by a pull-request run — schedule and dispatch run from `main` — so it is verified by +reading until the first night after the merge. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree From 719f1ce1f4c6f887c396aa022685700a6a9171d2 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Fri, 25 Sep 2026 01:24:33 +0200 Subject: [PATCH 061/141] docs(brief): record M1.D.19 measured, the executables and the tail Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- briefs/m1.d-phase-1-debt.md | 98 +++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index cacdff68..3a018e4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,7 +121,7 @@ commit, so a milestone still in review has no row here. - **M1.D.17 (this file's sense) — the `.sav` schema check is blind to a size-preserving field addition (opened at M1.1.15.2).** `loader.buildSchemaRemap` compares SIZE and ALIGNMENT, and `RigidBody.authority` landed in existing trailing padding — 32 bytes before and after. It bites nothing today because no image carries a `RigidBody` (measured: zero `.etch` name the type, nothing registers it, no cook reaches it) and `.solver` is the zero value, pinned by a test. It will bite at the first image that does, and nothing will announce it. Owner: `engine-scene-serialization.md`, not a physics milestone. - **M1.D.18 — a table component under sustained churn grows its chunk count until the DISPATCH FAILS (opened at M1.B/G11, measured, mechanism named)**. `Archetype.removeSwap` compacts INSIDE one chunk only — `chunk_idx` is fixed and the last slot OF THAT CHUNK fills the hole — and `archetype.zig` has NO release path: no `chunks.pop`, no `swapRemove`, no `entity_count == 0` test, no shrink. So the count follows the CUMULATIVE number of adds and never the live population. Measured by `bench/ecs_hybrid_crossover.zig` at `payload=64B`, fraction 1.0, churn 60/carrier/s: **128 chunks at the first tick, 8200 within the window**, for 20 000 entities over 8 archetypes — 2.4 entities per chunk where the payload allows ~156 — after which `jobs.Scheduler.dispatchBatch` returns `error.TooManyChunks` at its `workers × 8192` capacity. **The consequence is a HARD dispatch failure, not slowness**, and the population that reaches it is any durable churning load — precisely the load `@storage(.sparse)` exists to serve, whose range count is CONSTANT in the same report row. **Invisible to C0.1, which never churns.** NOT fix-as-you-go and the reason is structural, not convenience: the remedy is inter-chunk compaction or a partial-chunk free list, and **moving an entity between chunks invalidates the `chunk_ptr` of every live `ComponentRef`** — the type M1.B/G5 built, whose table arm deliberately holds a chunk pointer — so the fix touches a contract this milestone just froze, and it interacts with `chunkAt(i)`'s stability during a dispatch. That is a milestone, not a commit. Owner: the chunk-lifecycle owner; Guy carries the corpus side. **THE BLOCKER THIS ENTRY RECORDS NO LONGER EXISTS, measured at M1.D/S5/G6**: `M1.D.21` removed `chunk_ptr` in S2/G1 of that same milestone, one session BEFORE this sentence was written, so `ComponentRef` is `{entity, component_id, mutable}` and re-resolves at every access. Second deferral condition satisfied by work from elsewhere, after `M1.D.12`. And the half of the remedy that is REUSE — not compaction — landed at S5/G7 as `Archetype.first_partial`: it moves NO entity, because `removeSwap` leaves a dense prefix and free space at the tail, so its invalidation set is empty. Its benefit is deliberately UNMEASURED and no figure is offered, the instrument being absent (`DynamicQuery` exposes neither `chunkAt` nor `chunkCount`). - **`M1.D.14` gets its SEVENTH and EIGHTH measurements, and its first treatment (M1.B/G11)**. That plan row already carries this debt by name — *"le job `bench-ecs-smoke (windows-2025)` a une queue de durée qui franchit son budget `timeout-minutes: 10`"* — with six measurements at near-constant code (5m08, 6m52, 8m19, 9m21, 9m28, 9m38), factor 1.9, two cancellations and two green re-runs at the SAME SHA, and it names raising the budget as one of two options while taking neither. **M1.B adds 9m37 (passed, 23 seconds of headroom) and 11m28 (cancelled) and TAKES that option**: 10 → 20 minutes. M1.B also made the job heavier, measured rather than assumed — the step runs `zig build bench-ecs`, whose run step depends on the install step, so it compiles EVERY installed artifact, verified by deleting `zig-out/bin/` and watching `ecs-hybrid-crossover-bench` reappear beside `ecs-benchmark`. What the raise does NOT remove is the runner's intrinsic variance, which `M1.D.14`'s own analysis already establishes as the cause, nor the standing question of whether the Windows bench belongs in the PR matrix. *An earlier draft of this entry opened a parallel record under a new number; a debt treated under any name but its own stays open in the document that carries it.* **M1.D/S5 takes the option a second time, 20 → 30 minutes**: the Ubuntu job took 19m46s from an empty cache and 14m19s restored from its own lineage, its crossover sweep being 12 to 14 minutes of run time whatever the cache, and a cold Ubuntu run at `b71017ef` (2026-09-21) was cancelled at the 20-minute budget. -- **A CELL OF THE CI MATRIX HANGS, TEN TIMES MEASURED, AND THE CLASS HAS NO HOME IN THE CORPUS (opened at M1.B/G11, needs a number)**. Distinct from `M1.D.14`, which carries the DURATION of the bench job: this is the PENDING of a matrix cell that gates merges. **Cell:** `build-and-test (windows-2025, ReleaseSafe)`, both precisions. **Signature:** `error: test runner failed to respond for ~1m`, with **zero** occurrences of the sibling class `failed without output` — the two have never been co-present. **Count: TEN**, all on that cell, every one exonerated by a green re-run at the SAME SHA (three recorded before M1.B, two at M1.B/G10-G11, three at M1.B/P3-P4, one at M1.E/G12 on `6a2bb8e`, one at M1.E on `387ab97`). **The ninth, at M1.E/G12 on `6a2bb8e`, is the most informative the class has produced and it is the LARGEST BY AN ORDER**: `2164/2196 tests passed (32 skipped)` against a declared windows floor of 2250 gives **54 TESTS LOST**, where the previous counts were 1, 5, 6, 8 and 14. It ran 38.1 min against a 55-minute budget, so it is NOT the sibling timeout recorded below it — that one has every step green and no lost test — and the log carries ZERO `error: '…' failed:` lines, so no assertion fired. Fifty-four tests is a whole step of substance rather than a straggler, which narrows what can be hanging: the class is not a slow tail on a small step. **THE THIRTEENTH, at M1.A on `565012c`, hangs TWICE IN ONE RUN and the new discriminant reads BOTH**: signature present twice, sibling absent, zero assertions, `2253/2285 tests passed (32 skipped)` against a declared floor of 2288 — **THREE tests lost across two hung steps** — 52.6 minutes against 55 with conclusion `failure`. The two `failed command:` lines name `fecde19354ea9ccbd9ecb5d20cdb00ac` and `2fe9c3b586059afa4881744abc5715ef`: **two different executables in ONE build**, which is the within-run twin of the within-SHA comparison the twelfth produced. Four distinct identities are now recorded across three attempts and no two agree. **At M1.D/S5 on `db275303`, ONE identity hung in TWO cells of one attempt**: `06b3583a…` in `windows-2025 / ReleaseSafe` f64 (seed `0x21372d02`, its only hang, five tests lost) and f32 (seed `0x55397372`, beside `9fc23c50…` and `110fa824…`, eight lost). The two cells build that executable identically — thirteen test-executable hashes appear in both logs — so it is the same binary hanging twice. Both cells passed at the same sha on attempt 2. At `ae9ee612`, the next cold run, the f64 cell then failed THREE attempts in a row, on four identities none of which recurred (`ad84682e…`, `0a38a40e…`, `48f2ff74…`, `4738b31b…`). The series of losses is 1, 5, 6, 8, 14, 54, 2, 1, 3. **THE TWELFTH, at M1.A on `e054b26`, ties the smallest loss and adds a collateral the class did not carry**: `2255/2287 tests passed (32 skipped)` against a declared floor of 2288 gives **ONE test lost**, signature present once, sibling class absent, zero `error: '…' failed:` lines, and **53.0 minutes against a 55-minute budget with conclusion `failure` and not `cancelled`** — which excludes `M1.D.27` on both of its discriminants at once rather than on duration alone. The series of losses is now 1, 5, 6, 8, 14, 54, 2, 1. **The collateral is that two debts met on one job**: the run carries `Zig cache is 11222302720 bytes, over the 10737418240 cap — SKIPPING the final save`, which is `M1.D.10`'s mechanism, and a skipped final save leaves the NEXT run on that cell colder, which is `M1.D.27`'s trigger. Neither debt is new; their interaction is recorded nowhere else. **IT FAILED ITS FIRST SAME-SHA RE-RUN, and that re-run produced a THIRD DISCRIMINANT this entry records as impossible.** Attempt 2: signature once, sibling absent, zero assertions, `2256/2287 (31 skipped)` against 2288 — **one test lost again**, where every previous pair of failures at one SHA had lost DIFFERENT counts, which is the shape an implicated test would produce. Refuted by measurement: this entry states that *"naming the step from the log does not work"*, which is true of the step's SOURCE name and FALSE of its identity — **the `failed command:` line immediately following the `failed to respond` message carries the hung step's build-cache hash**, and the two attempts differ (`01a58047…` against `c810df3f…`). Two DIFFERENT executables hung at one SHA, so no test is implicated, and the equal loss explains itself: both hung steps sit in the contiguous family whose neighbours all report `0 pass, 1 skip (1 total)`, where a hang costs exactly one test whichever member it hits. *The count was never the discriminant; the identity is, and it is one grep away in every log this class has already produced.* **THE ELEVENTH, at M1.A on `3f22cf6`, is the smallest loss the class has produced and the cleanest measurement of it**: `2245/2277 tests passed (32 skipped)` against a declared floor of 2279 gives **2 TESTS LOST**, with the signature present once, the sibling class absent, zero `error: '…' failed:` lines, and 52.7 minutes against a 55-minute budget — so `M1.D.27` is excluded by duration and by conclusion (`fail`, not `cancelled`). The series of losses is now 1, 5, 6, 8, 14, 54 and 2, which continues to refute any single implicated test. Cleared by a re-run at the SAME SHA. **THE TENTH, at M1.E on `387ab97`, is the first to hang TWICE IN ONE RUN**: two `failed to respond` twelve minutes apart (19:49:14 and 20:01:15 UTC) on two DIFFERENT test executables, and `301/304 steps succeeded (2 failed)` accounts for exactly those two. **And the loss stopped following the step count**: `2217/2249 tests passed (32 skipped)` against the same 2250 floor gives **ONE test lost for TWO hung steps**, so at least one of the two cost no test at all — where the counts so far had been 1, 5, 6, 8, 14 and 54, always for a single step. That asymmetry is NOT explained here: the natural reading, a runner that blocks after its last result is already reported, is plausible and unmeasured, and this class has already paid for two hypotheses issued on a plausible reading. What it does strengthen is the `0664f28` discriminant — two different steps, in one run, on a commit whose diff is comments and two Markdown files. It ran 40.2 min, SHORTER than the sibling timeout because it aborted on the hang rather than finishing, carries ZERO `error: '…' failed:`, and exonerated on the FIRST re-run. **AT ONE SHA (`0664f28`) THE CELL FAILED THREE TIMES AND LOST THREE DIFFERENT COUNTS — 14, 1 and 5 tests — hence three different step sets.** That is a discriminant the class did not previously have, and it is the strongest evidence yet that no single test is implicated: a test that hangs deterministically blocks the same step every time and loses the same number. **And the frequency moved**: the five occurrences preceding that SHA each exonerated on the FIRST same-SHA re-run, where this one took two — f64 green on re-run 1, f32 failing again with a third lost count and green only on re-run 2. Two collateral facts from the same investigation: `pre-push` runs `zig build test -Doptimize=ReleaseSafe` (`lefthook.yml:31`), so the failing cell's MODE is green on the dev machine at every push; and pushing over an in-flight run marks that run `failure` with no evidence of its own (`aa7416c`), which is the recorded status-predicate hazard seen from the other side. **Two discriminants, and only one of them works today.** (1) `declared − collected` from the `Build Summary` line `--summary all` already produces: at the M1.B occurrence, `302/304 steps succeeded (1 failed); 2103/2135 tests passed` against a declared windows floor of 2143 gives **8 tests lost**, so the hung step holds eight — a SIZE, computed and not inferred. (2) Naming the step from the log **does not work**: a hung step emits no output at all, so the per-step summary that would name it is exactly what is missing, and `--log-failed` returns the aggregate. Naming it needs either a per-step timeout that identifies its target or a step-by-step run. What DOES survive is a negative discriminant used at G11: a step that printed its own report AND its `failed command:` artifact has COMPLETED, which is how the M1.B/G10 scheduler-dispatch tests were exonerated without a re-run. **The corpus carries no foyer for this**: the signature returns zero occurrences across the corpus (measured), so ten occurrences on a merge-gating cell live only in a succession of briefs. +- **A CELL OF THE CI MATRIX HANGS, TEN TIMES MEASURED, AND THE CLASS HAS NO HOME IN THE CORPUS (opened at M1.B/G11, needs a number)**. Distinct from `M1.D.14`, which carries the DURATION of the bench job: this is the PENDING of a matrix cell that gates merges. **Cell:** `build-and-test (windows-2025, ReleaseSafe)`, both precisions. **Signature:** `error: test runner failed to respond for ~1m`, with **zero** occurrences of the sibling class `failed without output` — the two have never been co-present. **Count: TEN**, all on that cell, every one exonerated by a green re-run at the SAME SHA (three recorded before M1.B, two at M1.B/G10-G11, three at M1.B/P3-P4, one at M1.E/G12 on `6a2bb8e`, one at M1.E on `387ab97`). **The ninth, at M1.E/G12 on `6a2bb8e`, is the most informative the class has produced and it is the LARGEST BY AN ORDER**: `2164/2196 tests passed (32 skipped)` against a declared windows floor of 2250 gives **54 TESTS LOST**, where the previous counts were 1, 5, 6, 8 and 14. It ran 38.1 min against a 55-minute budget, so it is NOT the sibling timeout recorded below it — that one has every step green and no lost test — and the log carries ZERO `error: '…' failed:` lines, so no assertion fired. Fifty-four tests is a whole step of substance rather than a straggler, which narrows what can be hanging: the class is not a slow tail on a small step. **THE THIRTEENTH, at M1.A on `565012c`, hangs TWICE IN ONE RUN and the new discriminant reads BOTH**: signature present twice, sibling absent, zero assertions, `2253/2285 tests passed (32 skipped)` against a declared floor of 2288 — **THREE tests lost across two hung steps** — 52.6 minutes against 55 with conclusion `failure`. The two `failed command:` lines name `fecde19354ea9ccbd9ecb5d20cdb00ac` and `2fe9c3b586059afa4881744abc5715ef`: **two different executables in ONE build**, which is the within-run twin of the within-SHA comparison the twelfth produced. Four distinct identities are now recorded across three attempts and no two agree. **At M1.D/S5 on `db275303`, ONE identity hung in TWO cells of one attempt**: `06b3583a…` in `windows-2025 / ReleaseSafe` f64 (seed `0x21372d02`, its only hang, five tests lost) and f32 (seed `0x55397372`, beside `9fc23c50…` and `110fa824…`, eight lost). The two cells build that executable identically — thirteen test-executable hashes appear in both logs — so it is the same binary hanging twice. Both cells passed at the same sha on attempt 2. At `ae9ee612`, the next cold run, the f64 cell then failed THREE attempts in a row, on four identities none of which recurred (`ad84682e…`, `0a38a40e…`, `48f2ff74…`, `4738b31b…`). **Measured at M1.D/S5/G9 septies, nothing corrected**: `06b3583a…` is `tests/vk_gen/raw_variants.zig`, five compile-time checks; the repeat is chance, anomalies recurring at a tree position 14 times over 16 executions where random placement gives 13.8; over 992 windows jobs since 2026-08-25 the class hit 0 of 496 Debug jobs, 29 % of cold ReleaseSafe ones and 1.4 % of warm ones, on 61 different executables. Zig 0.16's windows spawn leaves the child's pipe ends inheritable with no handle list, the race its POSIX path closes with `CLOEXEC`; that mechanism fits every measurement and is not measured. Detail in the brief. The series of losses is 1, 5, 6, 8, 14, 54, 2, 1, 3. **THE TWELFTH, at M1.A on `e054b26`, ties the smallest loss and adds a collateral the class did not carry**: `2255/2287 tests passed (32 skipped)` against a declared floor of 2288 gives **ONE test lost**, signature present once, sibling class absent, zero `error: '…' failed:` lines, and **53.0 minutes against a 55-minute budget with conclusion `failure` and not `cancelled`** — which excludes `M1.D.27` on both of its discriminants at once rather than on duration alone. The series of losses is now 1, 5, 6, 8, 14, 54, 2, 1. **The collateral is that two debts met on one job**: the run carries `Zig cache is 11222302720 bytes, over the 10737418240 cap — SKIPPING the final save`, which is `M1.D.10`'s mechanism, and a skipped final save leaves the NEXT run on that cell colder, which is `M1.D.27`'s trigger. Neither debt is new; their interaction is recorded nowhere else. **IT FAILED ITS FIRST SAME-SHA RE-RUN, and that re-run produced a THIRD DISCRIMINANT this entry records as impossible.** Attempt 2: signature once, sibling absent, zero assertions, `2256/2287 (31 skipped)` against 2288 — **one test lost again**, where every previous pair of failures at one SHA had lost DIFFERENT counts, which is the shape an implicated test would produce. Refuted by measurement: this entry states that *"naming the step from the log does not work"*, which is true of the step's SOURCE name and FALSE of its identity — **the `failed command:` line immediately following the `failed to respond` message carries the hung step's build-cache hash**, and the two attempts differ (`01a58047…` against `c810df3f…`). Two DIFFERENT executables hung at one SHA, so no test is implicated, and the equal loss explains itself: both hung steps sit in the contiguous family whose neighbours all report `0 pass, 1 skip (1 total)`, where a hang costs exactly one test whichever member it hits. *The count was never the discriminant; the identity is, and it is one grep away in every log this class has already produced.* **THE ELEVENTH, at M1.A on `3f22cf6`, is the smallest loss the class has produced and the cleanest measurement of it**: `2245/2277 tests passed (32 skipped)` against a declared floor of 2279 gives **2 TESTS LOST**, with the signature present once, the sibling class absent, zero `error: '…' failed:` lines, and 52.7 minutes against a 55-minute budget — so `M1.D.27` is excluded by duration and by conclusion (`fail`, not `cancelled`). The series of losses is now 1, 5, 6, 8, 14, 54 and 2, which continues to refute any single implicated test. Cleared by a re-run at the SAME SHA. **THE TENTH, at M1.E on `387ab97`, is the first to hang TWICE IN ONE RUN**: two `failed to respond` twelve minutes apart (19:49:14 and 20:01:15 UTC) on two DIFFERENT test executables, and `301/304 steps succeeded (2 failed)` accounts for exactly those two. **And the loss stopped following the step count**: `2217/2249 tests passed (32 skipped)` against the same 2250 floor gives **ONE test lost for TWO hung steps**, so at least one of the two cost no test at all — where the counts so far had been 1, 5, 6, 8, 14 and 54, always for a single step. That asymmetry is NOT explained here: the natural reading, a runner that blocks after its last result is already reported, is plausible and unmeasured, and this class has already paid for two hypotheses issued on a plausible reading. What it does strengthen is the `0664f28` discriminant — two different steps, in one run, on a commit whose diff is comments and two Markdown files. It ran 40.2 min, SHORTER than the sibling timeout because it aborted on the hang rather than finishing, carries ZERO `error: '…' failed:`, and exonerated on the FIRST re-run. **AT ONE SHA (`0664f28`) THE CELL FAILED THREE TIMES AND LOST THREE DIFFERENT COUNTS — 14, 1 and 5 tests — hence three different step sets.** That is a discriminant the class did not previously have, and it is the strongest evidence yet that no single test is implicated: a test that hangs deterministically blocks the same step every time and loses the same number. **And the frequency moved**: the five occurrences preceding that SHA each exonerated on the FIRST same-SHA re-run, where this one took two — f64 green on re-run 1, f32 failing again with a third lost count and green only on re-run 2. Two collateral facts from the same investigation: `pre-push` runs `zig build test -Doptimize=ReleaseSafe` (`lefthook.yml:31`), so the failing cell's MODE is green on the dev machine at every push; and pushing over an in-flight run marks that run `failure` with no evidence of its own (`aa7416c`), which is the recorded status-predicate hazard seen from the other side. **Two discriminants, and only one of them works today.** (1) `declared − collected` from the `Build Summary` line `--summary all` already produces: at the M1.B occurrence, `302/304 steps succeeded (1 failed); 2103/2135 tests passed` against a declared windows floor of 2143 gives **8 tests lost**, so the hung step holds eight — a SIZE, computed and not inferred. (2) Naming the step from the log **does not work**: a hung step emits no output at all, so the per-step summary that would name it is exactly what is missing, and `--log-failed` returns the aggregate. Naming it needs either a per-step timeout that identifies its target or a step-by-step run. What DOES survive is a negative discriminant used at G11: a step that printed its own report AND its `failed command:` artifact has COMPLETED, which is how the M1.B/G10 scheduler-dispatch tests were exonerated without a re-run. **The corpus carries no foyer for this**: the signature returns zero occurrences across the corpus (measured), so ten occurrences on a merge-gating cell live only in a succession of briefs. - **`win32_thread_safety_test` TIMES OUT on `windows-2025 / Debug`, and it is a DISTINCT class from the hang above (second occurrence at M1.A, needs a number)**. Not `M1.D.19`: the hang signature is ABSENT, no test is lost (collected equals the floor exactly), and ONE assertion genuinely fires — `error.Win32ThreadSafetyTimeout` at `tests/platform/win32_thread_safety_test.zig:99`, a wall-clock bound on three threads doing 100 `createWindow`/`destroyWindow` pairs each against a 30 s budget. The job takes ~10 minutes, not the ~50 of a hang. **The decisive discriminant is stronger than the sibling-cell one and is general to every flake class: A DIFF WITH NO ZIG IN IT.** The second occurrence landed on a commit whose diff is two Markdown files and seven lines, on top of a commit where that same cell had passed — so no code changed between the green and the red, and the commit cannot be the cause. The sibling `Debug / f64` cell passing at the same SHA corroborates it and is weaker: Win32 windowing has nothing to do with the physics scalar, so that axis could not discriminate a real fault either. **It is NOT the class M1.1.9 retired from `crash_recovery.zig`**, and the difference is worth keeping: those assertions ran AFTER the blocking receive returned and therefore guarded nothing, while this loop BOUNDS a wait and abandons it — a correctly shaped hang guard. What fires is its BUDGET on a runner the suite itself saturates. Removing it would remove a real guard; raising it weakens the bound it exists to give. Owner: unassigned. diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index abc3dc26..e348d1d8 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -6381,6 +6381,104 @@ exactly 3 entries, 198 183 034 bytes, afterwards. `nightly-fuzz.yml` is not exer by a pull-request run — schedule and dispatch run from `main` — so it is verified by reading until the first night after the merge. +### S5/G9 septies — `M1.D.19` measured, nothing corrected + +Guy put `M1.D.19` ahead of G9: `ci-gate` was red at `ae9ee612` on it, and it blocks +the pull request. The ruling was to measure and correct nothing: which test +`06b3583a…` is, and what it does when the runner stops answering. + +**`06b3583a…` is `tests/vk_gen/raw_variants.zig`.** `zig build test --summary all` prints +the `test` step's 155 run steps in the order `build.zig` adds them — twelve direct +dependencies, the 116 `test_specs`, the 16 `ipc_test_paths`, then eleven more — and +the order is the same in every cell of one tree. The hung step is #117 of 155 in both +cells. Its green twin at the same position reads `5 pass (5 total) 12ms`, and the file +has five `test` blocks. The mapping was checked by count on every neighbour read, +`smoke_test` (1), `transform_sync_test` (58), `forge_module_test` (30) and the whole +IPC loop among them, and by the lost counts, which equal the hung executable's test +count every time. All nine hangs of the two cold runs are named this way: + +| Run | Cell | Executable | Tests | +|---|---|---|---| +| `db275303` a1 | f64 | `tests/vk_gen/raw_variants.zig` | 5 | +| `db275303` a1 | f32 | `tests/vk_gen/raw_variants.zig` | 5 | +| | | `tests/ipc/viewport_cases/no_tearing_1000_frames.zig` | 1, skipped on windows | +| | | `tests/etch_interp/levelb_ir_diff_test.zig` | 2 | +| `ae9ee612` a1 | f64 | `tests/bindings/wayland_abi_test.zig` | 13 | +| | | `src/foundation/simd/tests/paeth_test.zig` | 2 | +| `ae9ee612` a2 | f64 | `tests/ipc/catalogue.zig` | 4, three skipped on windows | +| `ae9ee612` a3 | f64 | `tests/ipc/handshake.zig` | 3, all skipped on windows | + +**What it does when the runner stops answering: nothing of its own.** Its five tests +compare types resolved at compile time; nothing runs before them, and it creates no +thread and does no I/O. Two of the nine hung executables run no test at all on +windows. The signature is Zig's own response timeout, `@max(unit_test_timeout_ns +orelse 0, 60 s)` (`std/Build/Step/Run.zig:1844-1847`), armed only while no test is +active (`:1859`): from the spawn to `test_metadata`, between one test's results and +the next test's start, and after `exit` until both the child's stdout and stderr +reach end of stream. The CI output does not say which of the three it was. Every +elapsed time recorded is 60 s plus a few milliseconds, so the parent's reader was +awake at its deadline. + +**It is not random with respect to the load, and it is random with respect to the +executable.** All 992 windows `build-and-test` jobs since 2026-08-25: + +| Mode | Cache | Jobs | With a hang | +|---|---|---|---| +| Debug | any (273 cold) | 496 | **0** | +| ReleaseSafe | cold (≥ 90 % of the test compiles built) | 110 | 32 | +| ReleaseSafe | partial | 106 | 23 | +| ReleaseSafe | warm (≤ 20 % built) | 221 | **3** | + +The 88 hangs fall anywhere in the step, 1.2 to 41.5 minutes after its start. The 82 +that map to a file land on **61 different executables**, none more than three times. +Over 16 ReleaseSafe executions of the same binaries, 77 anomalies (a hang, or at +least 10 s and five times the position's median) repeat at the same position 14 +times, where the same number placed at random would repeat 13.8 times: the executable +that hung in both cells of one attempt is chance. **The signature is the tail of a +continuum**, visible in green logs on executables of five tests or fewer: + +| Regime | Steps | ≥ 20 s | ≥ 40 s | Longest | +|---|---|---|---|---| +| ReleaseSafe cold | 2 416 | 22 | 7 | 46 s | +| ReleaseSafe warm | 2 410 | 2 | 2 | 55 s | +| Debug cold | 2 425 | 1 | 0 | 24 s | +| Debug warm | 2 460 | 3 | 0 | 30 s | + +What separates the modes is how long the compiler processes live, not how much +memory they take: the test compiles built in a ReleaseSafe cell take 29 s at the +median, 120 s at most and 5 487 s in all; in Debug 7 s, 29 s and 1 488 s. Their +memory is the same, 288 and 276 MB at the median. + +**A mechanism in Zig 0.16 fits every one of these measurements, and it is not +measured.** On windows, `spawnWindows` creates the child's ends of the stdio pipes +inheritable (`std/Io/Threaded.zig:15472-15496`) and calls `CreateProcessW` with +`bInheritHandles = TRUE` (`:16236-16241`), with no handle list and no spawn lock. The +POSIX path of the same file creates its pipes `CLOEXEC` against exactly this race, and +says so (`:14871-14882`): another child spawned at the same moment inherits the pipe's +write end, and the parent stops seeing the first child's stdout close. The build +runner spawns compilers and tests concurrently. So a test whose pipe ends a compiler +inherited finishes, exits, and its end of stream waits for that compiler to exit. +That is the third window. It predicts windows only, ReleaseSafe and not Debug (a Debug +compile lives at most 29 s, the Debug tail tops out at 24 to 30 s), cold far more than +warm, any executable including one that runs no test, and a parent awake at its +deadline. The build runner's order is shuffled under a random seed (CI passes none), +and `-j1` does not serialise spawns, so neither the seed nor `-j1` can serve as a +control. + +**Reproducible locally on windows, without re-running CI.** The deciding +observation is whether the `test.exe` of a stalled step has already exited, and +which `zig.exe` holds a handle to its pipe — Process Explorer or `handle.exe`, whose +download is Guy's call. A stall does not have to reach 60 s to be seen: a cold +`zig build test --summary all -Doptimize=ReleaseSafe -Dcpu=baseline` shows tiny +executables taking tens of seconds, which is the window to catch. Process Monitor's +Process Create to first Load Image timeline covers the other reading, a slow first +launch, which CI cannot exclude. On a 4-vCPU runner the build runner runs +`NumberOfProcessors − 1` workers and its main thread, so matching CI takes `-j3`. + +The `M1.0.1` brief attributes this signature to a `deinit` `join()` blocking inside a +test. Under Zig 0.16 with no test timeout, a blocking test is never timed, so that +cannot print it. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree From 0dd5e3d0d3f274ea7dd986decb83f76b7dad9f6c Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Fri, 25 Sep 2026 02:33:59 +0200 Subject: [PATCH 062/141] docs(brief): record the cache rules verified on the run Co-Authored-By: Claude Opus 5.5 --- briefs/m1.d-phase-1-debt.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index e348d1d8..45698a9f 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -6381,6 +6381,25 @@ exactly 3 entries, 198 183 034 bytes, afterwards. `nightly-fuzz.yml` is not exer by a pull-request run — schedule and dispatch run from `main` — so it is verified by reading until the first night after the merge. +#### Verified on the run + +CI run `36067237885` and Bench run `36067237645` at `cb8265fc`, the CI run re-run once +for its one red cell. + +- The bench restored under `zig-v2-2026-39-bench-ecs-smoke-…` and missed; its guard + measured 548 691 968 and 481 084 416 bytes, under the cap; its save was skipped; + 19m35s and 16m27s. +- The runtime smoke restored under `zig-v2-2026-39-runtime-smoke-test-…` and missed; + its guard measured 673 132 544 bytes; its save was skipped; 5m35s. +- **The vertical slice ran no cache step and passed in 1m23s**, under the predicted + floor of 1.5 minutes. +- The repository still holds 3 entries, 198 183 034 bytes, all in the pull request's + scope. +- `windows-2025 / ReleaseSafe / f64` failed attempt 1 on `M1.D.19`, two hangs at tree + positions 48 and 152, `tests/core/events/lifetime_test.zig` (5 tests) and the + `audio` module's tests (1), six lost, 2616 collected against 2622. It passed on + attempt 2, whose smokes ran for the first time since the change. + ### S5/G9 septies — `M1.D.19` measured, nothing corrected Guy put `M1.D.19` ahead of G9: `ci-gate` was red at `ae9ee612` on it, and it blocks From afc5a479e6177ce4fe61ec1a06adb9790c340864 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Fri, 25 Sep 2026 10:36:48 +0200 Subject: [PATCH 063/141] fix(ci): raise the test timeout to 180 s against the spawn race Zig 0.16 on windows spawns a test with inheritable pipe ends and no handle list, so a compiler spawned in the same instant can hold the test's end of stream until it exits; the runner's response floor fires at 60 s, and a ReleaseSafe compile was measured to live up to 120 s. --test-timeout 180s lifts that floor. No test step of any cell reached 2 minutes over the last 97 jobs, so the per-test timeout it also arms kills nothing legitimate. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd758727..484a1bd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,7 +125,9 @@ jobs: run: | set -euo pipefail s=$SECONDS - zig build test --summary all -Doptimize=${{ matrix.mode }} -Dphysics_f64=${{ matrix.precision }} -Dcpu=${{ env.ZIG_CPU }} 2>&1 | tee test-out.txt + # --test-timeout raises Zig's 60 s response floor: on windows a compiler spawned beside a + # test can inherit its pipe and hold the end of stream until that compiler exits. + zig build test --summary all --test-timeout 180s -Doptimize=${{ matrix.mode }} -Dphysics_f64=${{ matrix.precision }} -Dcpu=${{ env.ZIG_CPU }} 2>&1 | tee test-out.txt rc=${PIPESTATUS[0]} if [ "$rc" -ne 0 ]; then exit "$rc"; fi collected="$(sed -n 's/.*[0-9][0-9]* of \([0-9][0-9]*\) tests passed.*/\1/p;s#.*[^0-9]\([0-9][0-9]*\)/\([0-9][0-9]*\) tests passed.*#\2#p' test-out.txt | tail -1)" From f93260fbb42cb1feaec4d9ef47fc0fe93a19e4a9 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Fri, 25 Sep 2026 10:36:51 +0200 Subject: [PATCH 064/141] docs(brief): state the 180 s experiment and its prediction Co-Authored-By: Claude Opus 5.5 --- briefs/m1.d-phase-1-debt.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index 45698a9f..d5c1d756 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -6498,6 +6498,42 @@ The `M1.0.1` brief attributes this signature to a `deinit` `join()` blocking ins test. Under Zig 0.16 with no test timeout, a blocking test is never timed, so that cannot print it. +### S5/G9 octies — `M1.D.19`, the experiment in CI + +Guy dropped the manual reproduction for an experiment that decides both ways: raise +Zig's test timeout to 180 s, above the longest a ReleaseSafe compiler was measured to +live (120 s). The response floor is `@max(unit_test_timeout_ns, 60 s)`, so a test +waiting on a compiler's exit then has until 180 s. + +**Where it goes: the command, not the build.** `--test-timeout` is a build runner +option (`compiler/build_runner.zig:202`) handed to every `Run` step as +`unit_test_timeout_ns`; `build.zig` has no API for it. It goes on the matrix's +`zig build test` in `ci.yml`, all thirteen cells, since the command is one line. Checked +locally: `--test-timeout 180s` is accepted, and `180` without a unit is refused. + +**The side effect, measured before the change.** A configured timeout also times each +test while it runs, which nothing did before, and kills one past 180 s. Over 97 +`build-and-test` jobs of the last 12 CI runs, every cell, no test step printed +2 minutes or more — the summary truncates to whole minutes from 60 s on — and the +longest, `forge_3d`'s 585 tests on `ubuntu-24.04 / Debug`, printed `1m`, under 120 s for +the whole step. No single test can exceed 180 s. + +**Predicted before the runs**, over three cold runs at one sha, so six windows +ReleaseSafe executions — the pull request saves nothing, so a re-run starts cold: + +- **If the mechanism is right**: no `test runner failed to respond` in any of the six, + and at least one small executable (five tests or fewer) whose step prints `1m` or + `2m` — 60 to 179 s — and passes, a step that would have died at 60 s. +- **If it is wrong**: a `failed to respond for 3m…` appears; the runner really stops + answering. Stopped and reported in that case. +- A test `timed out after 3m…` would be a different hang, inside a test. +- No other cell changes: no test times out anywhere. + +The power of six executions is stated with it: at the historical 29 % a cold +execution, six clean ones happen by chance 13 % of the time; at the 5 of 8 of the two +last cold runs, 0.3 %. The steps lasting 60 to 179 s and passing are what make the +result decide in both directions. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree From 2996161d119f256d59dcc1b2c0a96b4bae5cfc2f Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Fri, 25 Sep 2026 13:51:00 +0200 Subject: [PATCH 065/141] docs(brief): confirm the windows spawn race by the 180 s experiment Three cold runs, six windows ReleaseSafe executions under --test-timeout 180s: no hang, and four small executables lasting 60 to 119 s that passed. The upstream report is prepared, not filed. CLAUDE.md also records that the Codeberg migration is abandoned. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 +- .../m1.d-zig-windows-spawn-inherit-issue.md | 101 ++++++++++++++++++ briefs/m1.d-phase-1-debt.md | 30 ++++++ 3 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 briefs/artifacts/m1.d-zig-windows-spawn-inherit-issue.md diff --git a/CLAUDE.md b/CLAUDE.md index 3a018e4c..f17395b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,7 +121,7 @@ commit, so a milestone still in review has no row here. - **M1.D.17 (this file's sense) — the `.sav` schema check is blind to a size-preserving field addition (opened at M1.1.15.2).** `loader.buildSchemaRemap` compares SIZE and ALIGNMENT, and `RigidBody.authority` landed in existing trailing padding — 32 bytes before and after. It bites nothing today because no image carries a `RigidBody` (measured: zero `.etch` name the type, nothing registers it, no cook reaches it) and `.solver` is the zero value, pinned by a test. It will bite at the first image that does, and nothing will announce it. Owner: `engine-scene-serialization.md`, not a physics milestone. - **M1.D.18 — a table component under sustained churn grows its chunk count until the DISPATCH FAILS (opened at M1.B/G11, measured, mechanism named)**. `Archetype.removeSwap` compacts INSIDE one chunk only — `chunk_idx` is fixed and the last slot OF THAT CHUNK fills the hole — and `archetype.zig` has NO release path: no `chunks.pop`, no `swapRemove`, no `entity_count == 0` test, no shrink. So the count follows the CUMULATIVE number of adds and never the live population. Measured by `bench/ecs_hybrid_crossover.zig` at `payload=64B`, fraction 1.0, churn 60/carrier/s: **128 chunks at the first tick, 8200 within the window**, for 20 000 entities over 8 archetypes — 2.4 entities per chunk where the payload allows ~156 — after which `jobs.Scheduler.dispatchBatch` returns `error.TooManyChunks` at its `workers × 8192` capacity. **The consequence is a HARD dispatch failure, not slowness**, and the population that reaches it is any durable churning load — precisely the load `@storage(.sparse)` exists to serve, whose range count is CONSTANT in the same report row. **Invisible to C0.1, which never churns.** NOT fix-as-you-go and the reason is structural, not convenience: the remedy is inter-chunk compaction or a partial-chunk free list, and **moving an entity between chunks invalidates the `chunk_ptr` of every live `ComponentRef`** — the type M1.B/G5 built, whose table arm deliberately holds a chunk pointer — so the fix touches a contract this milestone just froze, and it interacts with `chunkAt(i)`'s stability during a dispatch. That is a milestone, not a commit. Owner: the chunk-lifecycle owner; Guy carries the corpus side. **THE BLOCKER THIS ENTRY RECORDS NO LONGER EXISTS, measured at M1.D/S5/G6**: `M1.D.21` removed `chunk_ptr` in S2/G1 of that same milestone, one session BEFORE this sentence was written, so `ComponentRef` is `{entity, component_id, mutable}` and re-resolves at every access. Second deferral condition satisfied by work from elsewhere, after `M1.D.12`. And the half of the remedy that is REUSE — not compaction — landed at S5/G7 as `Archetype.first_partial`: it moves NO entity, because `removeSwap` leaves a dense prefix and free space at the tail, so its invalidation set is empty. Its benefit is deliberately UNMEASURED and no figure is offered, the instrument being absent (`DynamicQuery` exposes neither `chunkAt` nor `chunkCount`). - **`M1.D.14` gets its SEVENTH and EIGHTH measurements, and its first treatment (M1.B/G11)**. That plan row already carries this debt by name — *"le job `bench-ecs-smoke (windows-2025)` a une queue de durée qui franchit son budget `timeout-minutes: 10`"* — with six measurements at near-constant code (5m08, 6m52, 8m19, 9m21, 9m28, 9m38), factor 1.9, two cancellations and two green re-runs at the SAME SHA, and it names raising the budget as one of two options while taking neither. **M1.B adds 9m37 (passed, 23 seconds of headroom) and 11m28 (cancelled) and TAKES that option**: 10 → 20 minutes. M1.B also made the job heavier, measured rather than assumed — the step runs `zig build bench-ecs`, whose run step depends on the install step, so it compiles EVERY installed artifact, verified by deleting `zig-out/bin/` and watching `ecs-hybrid-crossover-bench` reappear beside `ecs-benchmark`. What the raise does NOT remove is the runner's intrinsic variance, which `M1.D.14`'s own analysis already establishes as the cause, nor the standing question of whether the Windows bench belongs in the PR matrix. *An earlier draft of this entry opened a parallel record under a new number; a debt treated under any name but its own stays open in the document that carries it.* **M1.D/S5 takes the option a second time, 20 → 30 minutes**: the Ubuntu job took 19m46s from an empty cache and 14m19s restored from its own lineage, its crossover sweep being 12 to 14 minutes of run time whatever the cache, and a cold Ubuntu run at `b71017ef` (2026-09-21) was cancelled at the 20-minute budget. -- **A CELL OF THE CI MATRIX HANGS, TEN TIMES MEASURED, AND THE CLASS HAS NO HOME IN THE CORPUS (opened at M1.B/G11, needs a number)**. Distinct from `M1.D.14`, which carries the DURATION of the bench job: this is the PENDING of a matrix cell that gates merges. **Cell:** `build-and-test (windows-2025, ReleaseSafe)`, both precisions. **Signature:** `error: test runner failed to respond for ~1m`, with **zero** occurrences of the sibling class `failed without output` — the two have never been co-present. **Count: TEN**, all on that cell, every one exonerated by a green re-run at the SAME SHA (three recorded before M1.B, two at M1.B/G10-G11, three at M1.B/P3-P4, one at M1.E/G12 on `6a2bb8e`, one at M1.E on `387ab97`). **The ninth, at M1.E/G12 on `6a2bb8e`, is the most informative the class has produced and it is the LARGEST BY AN ORDER**: `2164/2196 tests passed (32 skipped)` against a declared windows floor of 2250 gives **54 TESTS LOST**, where the previous counts were 1, 5, 6, 8 and 14. It ran 38.1 min against a 55-minute budget, so it is NOT the sibling timeout recorded below it — that one has every step green and no lost test — and the log carries ZERO `error: '…' failed:` lines, so no assertion fired. Fifty-four tests is a whole step of substance rather than a straggler, which narrows what can be hanging: the class is not a slow tail on a small step. **THE THIRTEENTH, at M1.A on `565012c`, hangs TWICE IN ONE RUN and the new discriminant reads BOTH**: signature present twice, sibling absent, zero assertions, `2253/2285 tests passed (32 skipped)` against a declared floor of 2288 — **THREE tests lost across two hung steps** — 52.6 minutes against 55 with conclusion `failure`. The two `failed command:` lines name `fecde19354ea9ccbd9ecb5d20cdb00ac` and `2fe9c3b586059afa4881744abc5715ef`: **two different executables in ONE build**, which is the within-run twin of the within-SHA comparison the twelfth produced. Four distinct identities are now recorded across three attempts and no two agree. **At M1.D/S5 on `db275303`, ONE identity hung in TWO cells of one attempt**: `06b3583a…` in `windows-2025 / ReleaseSafe` f64 (seed `0x21372d02`, its only hang, five tests lost) and f32 (seed `0x55397372`, beside `9fc23c50…` and `110fa824…`, eight lost). The two cells build that executable identically — thirteen test-executable hashes appear in both logs — so it is the same binary hanging twice. Both cells passed at the same sha on attempt 2. At `ae9ee612`, the next cold run, the f64 cell then failed THREE attempts in a row, on four identities none of which recurred (`ad84682e…`, `0a38a40e…`, `48f2ff74…`, `4738b31b…`). **Measured at M1.D/S5/G9 septies, nothing corrected**: `06b3583a…` is `tests/vk_gen/raw_variants.zig`, five compile-time checks; the repeat is chance, anomalies recurring at a tree position 14 times over 16 executions where random placement gives 13.8; over 992 windows jobs since 2026-08-25 the class hit 0 of 496 Debug jobs, 29 % of cold ReleaseSafe ones and 1.4 % of warm ones, on 61 different executables. Zig 0.16's windows spawn leaves the child's pipe ends inheritable with no handle list, the race its POSIX path closes with `CLOEXEC`; that mechanism fits every measurement and is not measured. Detail in the brief. The series of losses is 1, 5, 6, 8, 14, 54, 2, 1, 3. **THE TWELFTH, at M1.A on `e054b26`, ties the smallest loss and adds a collateral the class did not carry**: `2255/2287 tests passed (32 skipped)` against a declared floor of 2288 gives **ONE test lost**, signature present once, sibling class absent, zero `error: '…' failed:` lines, and **53.0 minutes against a 55-minute budget with conclusion `failure` and not `cancelled`** — which excludes `M1.D.27` on both of its discriminants at once rather than on duration alone. The series of losses is now 1, 5, 6, 8, 14, 54, 2, 1. **The collateral is that two debts met on one job**: the run carries `Zig cache is 11222302720 bytes, over the 10737418240 cap — SKIPPING the final save`, which is `M1.D.10`'s mechanism, and a skipped final save leaves the NEXT run on that cell colder, which is `M1.D.27`'s trigger. Neither debt is new; their interaction is recorded nowhere else. **IT FAILED ITS FIRST SAME-SHA RE-RUN, and that re-run produced a THIRD DISCRIMINANT this entry records as impossible.** Attempt 2: signature once, sibling absent, zero assertions, `2256/2287 (31 skipped)` against 2288 — **one test lost again**, where every previous pair of failures at one SHA had lost DIFFERENT counts, which is the shape an implicated test would produce. Refuted by measurement: this entry states that *"naming the step from the log does not work"*, which is true of the step's SOURCE name and FALSE of its identity — **the `failed command:` line immediately following the `failed to respond` message carries the hung step's build-cache hash**, and the two attempts differ (`01a58047…` against `c810df3f…`). Two DIFFERENT executables hung at one SHA, so no test is implicated, and the equal loss explains itself: both hung steps sit in the contiguous family whose neighbours all report `0 pass, 1 skip (1 total)`, where a hang costs exactly one test whichever member it hits. *The count was never the discriminant; the identity is, and it is one grep away in every log this class has already produced.* **THE ELEVENTH, at M1.A on `3f22cf6`, is the smallest loss the class has produced and the cleanest measurement of it**: `2245/2277 tests passed (32 skipped)` against a declared floor of 2279 gives **2 TESTS LOST**, with the signature present once, the sibling class absent, zero `error: '…' failed:` lines, and 52.7 minutes against a 55-minute budget — so `M1.D.27` is excluded by duration and by conclusion (`fail`, not `cancelled`). The series of losses is now 1, 5, 6, 8, 14, 54 and 2, which continues to refute any single implicated test. Cleared by a re-run at the SAME SHA. **THE TENTH, at M1.E on `387ab97`, is the first to hang TWICE IN ONE RUN**: two `failed to respond` twelve minutes apart (19:49:14 and 20:01:15 UTC) on two DIFFERENT test executables, and `301/304 steps succeeded (2 failed)` accounts for exactly those two. **And the loss stopped following the step count**: `2217/2249 tests passed (32 skipped)` against the same 2250 floor gives **ONE test lost for TWO hung steps**, so at least one of the two cost no test at all — where the counts so far had been 1, 5, 6, 8, 14 and 54, always for a single step. That asymmetry is NOT explained here: the natural reading, a runner that blocks after its last result is already reported, is plausible and unmeasured, and this class has already paid for two hypotheses issued on a plausible reading. What it does strengthen is the `0664f28` discriminant — two different steps, in one run, on a commit whose diff is comments and two Markdown files. It ran 40.2 min, SHORTER than the sibling timeout because it aborted on the hang rather than finishing, carries ZERO `error: '…' failed:`, and exonerated on the FIRST re-run. **AT ONE SHA (`0664f28`) THE CELL FAILED THREE TIMES AND LOST THREE DIFFERENT COUNTS — 14, 1 and 5 tests — hence three different step sets.** That is a discriminant the class did not previously have, and it is the strongest evidence yet that no single test is implicated: a test that hangs deterministically blocks the same step every time and loses the same number. **And the frequency moved**: the five occurrences preceding that SHA each exonerated on the FIRST same-SHA re-run, where this one took two — f64 green on re-run 1, f32 failing again with a third lost count and green only on re-run 2. Two collateral facts from the same investigation: `pre-push` runs `zig build test -Doptimize=ReleaseSafe` (`lefthook.yml:31`), so the failing cell's MODE is green on the dev machine at every push; and pushing over an in-flight run marks that run `failure` with no evidence of its own (`aa7416c`), which is the recorded status-predicate hazard seen from the other side. **Two discriminants, and only one of them works today.** (1) `declared − collected` from the `Build Summary` line `--summary all` already produces: at the M1.B occurrence, `302/304 steps succeeded (1 failed); 2103/2135 tests passed` against a declared windows floor of 2143 gives **8 tests lost**, so the hung step holds eight — a SIZE, computed and not inferred. (2) Naming the step from the log **does not work**: a hung step emits no output at all, so the per-step summary that would name it is exactly what is missing, and `--log-failed` returns the aggregate. Naming it needs either a per-step timeout that identifies its target or a step-by-step run. What DOES survive is a negative discriminant used at G11: a step that printed its own report AND its `failed command:` artifact has COMPLETED, which is how the M1.B/G10 scheduler-dispatch tests were exonerated without a re-run. **The corpus carries no foyer for this**: the signature returns zero occurrences across the corpus (measured), so ten occurrences on a merge-gating cell live only in a succession of briefs. +- **A CELL OF THE CI MATRIX HANGS, TEN TIMES MEASURED, AND THE CLASS HAS NO HOME IN THE CORPUS (opened at M1.B/G11, needs a number)**. Distinct from `M1.D.14`, which carries the DURATION of the bench job: this is the PENDING of a matrix cell that gates merges. **Cell:** `build-and-test (windows-2025, ReleaseSafe)`, both precisions. **Signature:** `error: test runner failed to respond for ~1m`, with **zero** occurrences of the sibling class `failed without output` — the two have never been co-present. **Count: TEN**, all on that cell, every one exonerated by a green re-run at the SAME SHA (three recorded before M1.B, two at M1.B/G10-G11, three at M1.B/P3-P4, one at M1.E/G12 on `6a2bb8e`, one at M1.E on `387ab97`). **The ninth, at M1.E/G12 on `6a2bb8e`, is the most informative the class has produced and it is the LARGEST BY AN ORDER**: `2164/2196 tests passed (32 skipped)` against a declared windows floor of 2250 gives **54 TESTS LOST**, where the previous counts were 1, 5, 6, 8 and 14. It ran 38.1 min against a 55-minute budget, so it is NOT the sibling timeout recorded below it — that one has every step green and no lost test — and the log carries ZERO `error: '…' failed:` lines, so no assertion fired. Fifty-four tests is a whole step of substance rather than a straggler, which narrows what can be hanging: the class is not a slow tail on a small step. **THE THIRTEENTH, at M1.A on `565012c`, hangs TWICE IN ONE RUN and the new discriminant reads BOTH**: signature present twice, sibling absent, zero assertions, `2253/2285 tests passed (32 skipped)` against a declared floor of 2288 — **THREE tests lost across two hung steps** — 52.6 minutes against 55 with conclusion `failure`. The two `failed command:` lines name `fecde19354ea9ccbd9ecb5d20cdb00ac` and `2fe9c3b586059afa4881744abc5715ef`: **two different executables in ONE build**, which is the within-run twin of the within-SHA comparison the twelfth produced. Four distinct identities are now recorded across three attempts and no two agree. **At M1.D/S5 on `db275303`, ONE identity hung in TWO cells of one attempt**: `06b3583a…` in `windows-2025 / ReleaseSafe` f64 (seed `0x21372d02`, its only hang, five tests lost) and f32 (seed `0x55397372`, beside `9fc23c50…` and `110fa824…`, eight lost). The two cells build that executable identically — thirteen test-executable hashes appear in both logs — so it is the same binary hanging twice. Both cells passed at the same sha on attempt 2. At `ae9ee612`, the next cold run, the f64 cell then failed THREE attempts in a row, on four identities none of which recurred (`ad84682e…`, `0a38a40e…`, `48f2ff74…`, `4738b31b…`). **Measured at M1.D/S5/G9 septies, nothing corrected**: `06b3583a…` is `tests/vk_gen/raw_variants.zig`, five compile-time checks; the repeat is chance, anomalies recurring at a tree position 14 times over 16 executions where random placement gives 13.8; over 992 windows jobs since 2026-08-25 the class hit 0 of 496 Debug jobs, 29 % of cold ReleaseSafe ones and 1.4 % of warm ones, on 61 different executables. Zig 0.16's windows spawn leaves the child's pipe ends inheritable with no handle list, the race its POSIX path closes with `CLOEXEC`; that mechanism fits every measurement and is not measured. **Confirmed at G9 octies**: `--test-timeout 180s` on the matrix `zig build test`, three cold runs, six windows ReleaseSafe executions — zero hangs, and four small executables lasting 60 to 119 s that passed. The timeout stays as the workaround of a Zig 0.16 defect; the upstream report is prepared in `briefs/artifacts/`, not filed. Detail in the brief. The series of losses is 1, 5, 6, 8, 14, 54, 2, 1, 3. **THE TWELFTH, at M1.A on `e054b26`, ties the smallest loss and adds a collateral the class did not carry**: `2255/2287 tests passed (32 skipped)` against a declared floor of 2288 gives **ONE test lost**, signature present once, sibling class absent, zero `error: '…' failed:` lines, and **53.0 minutes against a 55-minute budget with conclusion `failure` and not `cancelled`** — which excludes `M1.D.27` on both of its discriminants at once rather than on duration alone. The series of losses is now 1, 5, 6, 8, 14, 54, 2, 1. **The collateral is that two debts met on one job**: the run carries `Zig cache is 11222302720 bytes, over the 10737418240 cap — SKIPPING the final save`, which is `M1.D.10`'s mechanism, and a skipped final save leaves the NEXT run on that cell colder, which is `M1.D.27`'s trigger. Neither debt is new; their interaction is recorded nowhere else. **IT FAILED ITS FIRST SAME-SHA RE-RUN, and that re-run produced a THIRD DISCRIMINANT this entry records as impossible.** Attempt 2: signature once, sibling absent, zero assertions, `2256/2287 (31 skipped)` against 2288 — **one test lost again**, where every previous pair of failures at one SHA had lost DIFFERENT counts, which is the shape an implicated test would produce. Refuted by measurement: this entry states that *"naming the step from the log does not work"*, which is true of the step's SOURCE name and FALSE of its identity — **the `failed command:` line immediately following the `failed to respond` message carries the hung step's build-cache hash**, and the two attempts differ (`01a58047…` against `c810df3f…`). Two DIFFERENT executables hung at one SHA, so no test is implicated, and the equal loss explains itself: both hung steps sit in the contiguous family whose neighbours all report `0 pass, 1 skip (1 total)`, where a hang costs exactly one test whichever member it hits. *The count was never the discriminant; the identity is, and it is one grep away in every log this class has already produced.* **THE ELEVENTH, at M1.A on `3f22cf6`, is the smallest loss the class has produced and the cleanest measurement of it**: `2245/2277 tests passed (32 skipped)` against a declared floor of 2279 gives **2 TESTS LOST**, with the signature present once, the sibling class absent, zero `error: '…' failed:` lines, and 52.7 minutes against a 55-minute budget — so `M1.D.27` is excluded by duration and by conclusion (`fail`, not `cancelled`). The series of losses is now 1, 5, 6, 8, 14, 54 and 2, which continues to refute any single implicated test. Cleared by a re-run at the SAME SHA. **THE TENTH, at M1.E on `387ab97`, is the first to hang TWICE IN ONE RUN**: two `failed to respond` twelve minutes apart (19:49:14 and 20:01:15 UTC) on two DIFFERENT test executables, and `301/304 steps succeeded (2 failed)` accounts for exactly those two. **And the loss stopped following the step count**: `2217/2249 tests passed (32 skipped)` against the same 2250 floor gives **ONE test lost for TWO hung steps**, so at least one of the two cost no test at all — where the counts so far had been 1, 5, 6, 8, 14 and 54, always for a single step. That asymmetry is NOT explained here: the natural reading, a runner that blocks after its last result is already reported, is plausible and unmeasured, and this class has already paid for two hypotheses issued on a plausible reading. What it does strengthen is the `0664f28` discriminant — two different steps, in one run, on a commit whose diff is comments and two Markdown files. It ran 40.2 min, SHORTER than the sibling timeout because it aborted on the hang rather than finishing, carries ZERO `error: '…' failed:`, and exonerated on the FIRST re-run. **AT ONE SHA (`0664f28`) THE CELL FAILED THREE TIMES AND LOST THREE DIFFERENT COUNTS — 14, 1 and 5 tests — hence three different step sets.** That is a discriminant the class did not previously have, and it is the strongest evidence yet that no single test is implicated: a test that hangs deterministically blocks the same step every time and loses the same number. **And the frequency moved**: the five occurrences preceding that SHA each exonerated on the FIRST same-SHA re-run, where this one took two — f64 green on re-run 1, f32 failing again with a third lost count and green only on re-run 2. Two collateral facts from the same investigation: `pre-push` runs `zig build test -Doptimize=ReleaseSafe` (`lefthook.yml:31`), so the failing cell's MODE is green on the dev machine at every push; and pushing over an in-flight run marks that run `failure` with no evidence of its own (`aa7416c`), which is the recorded status-predicate hazard seen from the other side. **Two discriminants, and only one of them works today.** (1) `declared − collected` from the `Build Summary` line `--summary all` already produces: at the M1.B occurrence, `302/304 steps succeeded (1 failed); 2103/2135 tests passed` against a declared windows floor of 2143 gives **8 tests lost**, so the hung step holds eight — a SIZE, computed and not inferred. (2) Naming the step from the log **does not work**: a hung step emits no output at all, so the per-step summary that would name it is exactly what is missing, and `--log-failed` returns the aggregate. Naming it needs either a per-step timeout that identifies its target or a step-by-step run. What DOES survive is a negative discriminant used at G11: a step that printed its own report AND its `failed command:` artifact has COMPLETED, which is how the M1.B/G10 scheduler-dispatch tests were exonerated without a re-run. **The corpus carries no foyer for this**: the signature returns zero occurrences across the corpus (measured), so ten occurrences on a merge-gating cell live only in a succession of briefs. - **`win32_thread_safety_test` TIMES OUT on `windows-2025 / Debug`, and it is a DISTINCT class from the hang above (second occurrence at M1.A, needs a number)**. Not `M1.D.19`: the hang signature is ABSENT, no test is lost (collected equals the floor exactly), and ONE assertion genuinely fires — `error.Win32ThreadSafetyTimeout` at `tests/platform/win32_thread_safety_test.zig:99`, a wall-clock bound on three threads doing 100 `createWindow`/`destroyWindow` pairs each against a 30 s budget. The job takes ~10 minutes, not the ~50 of a hang. **The decisive discriminant is stronger than the sibling-cell one and is general to every flake class: A DIFF WITH NO ZIG IN IT.** The second occurrence landed on a commit whose diff is two Markdown files and seven lines, on top of a commit where that same cell had passed — so no code changed between the green and the red, and the commit cannot be the cause. The sibling `Debug / f64` cell passing at the same SHA corroborates it and is weaker: Win32 windowing has nothing to do with the physics scalar, so that axis could not discriminate a real fault either. **It is NOT the class M1.1.9 retired from `crash_recovery.zig`**, and the difference is worth keeping: those assertions ran AFTER the blocking receive returned and therefore guarded nothing, while this loop BOUNDS a wait and abandons it — a correctly shaped hang guard. What fires is its BUDGET on a runner the suite itself saturates. Removing it would remove a real guard; raising it weakens the bound it exists to give. Owner: unassigned. @@ -132,7 +132,7 @@ commit, so a milestone still in review has no row here. - **`job_bound.reasonOf` was not widened with the walk, so the guard refuses correctly and explains nothing in the one case it exists for (opened at M1.E/G11, needs a number)**. `carriesMarkedIn` enters every composite — pointer, array, vector, optional, error union, and each field of a struct or union — while `reasonOf` follows only `.pointer` and `.optional` and answers `"no reason declared"` for everything else (`job_bound.zig:168-172`). `refuseMarkedArgs` reports `reasonOf(f.type)` on the OUTER field type, so for `SystemContext` carrying `cmd: *CommandBuffer` — **the exact shape the widening was written for** — the compile error names the type and gives no reason. The file's own doctrine, written at `carriesMarkedIn`, is that "widening only to struct fields would have repeated the class this reprise exists to close — a rule applied to a subset of what it must cover"; its sibling function IS that subset. The asymmetry is now stated at the marker's doc. The symmetric widening changes a compile-error message and is therefore behaviour. Owner: whoever owns the bound. - **The `tests/` subtree is OUTSIDE the comment rules by decision, and its size is now measured (M1.E/G11)**. `comment_scan.inPerimeter` returns false for any path whose FIRST segment is `tests`, with the reason written at the function: `src/`, `tools/` and `bench/` carry a conservation pass that gives a reworded comment somewhere to go, and `tests/` does not, so firing there would make the rule green only by leaving those files permanently red. **MEASURED WITH THE BINARY ITSELF, the perimeter exclusion lifted in a throwaway build: 732 diagnostics on 519 distinct comment lines in 146 files** — 494 lines carrying a milestone/gate/step/spike/review identifier and 25 more carrying a phase mention and no identifier. That is 2.6× `build.zig`'s entire diagnostic count, and the largest single unswept perimeter the comment work leaves. Seven of the twelve remaining word-provenance rows live there too. **An earlier figure of 492 lines was published here and is WRONG TWICE**: it was the `comment_identifiers` count alone where the honest quantity is all-rule, and it came from a Python transcription of the rule rather than from the rule — a transcription that tests only lines whose `strip()` starts with `//`, so it misses a comment trailing a line of code, and that counts the `tests/lint/bad/` fixtures the walker deliberately skips. **Measure with the instrument, not with a replica of it, when the instrument is one build away.** Owner: the pass that reads `tests/`. - **macOS in the CI matrix**: deferred, re-evaluated after Phase 0 (CI quota constraints, primary targets are Win11 + Fedora 44). -- **Codeberg migration**: end of Phase 1 (criterion C1.10 in `engine-phase-1-criteria.md`). The repo lives on GitHub for Phase −1 / 0 / 1. +- **Codeberg migration: abandoned** (Guy, 2026-09-25) — Codeberg does not accept repositories made with AI. The repo stays on GitHub; criterion C1.10 in `engine-phase-1-criteria.md` still names the migration and is Guy's to revise. - **`spec/` directory in the repo**: out of scope for Phase −1. Spec lives in the claude.ai knowledge base; re-evaluated during Phase 0 if the absence creates friction. - **Phase 0.6 IPC debts (SCM_RIGHTS fd-passing + editor Windows path)**: resolved in M0.7 (`v0.7.0-M0.7-ipc`). SCM_RIGHTS is the primary POSIX shm attach (create fd over AF_UNIX, runtime `mmap`s the received fd, sidestepping the macOS BSD shm quirk); the editor's Windows `CreateProcessW` + named-pipe path is wired (`src/editor/main.zig` no longer returns `error.Unimplemented`). - **`sendWithHandles` Windows (Phase 3)**: `transport_windows.zig:sendWithHandles` returns `error.Unimplemented`. The `DuplicateHandle`-based equivalent lands with the GPU shared framebuffer when an exportable Vulkan semaphore appears upstream (cf. `engine-ipc.md` §4.7). diff --git a/briefs/artifacts/m1.d-zig-windows-spawn-inherit-issue.md b/briefs/artifacts/m1.d-zig-windows-spawn-inherit-issue.md new file mode 100644 index 00000000..f46dba92 --- /dev/null +++ b/briefs/artifacts/m1.d-zig-windows-spawn-inherit-issue.md @@ -0,0 +1,101 @@ +# Upstream issue draft — Windows spawn leaks a child's stdio pipe ends to concurrent siblings + +> **Status: NOT FILED.** Filing is an outward-facing act and belongs to the repository +> owner. This file is the prepared text and its evidence. The measurements behind it are +> recorded in `briefs/m1.d-phase-1-debt.md`, sections S5/G9 septies and octies. + +## Title + +`std.Io.Threaded`: on Windows, a spawned child's stdio pipe ends are inheritable with no +handle list, so a concurrent spawn inherits them — the race `spawnPosix` closes with +`CLOEXEC` + +## Toolchain, exactly + +`zig version` reports **0.16.0** (Homebrew `zig 0.16.0_1` on the development host; the +official `zig-x86_64-windows-0.16.0.zip` on the CI runner). The failing host is GitHub's +`windows-2025` runner, 4 vCPU, native `x86_64-windows`. Line numbers below are those of +`lib/std/Io/Threaded.zig` and `lib/std/Build/Step/Run.zig` in that 0.16.0. + +## The code + +The Windows spawn path creates the child's end of the stdin, stdout, stderr and progress +pipes inheritable — `.client = .{ .attributes = .{ .INHERIT = true }, … }` at +`Threaded.zig:15474`, `:15481`, `:15488`, `:15495` — then calls `CreateProcessW` with +`bInheritHandles = .TRUE` (`:16236-16241`), and closes those ends only once +`CreateProcessW` has returned (`:15759`, `:15770`, `:15781`). There is no +`PROC_THREAD_ATTRIBUTE_HANDLE_LIST` and no lock around the window in which the handles are +inheritable. + +`spawnPosix` in the same file names exactly this race in its comment (`:14871-14882`): a +second thread spawning a different child at the same moment inherits the pipes, so the +parent does not see the first child's stdout close when expected. It avoids it by +creating the pipes `CLOEXEC` (`:14882`). The Windows path has no equivalent. + +## How it surfaces in `zig build test` + +`Step.Run` spawns test runners and compilers from several worker threads at once. After +the last test of an executable, the parent sends `exit` and waits for end of stream on the +child's stdout and stderr, with `response_timeout = @max(unit_test_timeout_ns orelse 0, +60 s)` armed (`Run.zig:1844-1847`, `:1859`). If a compiler spawned in the same instant +inherited the test's write end, end of stream waits for that compiler to exit. A +ReleaseSafe compile can live past 60 s, and the step fails with +`test runner failed to respond for 1m…` after every one of its tests has passed. + +## Evidence + +Measured on one repository's CI, 992 `windows-2025` jobs of `zig build test --summary all` +between 2026-08-25 and 2026-09-24: + +| Mode | Cache | Jobs | Jobs with the signature | +|---|---|---|---| +| Debug | any | 496 | 0 | +| ReleaseSafe | cold (≥ 90 % of the test compiles built) | 110 | 32 | +| ReleaseSafe | warm (≤ 20 % built) | 221 | 3 | + +- The failures fall on **61 different test executables**, none more than three times, among + them executables whose every test is skipped on Windows and one whose five tests are + compile-time checks only. No test body is implicated. +- The failure is the tail of a continuum. In **green** cold ReleaseSafe jobs, executables of + five tests or fewer — milliseconds of work — take up to 46 s; in Debug, up to 30 s. +- That tail tracks how long compilers live: ReleaseSafe test compiles took 29 s at the + median and up to 120 s; Debug ones 7 s and up to 29 s. Compiler memory is the same in both + modes. +- Every recorded elapsed time is 60 s plus a few milliseconds, so the parent's reader is + awake at its deadline. +- The project's records place every occurrence on the windows cells. Its Linux and ARM cells + run the same build graph through `spawnPosix`; they were not part of this sweep. + +**Experiment**: `--test-timeout 180s`, which lifts the response floor above the longest +compile measured, over three cold runs (six ReleaseSafe executions): + +| Run | Executions | Signature | Small executables at 60–119 s that passed | +|---|---|---|---| +| attempt 1 | 2 | 0 | 2 | +| attempt 2 | 2 | 0 | 0 | +| attempt 3 | 2 | 0 | 2 | + +Every execution was cold (no cache restored, all 155 test executables built). With the +floor lifted, the steps that would have failed at 60 s finish between 60 and 119 s and +pass: the test runner was waiting for an end of stream that did arrive, not stuck. One +of them bounds its own body at 30 s and passed, so at least half its step lay outside +the test. The experiment does not observe which process held the pipe; that part rests +on the code above. + +## Workaround + +`zig build test --test-timeout `. It also arms the per-test +timeout, which kills any test running longer. + +## Suggested direction + +Create the child's ends non-inheritable and hand them to `CreateProcessW` through +`STARTUPINFOEXW` with a `PROC_THREAD_ATTRIBUTE_HANDLE_LIST` naming only them, so a +concurrent `CreateProcessW` cannot inherit them — the Windows counterpart of the POSIX +`CLOEXEC` path. + +## On master + +Not verified against a checkout. A code search of the upstream repository finds +`PROC_THREAD_ATTRIBUTE_HANDLE_LIST` only in the vendored MinGW header +`lib/libc/include/any-windows-any/winbase.h`, not under `lib/std`. diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index d5c1d756..647973bf 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -6534,6 +6534,36 @@ execution, six clean ones happen by chance 13 % of the time; at the 5 of 8 of th last cold runs, 0.3 %. The steps lasting 60 to 179 s and passing are what make the result decide in both directions. +#### Result of the three runs + +CI run `36114020597` at `f93260fb`, attempts 1, 2 and 3, every windows ReleaseSafe cell +cold: `Cache not found`, 155 of 155 test compiles built. + +| Attempt | Cell | `failed to respond` | Small steps at `1m`, all passed | +|---|---|---|---| +| 1 | f32 | 0 | `tests/render/render_graph_topo.zig` (3), `tests/ipc/transport.zig` (3, skipped) | +| 1 | f64 | 0 | — | +| 2 | f32 | 0 | — | +| 2 | f64 | 0 | — | +| 3 | f32 | 0 | `tests/platform/win32_thread_safety_test.zig` (1) | +| 3 | f64 | 0 | `tests/platform/multi_monitor_test.zig` (1) | + +- **No hang in six executions, and four small executables that took 60 to 119 s and + passed**, each of which the 60 s floor would have failed. `win32_thread_safety_test` + bounds its own body at 30 s and passed, so at least 30 s of its step lay outside the + test. +- No test timed out in any cell of any attempt. +- One other red, windows `Debug` f64 on attempt 1: `win32_thread_safety_test`'s own + `error.Win32ThreadSafetyTimeout`, the known `M1.D.29` class — an assertion of the + test, not Zig's timeout. + +**The prediction holds: the mechanism is right.** The runner was waiting for something +that ended within 180 s, not stopped. What the experiment does not show is the +compiler holding the pipe; that part rests on the code. The 180 s timeout stays, as +the workaround of a Zig 0.16 defect that no fix on this side removes. The upstream +report is prepared in `briefs/artifacts/m1.d-zig-windows-spawn-inherit-issue.md`, not +filed. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree From 46f484eb441d1ca7900c7d4759e749c8f3071bf7 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Fri, 25 Sep 2026 15:25:12 +0200 Subject: [PATCH 066/141] docs: drop the Codeberg limit from C1.10 and trim repeated records Codeberg is discarded since July; the fallback is a self-hosted Forgejo on the project's own runners, so its hosted-runner limit is no constraint of C1.10. CLAUDE.md keeps a verdict and a pointer where it repeated the brief. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 +++--- briefs/m1.d-phase-1-debt.md | 6 ------ 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f17395b5..fd8434dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,8 +120,8 @@ commit, so a milestone still in review has no row here. - **THE NUMBER `M1.D.17` DENOTES TWO DIFFERENT DEBTS, and this is the SECOND such collision (measured at M1.D close).** `engine-phase-1-plan.md` numbers `M1.D.17` the Etch hot-reload debt — a `component` reloaded with a changed layout reusing its id — CLOSED at M1.D/S4/G6. The entry immediately below, carried only here, is a DIFFERENT debt under the same number, and the plan carries it under none at all: measured on the delivered lot (`sha256 761adec9…`, 646 lines), zero occurrences of `buildSchemaRemap` or `.sav`. M1.E already corrected a first `M1.D.17`/`M1.D.18` collision on chunk fragmentation, so this is not that one. **Deliberately NOT renumbered here**: the debt table belongs to `engine-phase-1-plan.md`, and a repo file renaming a corpus entry is how a third reading is born. Owner: Guy, with the plan. - **M1.D.17 (this file's sense) — the `.sav` schema check is blind to a size-preserving field addition (opened at M1.1.15.2).** `loader.buildSchemaRemap` compares SIZE and ALIGNMENT, and `RigidBody.authority` landed in existing trailing padding — 32 bytes before and after. It bites nothing today because no image carries a `RigidBody` (measured: zero `.etch` name the type, nothing registers it, no cook reaches it) and `.solver` is the zero value, pinned by a test. It will bite at the first image that does, and nothing will announce it. Owner: `engine-scene-serialization.md`, not a physics milestone. - **M1.D.18 — a table component under sustained churn grows its chunk count until the DISPATCH FAILS (opened at M1.B/G11, measured, mechanism named)**. `Archetype.removeSwap` compacts INSIDE one chunk only — `chunk_idx` is fixed and the last slot OF THAT CHUNK fills the hole — and `archetype.zig` has NO release path: no `chunks.pop`, no `swapRemove`, no `entity_count == 0` test, no shrink. So the count follows the CUMULATIVE number of adds and never the live population. Measured by `bench/ecs_hybrid_crossover.zig` at `payload=64B`, fraction 1.0, churn 60/carrier/s: **128 chunks at the first tick, 8200 within the window**, for 20 000 entities over 8 archetypes — 2.4 entities per chunk where the payload allows ~156 — after which `jobs.Scheduler.dispatchBatch` returns `error.TooManyChunks` at its `workers × 8192` capacity. **The consequence is a HARD dispatch failure, not slowness**, and the population that reaches it is any durable churning load — precisely the load `@storage(.sparse)` exists to serve, whose range count is CONSTANT in the same report row. **Invisible to C0.1, which never churns.** NOT fix-as-you-go and the reason is structural, not convenience: the remedy is inter-chunk compaction or a partial-chunk free list, and **moving an entity between chunks invalidates the `chunk_ptr` of every live `ComponentRef`** — the type M1.B/G5 built, whose table arm deliberately holds a chunk pointer — so the fix touches a contract this milestone just froze, and it interacts with `chunkAt(i)`'s stability during a dispatch. That is a milestone, not a commit. Owner: the chunk-lifecycle owner; Guy carries the corpus side. **THE BLOCKER THIS ENTRY RECORDS NO LONGER EXISTS, measured at M1.D/S5/G6**: `M1.D.21` removed `chunk_ptr` in S2/G1 of that same milestone, one session BEFORE this sentence was written, so `ComponentRef` is `{entity, component_id, mutable}` and re-resolves at every access. Second deferral condition satisfied by work from elsewhere, after `M1.D.12`. And the half of the remedy that is REUSE — not compaction — landed at S5/G7 as `Archetype.first_partial`: it moves NO entity, because `removeSwap` leaves a dense prefix and free space at the tail, so its invalidation set is empty. Its benefit is deliberately UNMEASURED and no figure is offered, the instrument being absent (`DynamicQuery` exposes neither `chunkAt` nor `chunkCount`). -- **`M1.D.14` gets its SEVENTH and EIGHTH measurements, and its first treatment (M1.B/G11)**. That plan row already carries this debt by name — *"le job `bench-ecs-smoke (windows-2025)` a une queue de durée qui franchit son budget `timeout-minutes: 10`"* — with six measurements at near-constant code (5m08, 6m52, 8m19, 9m21, 9m28, 9m38), factor 1.9, two cancellations and two green re-runs at the SAME SHA, and it names raising the budget as one of two options while taking neither. **M1.B adds 9m37 (passed, 23 seconds of headroom) and 11m28 (cancelled) and TAKES that option**: 10 → 20 minutes. M1.B also made the job heavier, measured rather than assumed — the step runs `zig build bench-ecs`, whose run step depends on the install step, so it compiles EVERY installed artifact, verified by deleting `zig-out/bin/` and watching `ecs-hybrid-crossover-bench` reappear beside `ecs-benchmark`. What the raise does NOT remove is the runner's intrinsic variance, which `M1.D.14`'s own analysis already establishes as the cause, nor the standing question of whether the Windows bench belongs in the PR matrix. *An earlier draft of this entry opened a parallel record under a new number; a debt treated under any name but its own stays open in the document that carries it.* **M1.D/S5 takes the option a second time, 20 → 30 minutes**: the Ubuntu job took 19m46s from an empty cache and 14m19s restored from its own lineage, its crossover sweep being 12 to 14 minutes of run time whatever the cache, and a cold Ubuntu run at `b71017ef` (2026-09-21) was cancelled at the 20-minute budget. -- **A CELL OF THE CI MATRIX HANGS, TEN TIMES MEASURED, AND THE CLASS HAS NO HOME IN THE CORPUS (opened at M1.B/G11, needs a number)**. Distinct from `M1.D.14`, which carries the DURATION of the bench job: this is the PENDING of a matrix cell that gates merges. **Cell:** `build-and-test (windows-2025, ReleaseSafe)`, both precisions. **Signature:** `error: test runner failed to respond for ~1m`, with **zero** occurrences of the sibling class `failed without output` — the two have never been co-present. **Count: TEN**, all on that cell, every one exonerated by a green re-run at the SAME SHA (three recorded before M1.B, two at M1.B/G10-G11, three at M1.B/P3-P4, one at M1.E/G12 on `6a2bb8e`, one at M1.E on `387ab97`). **The ninth, at M1.E/G12 on `6a2bb8e`, is the most informative the class has produced and it is the LARGEST BY AN ORDER**: `2164/2196 tests passed (32 skipped)` against a declared windows floor of 2250 gives **54 TESTS LOST**, where the previous counts were 1, 5, 6, 8 and 14. It ran 38.1 min against a 55-minute budget, so it is NOT the sibling timeout recorded below it — that one has every step green and no lost test — and the log carries ZERO `error: '…' failed:` lines, so no assertion fired. Fifty-four tests is a whole step of substance rather than a straggler, which narrows what can be hanging: the class is not a slow tail on a small step. **THE THIRTEENTH, at M1.A on `565012c`, hangs TWICE IN ONE RUN and the new discriminant reads BOTH**: signature present twice, sibling absent, zero assertions, `2253/2285 tests passed (32 skipped)` against a declared floor of 2288 — **THREE tests lost across two hung steps** — 52.6 minutes against 55 with conclusion `failure`. The two `failed command:` lines name `fecde19354ea9ccbd9ecb5d20cdb00ac` and `2fe9c3b586059afa4881744abc5715ef`: **two different executables in ONE build**, which is the within-run twin of the within-SHA comparison the twelfth produced. Four distinct identities are now recorded across three attempts and no two agree. **At M1.D/S5 on `db275303`, ONE identity hung in TWO cells of one attempt**: `06b3583a…` in `windows-2025 / ReleaseSafe` f64 (seed `0x21372d02`, its only hang, five tests lost) and f32 (seed `0x55397372`, beside `9fc23c50…` and `110fa824…`, eight lost). The two cells build that executable identically — thirteen test-executable hashes appear in both logs — so it is the same binary hanging twice. Both cells passed at the same sha on attempt 2. At `ae9ee612`, the next cold run, the f64 cell then failed THREE attempts in a row, on four identities none of which recurred (`ad84682e…`, `0a38a40e…`, `48f2ff74…`, `4738b31b…`). **Measured at M1.D/S5/G9 septies, nothing corrected**: `06b3583a…` is `tests/vk_gen/raw_variants.zig`, five compile-time checks; the repeat is chance, anomalies recurring at a tree position 14 times over 16 executions where random placement gives 13.8; over 992 windows jobs since 2026-08-25 the class hit 0 of 496 Debug jobs, 29 % of cold ReleaseSafe ones and 1.4 % of warm ones, on 61 different executables. Zig 0.16's windows spawn leaves the child's pipe ends inheritable with no handle list, the race its POSIX path closes with `CLOEXEC`; that mechanism fits every measurement and is not measured. **Confirmed at G9 octies**: `--test-timeout 180s` on the matrix `zig build test`, three cold runs, six windows ReleaseSafe executions — zero hangs, and four small executables lasting 60 to 119 s that passed. The timeout stays as the workaround of a Zig 0.16 defect; the upstream report is prepared in `briefs/artifacts/`, not filed. Detail in the brief. The series of losses is 1, 5, 6, 8, 14, 54, 2, 1, 3. **THE TWELFTH, at M1.A on `e054b26`, ties the smallest loss and adds a collateral the class did not carry**: `2255/2287 tests passed (32 skipped)` against a declared floor of 2288 gives **ONE test lost**, signature present once, sibling class absent, zero `error: '…' failed:` lines, and **53.0 minutes against a 55-minute budget with conclusion `failure` and not `cancelled`** — which excludes `M1.D.27` on both of its discriminants at once rather than on duration alone. The series of losses is now 1, 5, 6, 8, 14, 54, 2, 1. **The collateral is that two debts met on one job**: the run carries `Zig cache is 11222302720 bytes, over the 10737418240 cap — SKIPPING the final save`, which is `M1.D.10`'s mechanism, and a skipped final save leaves the NEXT run on that cell colder, which is `M1.D.27`'s trigger. Neither debt is new; their interaction is recorded nowhere else. **IT FAILED ITS FIRST SAME-SHA RE-RUN, and that re-run produced a THIRD DISCRIMINANT this entry records as impossible.** Attempt 2: signature once, sibling absent, zero assertions, `2256/2287 (31 skipped)` against 2288 — **one test lost again**, where every previous pair of failures at one SHA had lost DIFFERENT counts, which is the shape an implicated test would produce. Refuted by measurement: this entry states that *"naming the step from the log does not work"*, which is true of the step's SOURCE name and FALSE of its identity — **the `failed command:` line immediately following the `failed to respond` message carries the hung step's build-cache hash**, and the two attempts differ (`01a58047…` against `c810df3f…`). Two DIFFERENT executables hung at one SHA, so no test is implicated, and the equal loss explains itself: both hung steps sit in the contiguous family whose neighbours all report `0 pass, 1 skip (1 total)`, where a hang costs exactly one test whichever member it hits. *The count was never the discriminant; the identity is, and it is one grep away in every log this class has already produced.* **THE ELEVENTH, at M1.A on `3f22cf6`, is the smallest loss the class has produced and the cleanest measurement of it**: `2245/2277 tests passed (32 skipped)` against a declared floor of 2279 gives **2 TESTS LOST**, with the signature present once, the sibling class absent, zero `error: '…' failed:` lines, and 52.7 minutes against a 55-minute budget — so `M1.D.27` is excluded by duration and by conclusion (`fail`, not `cancelled`). The series of losses is now 1, 5, 6, 8, 14, 54 and 2, which continues to refute any single implicated test. Cleared by a re-run at the SAME SHA. **THE TENTH, at M1.E on `387ab97`, is the first to hang TWICE IN ONE RUN**: two `failed to respond` twelve minutes apart (19:49:14 and 20:01:15 UTC) on two DIFFERENT test executables, and `301/304 steps succeeded (2 failed)` accounts for exactly those two. **And the loss stopped following the step count**: `2217/2249 tests passed (32 skipped)` against the same 2250 floor gives **ONE test lost for TWO hung steps**, so at least one of the two cost no test at all — where the counts so far had been 1, 5, 6, 8, 14 and 54, always for a single step. That asymmetry is NOT explained here: the natural reading, a runner that blocks after its last result is already reported, is plausible and unmeasured, and this class has already paid for two hypotheses issued on a plausible reading. What it does strengthen is the `0664f28` discriminant — two different steps, in one run, on a commit whose diff is comments and two Markdown files. It ran 40.2 min, SHORTER than the sibling timeout because it aborted on the hang rather than finishing, carries ZERO `error: '…' failed:`, and exonerated on the FIRST re-run. **AT ONE SHA (`0664f28`) THE CELL FAILED THREE TIMES AND LOST THREE DIFFERENT COUNTS — 14, 1 and 5 tests — hence three different step sets.** That is a discriminant the class did not previously have, and it is the strongest evidence yet that no single test is implicated: a test that hangs deterministically blocks the same step every time and loses the same number. **And the frequency moved**: the five occurrences preceding that SHA each exonerated on the FIRST same-SHA re-run, where this one took two — f64 green on re-run 1, f32 failing again with a third lost count and green only on re-run 2. Two collateral facts from the same investigation: `pre-push` runs `zig build test -Doptimize=ReleaseSafe` (`lefthook.yml:31`), so the failing cell's MODE is green on the dev machine at every push; and pushing over an in-flight run marks that run `failure` with no evidence of its own (`aa7416c`), which is the recorded status-predicate hazard seen from the other side. **Two discriminants, and only one of them works today.** (1) `declared − collected` from the `Build Summary` line `--summary all` already produces: at the M1.B occurrence, `302/304 steps succeeded (1 failed); 2103/2135 tests passed` against a declared windows floor of 2143 gives **8 tests lost**, so the hung step holds eight — a SIZE, computed and not inferred. (2) Naming the step from the log **does not work**: a hung step emits no output at all, so the per-step summary that would name it is exactly what is missing, and `--log-failed` returns the aggregate. Naming it needs either a per-step timeout that identifies its target or a step-by-step run. What DOES survive is a negative discriminant used at G11: a step that printed its own report AND its `failed command:` artifact has COMPLETED, which is how the M1.B/G10 scheduler-dispatch tests were exonerated without a re-run. **The corpus carries no foyer for this**: the signature returns zero occurrences across the corpus (measured), so ten occurrences on a merge-gating cell live only in a succession of briefs. +- **`M1.D.14` gets its SEVENTH and EIGHTH measurements, and its first treatment (M1.B/G11)**. That plan row already carries this debt by name — *"le job `bench-ecs-smoke (windows-2025)` a une queue de durée qui franchit son budget `timeout-minutes: 10`"* — with six measurements at near-constant code (5m08, 6m52, 8m19, 9m21, 9m28, 9m38), factor 1.9, two cancellations and two green re-runs at the SAME SHA, and it names raising the budget as one of two options while taking neither. **M1.B adds 9m37 (passed, 23 seconds of headroom) and 11m28 (cancelled) and TAKES that option**: 10 → 20 minutes. M1.B also made the job heavier, measured rather than assumed — the step runs `zig build bench-ecs`, whose run step depends on the install step, so it compiles EVERY installed artifact, verified by deleting `zig-out/bin/` and watching `ecs-hybrid-crossover-bench` reappear beside `ecs-benchmark`. What the raise does NOT remove is the runner's intrinsic variance, which `M1.D.14`'s own analysis already establishes as the cause, nor the standing question of whether the Windows bench belongs in the PR matrix. *An earlier draft of this entry opened a parallel record under a new number; a debt treated under any name but its own stays open in the document that carries it.* Raised again to 30 minutes at M1.D/S5; detail in the brief. +- **A CELL OF THE CI MATRIX HANGS, TEN TIMES MEASURED, AND THE CLASS HAS NO HOME IN THE CORPUS (opened at M1.B/G11, needs a number)**. Distinct from `M1.D.14`, which carries the DURATION of the bench job: this is the PENDING of a matrix cell that gates merges. **Cell:** `build-and-test (windows-2025, ReleaseSafe)`, both precisions. **Signature:** `error: test runner failed to respond for ~1m`, with **zero** occurrences of the sibling class `failed without output` — the two have never been co-present. **Count: TEN**, all on that cell, every one exonerated by a green re-run at the SAME SHA (three recorded before M1.B, two at M1.B/G10-G11, three at M1.B/P3-P4, one at M1.E/G12 on `6a2bb8e`, one at M1.E on `387ab97`). **The ninth, at M1.E/G12 on `6a2bb8e`, is the most informative the class has produced and it is the LARGEST BY AN ORDER**: `2164/2196 tests passed (32 skipped)` against a declared windows floor of 2250 gives **54 TESTS LOST**, where the previous counts were 1, 5, 6, 8 and 14. It ran 38.1 min against a 55-minute budget, so it is NOT the sibling timeout recorded below it — that one has every step green and no lost test — and the log carries ZERO `error: '…' failed:` lines, so no assertion fired. Fifty-four tests is a whole step of substance rather than a straggler, which narrows what can be hanging: the class is not a slow tail on a small step. **THE THIRTEENTH, at M1.A on `565012c`, hangs TWICE IN ONE RUN and the new discriminant reads BOTH**: signature present twice, sibling absent, zero assertions, `2253/2285 tests passed (32 skipped)` against a declared floor of 2288 — **THREE tests lost across two hung steps** — 52.6 minutes against 55 with conclusion `failure`. The two `failed command:` lines name `fecde19354ea9ccbd9ecb5d20cdb00ac` and `2fe9c3b586059afa4881744abc5715ef`: **two different executables in ONE build**, which is the within-run twin of the within-SHA comparison the twelfth produced. Four distinct identities are now recorded across three attempts and no two agree. **CLOSED at M1.D/S5 as `M1.D.19`**: a Zig 0.16 defect — the windows spawn leaves a test's pipe ends inheritable, so a concurrent compiler holds its end of stream — worked around by `--test-timeout 180s` on the matrix `zig build test`. Detail in the brief; the upstream report, not filed, is in `briefs/artifacts/`. The series of losses is 1, 5, 6, 8, 14, 54, 2, 1, 3. **THE TWELFTH, at M1.A on `e054b26`, ties the smallest loss and adds a collateral the class did not carry**: `2255/2287 tests passed (32 skipped)` against a declared floor of 2288 gives **ONE test lost**, signature present once, sibling class absent, zero `error: '…' failed:` lines, and **53.0 minutes against a 55-minute budget with conclusion `failure` and not `cancelled`** — which excludes `M1.D.27` on both of its discriminants at once rather than on duration alone. The series of losses is now 1, 5, 6, 8, 14, 54, 2, 1. **The collateral is that two debts met on one job**: the run carries `Zig cache is 11222302720 bytes, over the 10737418240 cap — SKIPPING the final save`, which is `M1.D.10`'s mechanism, and a skipped final save leaves the NEXT run on that cell colder, which is `M1.D.27`'s trigger. Neither debt is new; their interaction is recorded nowhere else. **IT FAILED ITS FIRST SAME-SHA RE-RUN, and that re-run produced a THIRD DISCRIMINANT this entry records as impossible.** Attempt 2: signature once, sibling absent, zero assertions, `2256/2287 (31 skipped)` against 2288 — **one test lost again**, where every previous pair of failures at one SHA had lost DIFFERENT counts, which is the shape an implicated test would produce. Refuted by measurement: this entry states that *"naming the step from the log does not work"*, which is true of the step's SOURCE name and FALSE of its identity — **the `failed command:` line immediately following the `failed to respond` message carries the hung step's build-cache hash**, and the two attempts differ (`01a58047…` against `c810df3f…`). Two DIFFERENT executables hung at one SHA, so no test is implicated, and the equal loss explains itself: both hung steps sit in the contiguous family whose neighbours all report `0 pass, 1 skip (1 total)`, where a hang costs exactly one test whichever member it hits. *The count was never the discriminant; the identity is, and it is one grep away in every log this class has already produced.* **THE ELEVENTH, at M1.A on `3f22cf6`, is the smallest loss the class has produced and the cleanest measurement of it**: `2245/2277 tests passed (32 skipped)` against a declared floor of 2279 gives **2 TESTS LOST**, with the signature present once, the sibling class absent, zero `error: '…' failed:` lines, and 52.7 minutes against a 55-minute budget — so `M1.D.27` is excluded by duration and by conclusion (`fail`, not `cancelled`). The series of losses is now 1, 5, 6, 8, 14, 54 and 2, which continues to refute any single implicated test. Cleared by a re-run at the SAME SHA. **THE TENTH, at M1.E on `387ab97`, is the first to hang TWICE IN ONE RUN**: two `failed to respond` twelve minutes apart (19:49:14 and 20:01:15 UTC) on two DIFFERENT test executables, and `301/304 steps succeeded (2 failed)` accounts for exactly those two. **And the loss stopped following the step count**: `2217/2249 tests passed (32 skipped)` against the same 2250 floor gives **ONE test lost for TWO hung steps**, so at least one of the two cost no test at all — where the counts so far had been 1, 5, 6, 8, 14 and 54, always for a single step. That asymmetry is NOT explained here: the natural reading, a runner that blocks after its last result is already reported, is plausible and unmeasured, and this class has already paid for two hypotheses issued on a plausible reading. What it does strengthen is the `0664f28` discriminant — two different steps, in one run, on a commit whose diff is comments and two Markdown files. It ran 40.2 min, SHORTER than the sibling timeout because it aborted on the hang rather than finishing, carries ZERO `error: '…' failed:`, and exonerated on the FIRST re-run. **AT ONE SHA (`0664f28`) THE CELL FAILED THREE TIMES AND LOST THREE DIFFERENT COUNTS — 14, 1 and 5 tests — hence three different step sets.** That is a discriminant the class did not previously have, and it is the strongest evidence yet that no single test is implicated: a test that hangs deterministically blocks the same step every time and loses the same number. **And the frequency moved**: the five occurrences preceding that SHA each exonerated on the FIRST same-SHA re-run, where this one took two — f64 green on re-run 1, f32 failing again with a third lost count and green only on re-run 2. Two collateral facts from the same investigation: `pre-push` runs `zig build test -Doptimize=ReleaseSafe` (`lefthook.yml:31`), so the failing cell's MODE is green on the dev machine at every push; and pushing over an in-flight run marks that run `failure` with no evidence of its own (`aa7416c`), which is the recorded status-predicate hazard seen from the other side. **Two discriminants, and only one of them works today.** (1) `declared − collected` from the `Build Summary` line `--summary all` already produces: at the M1.B occurrence, `302/304 steps succeeded (1 failed); 2103/2135 tests passed` against a declared windows floor of 2143 gives **8 tests lost**, so the hung step holds eight — a SIZE, computed and not inferred. (2) Naming the step from the log **does not work**: a hung step emits no output at all, so the per-step summary that would name it is exactly what is missing, and `--log-failed` returns the aggregate. Naming it needs either a per-step timeout that identifies its target or a step-by-step run. What DOES survive is a negative discriminant used at G11: a step that printed its own report AND its `failed command:` artifact has COMPLETED, which is how the M1.B/G10 scheduler-dispatch tests were exonerated without a re-run. **The corpus carries no foyer for this**: the signature returns zero occurrences across the corpus (measured), so ten occurrences on a merge-gating cell live only in a succession of briefs. - **`win32_thread_safety_test` TIMES OUT on `windows-2025 / Debug`, and it is a DISTINCT class from the hang above (second occurrence at M1.A, needs a number)**. Not `M1.D.19`: the hang signature is ABSENT, no test is lost (collected equals the floor exactly), and ONE assertion genuinely fires — `error.Win32ThreadSafetyTimeout` at `tests/platform/win32_thread_safety_test.zig:99`, a wall-clock bound on three threads doing 100 `createWindow`/`destroyWindow` pairs each against a 30 s budget. The job takes ~10 minutes, not the ~50 of a hang. **The decisive discriminant is stronger than the sibling-cell one and is general to every flake class: A DIFF WITH NO ZIG IN IT.** The second occurrence landed on a commit whose diff is two Markdown files and seven lines, on top of a commit where that same cell had passed — so no code changed between the green and the red, and the commit cannot be the cause. The sibling `Debug / f64` cell passing at the same SHA corroborates it and is weaker: Win32 windowing has nothing to do with the physics scalar, so that axis could not discriminate a real fault either. **It is NOT the class M1.1.9 retired from `crash_recovery.zig`**, and the difference is worth keeping: those assertions ran AFTER the blocking receive returned and therefore guarded nothing, while this loop BOUNDS a wait and abandons it — a correctly shaped hang guard. What fires is its BUDGET on a runner the suite itself saturates. Removing it would remove a real guard; raising it weakens the bound it exists to give. Owner: unassigned. @@ -132,7 +132,7 @@ commit, so a milestone still in review has no row here. - **`job_bound.reasonOf` was not widened with the walk, so the guard refuses correctly and explains nothing in the one case it exists for (opened at M1.E/G11, needs a number)**. `carriesMarkedIn` enters every composite — pointer, array, vector, optional, error union, and each field of a struct or union — while `reasonOf` follows only `.pointer` and `.optional` and answers `"no reason declared"` for everything else (`job_bound.zig:168-172`). `refuseMarkedArgs` reports `reasonOf(f.type)` on the OUTER field type, so for `SystemContext` carrying `cmd: *CommandBuffer` — **the exact shape the widening was written for** — the compile error names the type and gives no reason. The file's own doctrine, written at `carriesMarkedIn`, is that "widening only to struct fields would have repeated the class this reprise exists to close — a rule applied to a subset of what it must cover"; its sibling function IS that subset. The asymmetry is now stated at the marker's doc. The symmetric widening changes a compile-error message and is therefore behaviour. Owner: whoever owns the bound. - **The `tests/` subtree is OUTSIDE the comment rules by decision, and its size is now measured (M1.E/G11)**. `comment_scan.inPerimeter` returns false for any path whose FIRST segment is `tests`, with the reason written at the function: `src/`, `tools/` and `bench/` carry a conservation pass that gives a reworded comment somewhere to go, and `tests/` does not, so firing there would make the rule green only by leaving those files permanently red. **MEASURED WITH THE BINARY ITSELF, the perimeter exclusion lifted in a throwaway build: 732 diagnostics on 519 distinct comment lines in 146 files** — 494 lines carrying a milestone/gate/step/spike/review identifier and 25 more carrying a phase mention and no identifier. That is 2.6× `build.zig`'s entire diagnostic count, and the largest single unswept perimeter the comment work leaves. Seven of the twelve remaining word-provenance rows live there too. **An earlier figure of 492 lines was published here and is WRONG TWICE**: it was the `comment_identifiers` count alone where the honest quantity is all-rule, and it came from a Python transcription of the rule rather than from the rule — a transcription that tests only lines whose `strip()` starts with `//`, so it misses a comment trailing a line of code, and that counts the `tests/lint/bad/` fixtures the walker deliberately skips. **Measure with the instrument, not with a replica of it, when the instrument is one build away.** Owner: the pass that reads `tests/`. - **macOS in the CI matrix**: deferred, re-evaluated after Phase 0 (CI quota constraints, primary targets are Win11 + Fedora 44). -- **Codeberg migration: abandoned** (Guy, 2026-09-25) — Codeberg does not accept repositories made with AI. The repo stays on GitHub; criterion C1.10 in `engine-phase-1-criteria.md` still names the migration and is Guy's to revise. +- **Codeberg is discarded** since 2026-07-22; the repo stays on GitHub and the fallback is a self-hosted Forgejo on the project's own runners (`engine-phase-1-criteria.md` C1.10). - **`spec/` directory in the repo**: out of scope for Phase −1. Spec lives in the claude.ai knowledge base; re-evaluated during Phase 0 if the absence creates friction. - **Phase 0.6 IPC debts (SCM_RIGHTS fd-passing + editor Windows path)**: resolved in M0.7 (`v0.7.0-M0.7-ipc`). SCM_RIGHTS is the primary POSIX shm attach (create fd over AF_UNIX, runtime `mmap`s the received fd, sidestepping the macOS BSD shm quirk); the editor's Windows `CreateProcessW` + named-pipe path is wired (`src/editor/main.zig` no longer returns `error.Unimplemented`). - **`sendWithHandles` Windows (Phase 3)**: `transport_windows.zig:sendWithHandles` returns `error.Unimplemented`. The `DuplicateHandle`-based equivalent lands with the GPU shared framebuffer when an exportable Vulkan semaphore appears upstream (cf. `engine-ipc.md` §4.7). diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index 647973bf..11f54956 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -6097,12 +6097,6 @@ green when the bench ran green or was skipped by inheritance, and it loses its `paths-ignore`. One consequence is behaviour: a documentation-only push to `main` now runs the bench, where `paths-ignore` skipped it, since a push always runs. -#### For the migration, not now - -Codeberg's hosted runners cap a job at 10 minutes, where these cells take 28 to -56. Bascule C1.10 will have to meet it: self-hosted runners, whose cache is local -to each runner unless they share an external cache server. - #### Tests removed or changed None removed. Twenty-eight added, `tools/ci_verdict`. From 718a48f4964233900d20186cdf3a1983d0ca06bd Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Fri, 25 Sep 2026 15:58:50 +0200 Subject: [PATCH 067/141] fix(etch): keep nested field runs contiguous, refuse struct event fields Emit bodies, event payload filters, effect emitters, audio score sections and anim body sub-blocks appended each field to struct_lit_fields as it parsed, so a nested struct literal committed its own fields inside the outer run; a tuple-like enum variant did the same in extra around a generic type. They now buffer and commit once, as parseStructLiteral does. An event field of struct type is refused at the declaration with E0102, whatever the declaration order, through an alias or on the builtin Error. Floor 2624 -> 2632 on macOS, 2622 -> 2630 on windows, read from the suite. Co-Authored-By: Claude Opus 5.5 --- src/etch/parser.zig | 163 +++++++++++++++++++++++++++++---- src/etch/types.zig | 86 ++++++++++++++--- tools/weld_lint/dead_tests.zig | 4 +- 3 files changed, 218 insertions(+), 35 deletions(-) diff --git a/src/etch/parser.zig b/src/etch/parser.zig index 9dff3d78..c2b2cd33 100644 --- a/src/etch/parser.zig +++ b/src/etch/parser.zig @@ -1779,16 +1779,19 @@ pub const Parser = struct { // as a run of type-`NodeId`s in `arena.extra`. _ = try self.advance(); // '(' shape = .tuple_like; - data_start = @intCast(self.arena.extra.items.len); + var types: std.ArrayListUnmanaged(u32) = .empty; + defer types.deinit(self.gpa); if (self.peek() != .rparen) { while (true) { const t = try self.parseType(); - try self.arena.extra.append(self.gpa, t.raw()); + try types.append(self.gpa, t.raw()); if (!try self.match(.comma)) break; } } _ = try self.expect(.rparen, "expected ')' to close tuple-like enum variant"); - data_len = @as(u32, @intCast(self.arena.extra.items.len)) - data_start; + data_start = @intCast(self.arena.extra.items.len); + try self.arena.extra.appendSlice(self.gpa, types.items); + data_len = @intCast(types.items.len); }, else => {}, } @@ -2622,17 +2625,20 @@ pub const Parser = struct { else => return self.parseErr(self.peekSpan(), "expected emitter name (identifier) after 'emitter'"), }; _ = try self.expect(.lbrace, "expected '{' to start the emitter body"); - const props_start: u32 = @intCast(self.arena.struct_lit_fields.items.len); + var props: std.ArrayListUnmanaged(ast_mod.StructLitField) = .empty; + defer props.deinit(self.gpa); while (self.peek() != .rbrace and self.peek() != .eof) { try self.surfaceTokenErrors(); const pname = try self.expect(.ident, "expected an emitter property name (identifier)"); _ = try self.expect(.colon, "expected ':' after the emitter property name"); const value = try self.parseExpr(0); - try self.arena.struct_lit_fields.append(self.gpa, .{ .name = try self.internSlice(pname.span), .value = value }); + try props.append(self.gpa, .{ .name = try self.internSlice(pname.span), .value = value }); _ = try self.match(.comma); // properties are newline-separated; tolerate an optional comma } const closing = try self.expect(.rbrace, "expected '}' to close the emitter body"); - const props_len: u32 = @as(u32, @intCast(self.arena.struct_lit_fields.items.len)) - props_start; + const props_start: u32 = @intCast(self.arena.struct_lit_fields.items.len); + try self.arena.struct_lit_fields.appendSlice(self.gpa, props.items); + const props_len: u32 = @intCast(props.items.len); try self.arena.effect_emitters.append(self.gpa, .{ .name = try self.internSlice(name_tok.span), .props_start = props_start, @@ -2801,7 +2807,8 @@ pub const Parser = struct { else => return self.parseErr(self.peekSpan(), "expected a section name (identifier) after 'section'"), }; _ = try self.expect(.lbrace, "expected '{' to start the section body"); - const props_start: u32 = @intCast(self.arena.struct_lit_fields.items.len); + var props: std.ArrayListUnmanaged(ast_mod.StructLitField) = .empty; + defer props.deinit(self.gpa); const targets_start: u32 = @intCast(self.arena.audio_score_targets.items.len); var on_finish: StringId = 0; var has_on_finish = false; @@ -2811,7 +2818,7 @@ pub const Parser = struct { const loop_tok = try self.advance(); // 'loop' (kw_loop token kind) _ = try self.expect(.colon, "expected ':' after 'loop'"); const value = try self.parseExpr(0); - try self.arena.struct_lit_fields.append(self.gpa, .{ .name = try self.internSlice(loop_tok.span), .value = value }); + try props.append(self.gpa, .{ .name = try self.internSlice(loop_tok.span), .value = value }); } else { const key_tok = try self.expect(.ident, "expected a section property name"); const key = self.sliceOf(key_tok.span); @@ -2839,16 +2846,18 @@ pub const Parser = struct { } else { _ = try self.expect(.colon, "expected ':' after the section property name"); const value = try self.parseExpr(0); - try self.arena.struct_lit_fields.append(self.gpa, .{ .name = try self.internSlice(key_tok.span), .value = value }); + try props.append(self.gpa, .{ .name = try self.internSlice(key_tok.span), .value = value }); } } _ = try self.match(.comma); } const closing = try self.expect(.rbrace, "expected '}' to close the section body"); + const props_start: u32 = @intCast(self.arena.struct_lit_fields.items.len); + try self.arena.struct_lit_fields.appendSlice(self.gpa, props.items); try self.arena.audio_score_sections.append(self.gpa, .{ .name = try self.internSlice(name_tok.span), .props_start = props_start, - .props_len = @as(u32, @intCast(self.arena.struct_lit_fields.items.len)) - props_start, + .props_len = @intCast(props.items.len), .can_transition_start = targets_start, .can_transition_len = @as(u32, @intCast(self.arena.audio_score_targets.items.len)) - targets_start, .on_finish = on_finish, @@ -3077,17 +3086,20 @@ pub const Parser = struct { /// warping / distance_matching bodies) into a `struct_lit_fields` run. fn parseAnimKeyExprBlock(self: *Parser) ParseError!struct { start: u32, len: u32 } { _ = try self.expect(.lbrace, "expected '{' to start the body sub-block"); - const start: u32 = @intCast(self.arena.struct_lit_fields.items.len); + var props: std.ArrayListUnmanaged(ast_mod.StructLitField) = .empty; + defer props.deinit(self.gpa); while (self.peek() != .rbrace and self.peek() != .eof) { try self.surfaceTokenErrors(); const pk = try self.expect(.ident, "expected a property name in the body sub-block"); _ = try self.expect(.colon, "expected ':' after the property name"); const val = try self.parseExpr(0); - try self.arena.struct_lit_fields.append(self.gpa, .{ .name = try self.internSlice(pk.span), .value = val }); + try props.append(self.gpa, .{ .name = try self.internSlice(pk.span), .value = val }); _ = try self.match(.comma); } _ = try self.expect(.rbrace, "expected '}' to close the body sub-block"); - return .{ .start = start, .len = @as(u32, @intCast(self.arena.struct_lit_fields.items.len)) - start }; + const start: u32 = @intCast(self.arena.struct_lit_fields.items.len); + try self.arena.struct_lit_fields.appendSlice(self.gpa, props.items); + return .{ .start = start, .len = @intCast(props.items.len) }; } /// `anim_state = "state" IDENT "{" {anim_state_prop} "}"` (§11). Exactly one @@ -5731,7 +5743,8 @@ pub const Parser = struct { const saved = self.no_struct_lit; self.no_struct_lit = false; defer self.no_struct_lit = saved; - const fields_start: u32 = @intCast(self.arena.struct_lit_fields.items.len); + var fields: std.ArrayListUnmanaged(ast_mod.StructLitField) = .empty; + defer fields.deinit(self.gpa); while (self.peek() != .rbrace and self.peek() != .eof) { if (self.peek() == .dotdot) { return self.parseErr(self.peekSpan(), "emit-body spread '..base' is not supported in M0.8 (data-table feature, E4)"); @@ -5739,11 +5752,13 @@ pub const Parser = struct { const fname = try self.expect(.ident, "expected field name in emit body"); _ = try self.expect(.colon, "expected ':' after emit-body field name"); const value = try self.parseExpr(0); - try self.arena.struct_lit_fields.append(self.gpa, .{ .name = try self.internSlice(fname.span), .value = value }); + try fields.append(self.gpa, .{ .name = try self.internSlice(fname.span), .value = value }); if (!try self.match(.comma)) break; } const closing = try self.expect(.rbrace, "expected '}' to close the emitted event body"); - const fields_len: u32 = @as(u32, @intCast(self.arena.struct_lit_fields.items.len)) - fields_start; + const fields_start: u32 = @intCast(self.arena.struct_lit_fields.items.len); + try self.arena.struct_lit_fields.appendSlice(self.gpa, fields.items); + const fields_len: u32 = @intCast(fields.items.len); return try self.arena.addEmitStmt(self.gpa, .{ .event_type = event_type, .fields_start = fields_start, @@ -6696,7 +6711,8 @@ pub const Parser = struct { const saved = self.no_struct_lit; self.no_struct_lit = false; defer self.no_struct_lit = saved; - const start: u32 = @intCast(self.arena.struct_lit_fields.items.len); + var fields: std.ArrayListUnmanaged(ast_mod.StructLitField) = .empty; + defer fields.deinit(self.gpa); while (self.peek() != .rbrace and self.peek() != .eof) { if (self.peek() == .dotdot) { return self.parseErr(self.peekSpan(), "event payload-filter spread '..base' is not supported (the filter is field-equality only)"); @@ -6704,12 +6720,13 @@ pub const Parser = struct { const fname = try self.expect(.ident, "expected field name in event payload filter"); _ = try self.expect(.colon, "expected ':' after payload-filter field name"); const value = try self.parseExpr(0); - try self.arena.struct_lit_fields.append(self.gpa, .{ .name = try self.internSlice(fname.span), .value = value }); + try fields.append(self.gpa, .{ .name = try self.internSlice(fname.span), .value = value }); if (!try self.match(.comma)) break; } _ = try self.expect(.rbrace, "expected '}' to close the event payload filter"); - const len: u32 = @as(u32, @intCast(self.arena.struct_lit_fields.items.len)) - start; - return .{ .start = start, .len = len }; + const start: u32 = @intCast(self.arena.struct_lit_fields.items.len); + try self.arena.struct_lit_fields.appendSlice(self.gpa, fields.items); + return .{ .start = start, .len = @intCast(fields.items.len) }; } /// Parse `await ` (`etch-grammar.md` §4.2 @@ -8958,6 +8975,112 @@ test "parser keeps data entry field runs contiguous around nested struct literal try std.testing.expectEqualStrings("hp", result.ast.strings.slice(f1.name)); } +fn expectFieldRun(ast: *const ast_mod.AstArena, start: u32, len: u32, names: []const []const u8) !void { + try std.testing.expectEqual(@as(u32, @intCast(names.len)), len); + for (names, 0..) |name, i| { + try std.testing.expectEqualStrings(name, ast.strings.slice(ast.struct_lit_fields.items[start + i].name)); + } +} + +fn parseClean(gpa: std.mem.Allocator, source: []const u8) !ParseResult { + var result = try parse(gpa, source); + if (result.diagnostics.len > 0) { + std.debug.print("unexpected parse diagnostic: {s}\n", .{result.diagnostics[0].primary_message}); + result.deinit(gpa); + return error.TestUnexpectedResult; + } + return result; +} + +test "an emit keeps its field run contiguous around a nested struct literal" { + const gpa = std.testing.allocator; + var result = try parseClean(gpa, + \\rule r(entity: Entity) when entity has Health { + \\ emit Hit { a: 1, p: Payload { v: 5 }, b: 2 } + \\} + ); + defer result.deinit(gpa); + const em = result.ast.emit_stmts.items[0]; + try expectFieldRun(&result.ast, em.fields_start, em.fields_len, &.{ "a", "p", "b" }); +} + +test "an event payload filter keeps its field run contiguous around a nested struct literal" { + const gpa = std.testing.allocator; + var result = try parseClean(gpa, + \\async rule r(entity: Entity) when entity has Health { + \\ await global_event(Hit { a: 1, p: Payload { v: 5 }, b: 2 }) + \\ await entity_event(entity, Hit { a: 1, p: Payload { v: 5 }, b: 2 }) + \\} + ); + defer result.deinit(gpa); + try std.testing.expectEqual(@as(usize, 2), result.ast.await_exprs.items.len); + for (result.ast.await_exprs.items) |aw| { + try expectFieldRun(&result.ast, aw.filter_start, aw.filter_len, &.{ "a", "p", "b" }); + } +} + +test "an effect emitter keeps its property run contiguous around a nested struct literal" { + const gpa = std.testing.allocator; + var result = try parseClean(gpa, + \\effect Burst { + \\ emitter Flash { + \\ burst: 1 + \\ shape: Shape { r: 2.0 } + \\ lifetime: 0.1 + \\ } + \\} + ); + defer result.deinit(gpa); + const em = result.ast.effect_emitters.items[0]; + try expectFieldRun(&result.ast, em.props_start, em.props_len, &.{ "burst", "shape", "lifetime" }); +} + +test "an audio score section keeps its property run contiguous around a nested struct literal" { + const gpa = std.testing.allocator; + var result = try parseClean(gpa, + \\audio_score "s" { + \\ section Calm { + \\ intro: Clip { path: "a.ogg" } + \\ loop: true + \\ } + \\} + ); + defer result.deinit(gpa); + const section = result.ast.audio_score_sections.items[0]; + try expectFieldRun(&result.ast, section.props_start, section.props_len, &.{ "intro", "loop" }); +} + +test "an anim body sub-block keeps its property run contiguous around a nested struct literal" { + const gpa = std.testing.allocator; + var result = try parseClean(gpa, + \\anim_graph G { + \\ state S { + \\ motion_matching { + \\ database: Db { name: "x" } + \\ blend_time: 0.2s + \\ } + \\ } + \\} + ); + defer result.deinit(gpa); + const state = result.ast.anim_states.items[0]; + try expectFieldRun(&result.ast, state.body_props_start, state.body_props_len, &.{ "database", "blend_time" }); +} + +test "a tuple-like enum variant keeps its type run contiguous around a generic type" { + const gpa = std.testing.allocator; + var result = try parseClean(gpa, + \\enum E { v(Box, int) } + ); + defer result.deinit(gpa); + const variant = result.ast.enum_variants.items[0]; + try std.testing.expectEqual(@as(u32, 2), variant.data_len); + const first: NodeId = @bitCast(result.ast.extra.items[variant.data_start]); + const second: NodeId = @bitCast(result.ast.extra.items[variant.data_start + 1]); + try std.testing.expectEqual(ast_mod.TypeNodeKind.generic, result.ast.typeNodeKind(first)); + try std.testing.expectEqualStrings("int", result.ast.strings.slice(result.ast.namedTypeName(second).?)); +} + test "parser accepts a PascalCase data entry id, recorded for E1768" { const gpa = std.testing.allocator; var result = try parse(gpa, diff --git a/src/etch/types.zig b/src/etch/types.zig index 7d815a1c..45407cd3 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -3386,8 +3386,8 @@ pub const TypeChecker = struct { // `etch-abi-zig.md` §3.1/§3.2 passes non-POD by handle), and // `etch-grammar.md` §5.10 imposes no POD constraint. POD-strict is // component-only (part1 §5.5, SoA storage). Event fields therefore - // follow the `struct` field surface (`string`/enum accepted, - // nested-struct deferred) via the `.event_` origin. + // follow the `struct` field surface (`string`/enum accepted) + // via the `.event_` origin; a struct-typed event field is refused. const decl = self.arena.event_decls.items[data]; try self.registerSymbol(.event_, decl.name, item_id, span); try self.validateAnnotations(decl.annotations_extra, decl.annotations_len, .event); @@ -3850,10 +3850,10 @@ pub const TypeChecker = struct { /// non-POD surface with `struct_` / `event_` for `string` (the /// alignment; part1 §5.5 "no POD /// constraint for resources"); enum-typed resource fields are accepted too, - /// nested-struct fields stay deferred. `struct_` and `event_` accept `string` + /// nested-struct fields stay refused. `struct_` and `event_` accept `string` /// + enum-typed fields (the builtin `Error` forces them on structs; events - /// carry frame-arena non-POD payloads per `etch-memory-model.md` §6.7 / §2.5). - /// POD-strict is component-only. + /// carry frame-arena non-POD payloads per `etch-memory-model.md` §6.7 / §2.5); + /// only `struct_` accepts a struct-typed field. POD-strict is component-only. const FieldDeclOrigin = enum { component_like, resource, struct_, event_ }; fn validateFieldsInDecl(self: *TypeChecker, fields_start: u32, fields_len: u32, origin: FieldDeclOrigin) !void { @@ -3978,14 +3978,14 @@ pub const TypeChecker = struct { // against the AST enum slab (not the symbol table) so a // later-declared enum is seen — pass 1 registers symbols // incrementally. Components stay enum-rejected (POD-strict). - } else if ((origin == .struct_ or origin == .event_) and self.declaredStructName(resolved_name)) { - // Struct-typed STRUCT / event fields are deferred: the - // anonymous `.{ … }` field-value context - // carries them — part1 §5.5 allows nested POD structs; the - // literal must PROVIDE such a field (E0208, checked at the - // struct literal) because it has no declared default the two - // backends could agree on. Component / resource fields stay - // builtin-POD-bounded. + } else if (origin == .event_ and self.declaredStructName(resolved_name)) { + try self.emit(.undefined_symbol, .error_, tspan, "an event field cannot be of struct type '{s}'", .{tname}); + } else if (origin == .struct_ and self.declaredStructName(resolved_name)) { + // Struct-typed struct fields: the anonymous `.{ … }` + // field-value context carries them — part1 §5.5 allows + // nested POD structs; the literal must PROVIDE such a field + // (E0208, checked at the struct literal) because it has no + // declared default the two backends could agree on. } else if (self.symbols.get(resolved_name)) |sym| { if (sym.kind == .rule) { try self.emit(.undefined_symbol, .error_, tspan, "type '{s}' is not a component, resource, or builtin", .{tname}); @@ -13544,6 +13544,66 @@ test "a scalar event filter is still accepted, and only a string can reach one" try expectAnyCode(arr.diagnostics.items, .undefined_symbol); } +fn expectStructEventFieldRefused(gpa: std.mem.Allocator, source: []const u8) !void { + var outcome = try parseAndCheck(gpa, source); + defer outcome.deinit(gpa); + var refused: usize = 0; + for (outcome.diagnostics.items) |d| { + if (d.code == .undefined_symbol and std.mem.indexOf(u8, d.primary_message, "cannot be of struct type") != null) refused += 1; + } + try std.testing.expectEqual(@as(usize, 1), refused); +} + +test "an event field of struct type is refused at the declaration" { + const gpa = std.testing.allocator; + try expectStructEventFieldRefused(gpa, + \\struct P { v: int } + \\event E { p: P } + ); + try expectStructEventFieldRefused(gpa, + \\event E { p: P } + \\struct P { v: int } + ); + try expectStructEventFieldRefused(gpa, + \\struct P { v: int } + \\type Q = P + \\event E { q: Q } + ); + try expectStructEventFieldRefused(gpa, + \\event E { e: Error } + ); + var nested = try parseAndCheck(gpa, + \\struct P { v: int } + \\struct S { p: P } + ); + defer nested.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), nested.diagnostics.items.len); +} + +test "an emit value holding a nested struct literal is checked against the event" { + const gpa = std.testing.allocator; + var emitted = try parseAndCheck(gpa, + \\component C { out: int = 0 } + \\struct Payload { v: int } + \\event Hit { n: int = 0 } + \\rule r(entity: Entity) when entity has C { + \\ emit Hit { n: Payload { v: 5 }.v } + \\} + ); + defer emitted.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), emitted.diagnostics.items.len); + var filtered = try parseAndCheck(gpa, + \\component C { out: int = 0 } + \\struct Payload { v: int } + \\event Hit { n: int = 0 } + \\async rule r(entity: Entity) when entity has C { + \\ await global_event(Hit { n: Payload { v: 5 }.v }) + \\} + ); + defer filtered.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), filtered.diagnostics.items.len); +} + test "a component or resource may not take a name the engine registers" { const gpa = std.testing.allocator; diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 3242a1a2..8a1b7504 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2622, - else => 2624, + .windows => 2630, + else => 2632, }; } From 6ee051c61764d3f6b49a3ebd35b4310a92a6c86c Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Fri, 25 Sep 2026 16:13:11 +0200 Subject: [PATCH 068/141] fix(etch): refuse entity.get of a name that is not a component The receiver form accepted a struct, an event or an undeclared name. In a rule its field then indexed component_decls with another slab's index, out of bounds once that index passed the component count; in a test body it passed with no diagnostic. It now answers E0102, as the resource form already did. Co-Authored-By: Claude Opus 5.5 --- src/etch/types.zig | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/etch/types.zig b/src/etch/types.zig index 45407cd3..84956591 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -6493,6 +6493,13 @@ pub const TypeChecker = struct { try self.emit(.component_expected_resource_given, .error_, self.arena.exprSpan(id), "'{s}' is a resource — entity.get(...) accesses a component; use get({s})", .{ tname, tname }); return ResolvedType.unknown; } + if (sym.kind != .component) { + try self.emit(.undefined_symbol, .error_, self.arena.exprSpan(id), "'{s}' is not a component", .{tname}); + return ResolvedType.unknown; + } + } else { + try self.emit(.undefined_symbol, .error_, self.arena.exprSpan(id), "unknown component '{s}'", .{tname}); + return ResolvedType.unknown; } if (ctx_opt) |ctx| { if (!ctx.unrestricted_ecs_access and !ctx.components_in_when.contains(mg.type_name)) { @@ -13604,6 +13611,43 @@ test "an emit value holding a nested struct literal is checked against the event try std.testing.expectEqual(@as(usize, 0), filtered.diagnostics.items.len); } +fn countMessage(diagnostics: []const Diagnostic, code: DiagnosticCode, needle: []const u8) usize { + var n: usize = 0; + for (diagnostics) |d| { + if (d.code == code and std.mem.indexOf(u8, d.primary_message, needle) != null) n += 1; + } + return n; +} + +test "entity.get of a struct is refused as not a component" { + const gpa = std.testing.allocator; + var outcome = try parseAndCheck(gpa, + \\struct A0 { x: int } + \\struct A1 { x: int } + \\struct S { v: int } + \\component C { out: int = 0 } + \\rule r(entity: Entity) when entity has C { + \\ let x = entity.get(S).v + \\} + ); + defer outcome.deinit(gpa); + try std.testing.expectEqual(@as(usize, 1), countMessage(outcome.diagnostics.items, .undefined_symbol, "'S' is not a component")); +} + +test "entity.get of an undeclared name is refused in an unrestricted body" { + const gpa = std.testing.allocator; + var outcome = try parseAndCheck(gpa, + \\component C { out: int = 0 } + \\test "t" { + \\ let w = test_world() + \\ let e = w.spawn_with([C { out: 1 }]) + \\ let v = e.get(Nope).out + \\} + ); + defer outcome.deinit(gpa); + try std.testing.expectEqual(@as(usize, 1), countMessage(outcome.diagnostics.items, .undefined_symbol, "unknown component 'Nope'")); +} + test "a component or resource may not take a name the engine registers" { const gpa = std.testing.allocator; From e5cd09199522869b96548e2a3e48f96469449d8f Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Fri, 25 Sep 2026 16:13:15 +0200 Subject: [PATCH 069/141] fix(etch): compare runtime strings by bytes, read resource enums typed Value.eql compares a .string_id by pool id and never matches a .string_run, so a match literal arm missed a runtime-built or resource string and a rule-arena map or set duplicated and lost string keys. One comparator, valueEql, now serves every runtime string comparison. Interpolation, Set.from and slicing accept a resource string or array, each element copied into an arena value taking its reference. A resource filter bound every field through the byte-only decoder and reached its enum unreachable; readFieldValue binds an enum typed. And newRunString freed nothing when its store could not grow. Floor 2632 -> 2647 on macOS, 2630 -> 2645 on windows, read from the suite. Co-Authored-By: Claude Opus 5.5 --- src/etch/ecs_bridge.zig | 30 ++-- src/etch/interp.zig | 281 +++++++++++++++++++++++++++------ tools/weld_lint/dead_tests.zig | 4 +- 3 files changed, 253 insertions(+), 62 deletions(-) diff --git a/src/etch/ecs_bridge.zig b/src/etch/ecs_bridge.zig index 9b48b10a..2888e8e3 100644 --- a/src/etch/ecs_bridge.zig +++ b/src/etch/ecs_bridge.zig @@ -229,17 +229,7 @@ pub const Bridge = struct { const bytes = store.getResource(resource_id) orelse return BridgeError.UnknownResource; const field = registry.findField(resource_id, field_name) orelse return BridgeError.UnknownField; const slice = bytes[field.offset .. field.offset + @as(u16, @intCast(field.kind.sizeBytes()))]; - // Enum read: rebuild a typed `enum_value` from the slot's - // discriminant + the declared enum type's interned id on `FieldDesc` - // (the byte-only `readBytesAsValue` has no access to the latter). The - // `type_name` id matches the rest of the interpreter's enum machinery - // (`enum_decls` is keyed by it), so the value compares/matches correctly. - if (field.kind == .enum_) { - var disc: u32 = 0; - @memcpy(std.mem.asBytes(&disc), slice[0..@sizeOf(u32)]); - return .{ .enum_value = .{ .type_name = field.enum_type_name_id, .variant = disc } }; - } - return readBytesAsValue(field.kind, slice); + return readFieldValue(field.kind, field.enum_type_name_id, slice); } pub fn writeResourceField( @@ -324,6 +314,19 @@ pub const Bridge = struct { } }; +/// Decode one field slot. An enum slot rebuilds a typed `enum_value` from its +/// discriminant and the declared enum type's interned id on `FieldDesc`, which +/// `readBytesAsValue` has no access to; the id matches the interpreter's +/// `enum_decls` keys, so the value compares and matches correctly. +pub fn readFieldValue(kind: FieldKind, enum_type_name_id: u32, bytes: []const u8) Value { + if (kind == .enum_) { + var disc: u32 = 0; + @memcpy(std.mem.asBytes(&disc), bytes[0..@sizeOf(u32)]); + return .{ .enum_value = .{ .type_name = enum_type_name_id, .variant = disc } }; + } + return readBytesAsValue(kind, bytes); +} + /// Decode the on-storage byte representation of a field into the /// interpreter's tagged `Value`. The width to read is dictated by /// `kind` — the slice must already be sized to the field's column @@ -370,9 +373,8 @@ pub fn readBytesAsValue(kind: FieldKind, bytes: []const u8) Value { break :blk .{ .string_persistent = .{ .ptr = ss.ptr, .len = ss.len } }; }, // Enum reads need the declared type's id (on `FieldDesc`), which this - // byte-only decoder lacks — `readResourceField` handles `.enum_` before - // delegating here, and components never carry `.enum_` (validator-gated). - // Proven invariant: this arm is never reached. + // byte-only decoder lacks: resource reads go through `readFieldValue`, + // and components never carry `.enum_` (validator-gated). .enum_ => unreachable, // Collection read: decode the `CollectionSlot { ptr }` into a // borrowed `.array_persistent` view over the owned container block, diff --git a/src/etch/interp.zig b/src/etch/interp.zig index ab8d97b6..5f6da475 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -130,6 +130,7 @@ const BoundField = struct { name: StringId, offset: u16, kind: FieldKind, + enum_type_name_id: u32, }; /// One `has T { expression }` general filter. @@ -2586,7 +2587,7 @@ pub const Interpreter = struct { break; }; for (rf.fields) |bf| { - const v = bridge_mod.readBytesAsValue(bf.kind, bytes[bf.offset .. bf.offset + @as(u16, @intCast(bf.kind.sizeBytes()))]); + const v = bridge_mod.readFieldValue(bf.kind, bf.enum_type_name_id, bytes[bf.offset .. bf.offset + @as(u16, @intCast(bf.kind.sizeBytes()))]); try locals.put(self.gpa, bf.name, v, false); } if (!(try self.evalGuardExpr(world, &locals, rf.expr))) { @@ -3813,7 +3814,7 @@ pub const Interpreter = struct { .literal => { const lit: NodeId = @bitCast(arm.pattern_payload); const lit_v = try self.evalExpr(world, locals, lit); - if (scrut.eql(lit_v)) return arm.body; + if (self.valueEql(scrut, lit_v)) return arm.body; }, .enum_variant => { if (scrut != .enum_value) return error.RuntimeFailure; @@ -4027,24 +4028,11 @@ pub const Interpreter = struct { while (i < filter.start + filter.len) : (i += 1) { const want = self.captured_filters.items[i]; const fv = eventFieldByName(ev, want.name) orelse return false; - if (!self.eventValueEql(want.value, fv)) return false; + if (!self.valueEql(want.value, fv)) return false; } return true; } - /// Equality for an event-field comparison. Strings compare by - /// BYTES (a filter literal is `.string_id`, but an emitted string may be - /// `.string_run` / `.string_persistent` — `Value.eql` only matches same tag - /// + same pool index); every other admitted kind (int/float/bool/entity/ - /// enum) uses `Value.eql`. - fn eventValueEql(self: *const Interpreter, a: Value, b: Value) bool { - if (self.stringBytes(a)) |ab| { - const bb = self.stringBytes(b) orelse return false; - return std.mem.eql(u8, ab, bb); - } - return a.eql(b); - } - /// Resolve a wake-condition `await` target to a `WakeCond`. Only /// `wait` / `wait_unscaled` reach here (`global_event` / `entity_event` build /// their wake via `evalEventWake`; `future` via `beginAsyncCall`). `wait` and @@ -5366,14 +5354,9 @@ pub const Interpreter = struct { if (call.args_len < 2) return error.RuntimeFailure; const a = try self.evalArg(world, locals, call, 0); const b = try self.evalArg(world, locals, call, 1); - // String-aware equality: `Value.eql` compares - // strings only by pool identity (`.string_run` is always unequal, and - // a `.string_id` literal never matches a `.string_persistent` / - // `.string_run`), which would false-fail `assert_eq` and — worse — - // false-PASS `assert_neq`. `eventValueEql` byte-compares strings and - // falls back to `Value.eql` otherwise. Aggregates are rejected at - // type-check (`synthBuiltinCall`), so only comparable values arrive. - if (self.eventValueEql(a, b) != want_eq) { + // Aggregates are rejected at type-check (`synthBuiltinCall`), so only + // comparable values arrive. + if (self.valueEql(a, b) != want_eq) { self.test_msg_buf.clearRetainingCapacity(); try self.msgAssertPrefix(world, locals, call, 2, name); try self.msgAppend(": "); @@ -5753,7 +5736,7 @@ pub const Interpreter = struct { const arg: NodeId = @bitCast(self.ast.extra.items[mc.args_start]); const v = try self.evalExpr(world, locals, arg); for (persistentSetOf(ptr).items) |existing| { - if (self.collectionKeyEql(existing, v)) return Value{ .bool_ = true }; + if (self.valueEql(existing, v)) return Value{ .bool_ = true }; } return Value{ .bool_ = false }; } @@ -5768,7 +5751,7 @@ pub const Interpreter = struct { // of stdlib §15.2). `insert` is the same // scan-skip-or-append as the `Set.from` seeding (its `bool` // return is out of the subset — statement use only, the - // value here is unit); `contains` scans with `Value.eql`; + // value here is unit); `contains` scans with `valueEql`; // `len` is the element count; any other §15 method is // unimplemented stdlib → fail loud. const mname = self.ast.strings.slice(mc.method_name); @@ -5784,7 +5767,7 @@ pub const Interpreter = struct { const arg: NodeId = @bitCast(self.ast.extra.items[mc.args_start]); const v = try self.evalExpr(world, locals, arg); for (self.collections.sets.items[handle].items) |existing| { - if (existing.eql(v)) return Value{ .bool_ = true }; + if (self.valueEql(existing, v)) return Value{ .bool_ = true }; } return Value{ .bool_ = false }; } @@ -5813,7 +5796,7 @@ pub const Interpreter = struct { // could have grown the outer store vector). var replaced = false; for (self.collections.maps.items[handle].items) |*pair| { - if (pair.key.eql(k)) { + if (self.valueEql(pair.key, k)) { pair.value = v; replaced = true; break; @@ -5852,6 +5835,14 @@ pub const Interpreter = struct { if (mc.args_len != 1) return error.RuntimeFailure; const arg: NodeId = @bitCast(self.ast.extra.items[mc.args_start]); const av = try self.evalExpr(world, locals, arg); + if (av == .array_persistent) { + const handle = try self.collections.newSet(self.gpa); + for (persistentArrayOf(av.array_persistent).items) |v| { + try self.retainArena(v); + try self.setInsert(handle, v); + } + return Value{ .set_ref = handle }; + } if (av != .array_ref) return error.RuntimeFailure; const handle = try self.collections.newSet(self.gpa); var i: usize = 0; @@ -5873,7 +5864,7 @@ pub const Interpreter = struct { /// byte-exact across the two backends by construction. fn setInsert(self: *Interpreter, handle: u32, item: Value) !void { for (self.collections.sets.items[handle].items) |existing| { - if (existing.eql(item)) return; + if (self.valueEql(existing, item)) return; } try self.collections.sets.items[handle].append(self.gpa, item); } @@ -5996,11 +5987,10 @@ pub const Interpreter = struct { return block; } - /// Collection key equality: string keys compared BY BYTES (a - /// stored key is always a promoted `.string_persistent`, an incoming key may - /// be `.string_id`/`.string_run` — `Value.eql` would false-mismatch across - /// tags), POD keys by value. Load-bearing for the `[K:V]` unique-key policy. - fn collectionKeyEql(self: *const Interpreter, a: Value, b: Value) bool { + /// Runtime value equality: two strings compare by bytes, whatever their + /// tags; anything else by `Value.eql`, which compares a `.string_id` by pool + /// id and never matches a `.string_run`. + fn valueEql(self: *const Interpreter, a: Value, b: Value) bool { const ab = self.stringBytes(a); const bb = self.stringBytes(b); if (ab != null and bb != null) return std.mem.eql(u8, ab.?, bb.?); @@ -6013,7 +6003,7 @@ pub const Interpreter = struct { /// AND value, append. Leak-safe (reserve → promote → commit). fn mapInsertPromoted(self: *Interpreter, list: *PersistentMap, k: Value, v: Value) !void { for (list.items) |*pair| { - if (self.collectionKeyEql(pair.key, k)) { + if (self.valueEql(pair.key, k)) { const new_v = try self.promoteForCollection(v); if (pair.value == .string_persistent and pair.value.string_persistent.ptr != 0) { persistent.decref(self.gpa, @ptrFromInt(pair.value.string_persistent.ptr)); @@ -6052,7 +6042,7 @@ pub const Interpreter = struct { /// subtlety as map keys), else promote + append. Leak-safe. fn setInsertPromoted(self: *Interpreter, list: *PersistentSet, v: Value) !void { for (list.items) |existing| { - if (self.collectionKeyEql(existing, v)) return; + if (self.valueEql(existing, v)) return; } try list.ensureUnusedCapacity(self.gpa, 1); const owned = try self.promoteForCollection(v); @@ -6077,6 +6067,7 @@ pub const Interpreter = struct { /// Take ownership of `bytes` into the per-body runtime-string store, /// returning its `string_run` handle value. fn newRunString(self: *Interpreter, bytes: []u8) !Value { + errdefer self.gpa.free(bytes); const handle: u32 = @intCast(self.run_strings.items.len); try self.run_strings.append(self.gpa, bytes); return Value{ .string_run = handle }; @@ -6147,7 +6138,7 @@ pub const Interpreter = struct { try out.appendSlice(self.gpa, piece); }, .bool_ => |x| try out.appendSlice(self.gpa, if (x) "true" else "false"), - .string_id, .string_run => try out.appendSlice(self.gpa, self.stringBytes(v).?), + .string_id, .string_run, .string_persistent, .string_view => try out.appendSlice(self.gpa, self.stringBytes(v).?), // Any other type is resolver-gated (minimal Display // subset) — fail loud if one slips through. else => return error.RuntimeFailure, @@ -6320,7 +6311,7 @@ pub const Interpreter = struct { .literal => { const lit: NodeId = @bitCast(arm.pattern_payload); const lit_v = try self.evalExpr(world, locals, lit); - if (scrut.eql(lit_v)) return try self.evalExpr(world, locals, arm.body); + if (self.valueEql(scrut, lit_v)) return try self.evalExpr(world, locals, arm.body); }, .enum_variant => { // Compare the scrutinee's enum value against the @@ -6432,7 +6423,7 @@ pub const Interpreter = struct { const v = try self.evalExpr(world, locals, entry.value); var replaced = false; for (self.collections.maps.items[handle].items) |*pair| { - if (pair.key.eql(k)) { + if (self.valueEql(pair.key, k)) { pair.value = v; replaced = true; break; @@ -6456,7 +6447,7 @@ pub const Interpreter = struct { const key_v = try self.evalExpr(world, locals, ix.index); var found: ?Value = null; for (self.collections.maps.items[recv.map_ref].items) |pair| { - if (pair.key.eql(key_v)) { + if (self.valueEql(pair.key, key_v)) { found = pair.value; break; } @@ -6472,7 +6463,7 @@ pub const Interpreter = struct { const key_v = try self.evalExpr(world, locals, ix.index); var found: ?Value = null; for (persistentMapOf(recv.map_persistent).items) |pair| { - if (self.collectionKeyEql(pair.key, key_v)) { + if (self.valueEql(pair.key, key_v)) { found = pair.value; break; } @@ -6483,10 +6474,21 @@ pub const Interpreter = struct { return Value{ .optional = oh }; } if (recv == .array_persistent) { - // Single-element read `xs[i]` on a resource collection. Slicing - // a persistent array (`xs[0..3]`) is out of the - // surface (it would need a fresh rule-arena copy). - if (self.ast.exprKind(ix.index) == .range) return error.RuntimeFailure; + if (self.ast.exprKind(ix.index) == .range) { + const r = self.ast.ranges.items[self.ast.exprData(ix.index)]; + const start_v = try self.evalExpr(world, locals, r.start); + const end_v = try self.evalExpr(world, locals, r.end); + if (start_v != .int_ or end_v != .int_) return error.RuntimeFailure; + const lo = std.math.cast(usize, start_v.int_) orelse return error.RuntimeFailure; + var hi = std.math.cast(usize, end_v.int_) orelse return error.RuntimeFailure; + if (r.inclusive) hi += 1; + const src = persistentArrayOf(recv.array_persistent).items; + if (lo > hi or hi > src.len) return error.RuntimeFailure; + const handle = try self.collections.newArray(self.gpa); + for (src[lo..hi]) |v| try self.retainArena(v); + try self.collections.arrays.items[handle].appendSlice(self.gpa, src[lo..hi]); + return Value{ .array_ref = handle }; + } const list = persistentArrayOf(recv.array_persistent); const idx_v = try self.evalExpr(world, locals, ix.index); if (idx_v != .int_) return error.RuntimeFailure; @@ -8259,7 +8261,7 @@ fn captureBoundFields(ctx: *LowerWhenCtx, type_name: StringId, id: ComponentId, while (f < fields_len) : (f += 1) { const field = ast.fields.items[fields_start + f]; const fd = ctx.registry.findField(id, ast.strings.slice(field.name)) orelse return error.InvalidProgram; - try out.append(ctx.gpa, .{ .name = field.name, .offset = fd.offset, .kind = fd.kind }); + try out.append(ctx.gpa, .{ .name = field.name, .offset = fd.offset, .kind = fd.kind, .enum_type_name_id = fd.enum_type_name_id }); } return try out.toOwnedSlice(ctx.gpa); } @@ -17377,3 +17379,190 @@ test "a collection element that does not fold is refused at compile" { test "an enum field default that names no variant is refused at compile" { try expectInvalidProgram("enum Mode { a }\nresource R { m: Mode = .nope }"); } + +fn runOneTickOut(gpa: std.mem.Allocator, source: []const u8) !i64 { + var world = World.init(); + defer world.deinit(gpa); + var pr = try checkCleanProgram(gpa, source); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + _ = try interp.runFor(&world, 1); + return readResourceIntNamed(&world, "Out", "n"); +} + +test "a match literal arm matches a runtime-built string" { + try std.testing.expectEqual(@as(i64, 1), try runOneTickOut(std.testing.allocator, + \\resource Out { n: int = 0 } + \\rule r() + \\ when resource Out + \\{ + \\ let s = "a" + "" + \\ let o = get_mut(Out) + \\ o.n = match s { "a" => 1, _ => 2 } + \\} + )); +} + +test "a match literal arm matches a resource string" { + try std.testing.expectEqual(@as(i64, 1), try runOneTickOut(std.testing.allocator, + \\resource Out { n: int = 0, name: string = "a" } + \\rule r() + \\ when resource Out + \\{ + \\ let v = match get(Out).name { "a" => 1, _ => 2 } + \\ get_mut(Out).n = v + \\} + )); +} + +test "an async match statement's literal arm matches a runtime-built string" { + try std.testing.expectEqual(@as(i64, 1), try runOneTickOut(std.testing.allocator, + \\resource Out { n: int = 0 } + \\async rule r() + \\ when resource Out + \\{ + \\ let s = "a" + "" + \\ match s { + \\ "a" => { get_mut(Out).n = 1 }, + \\ _ => { get_mut(Out).n = 2 } + \\ } + \\} + )); +} + +test "a rule-arena map keeps one entry per runtime-built string key" { + try std.testing.expectEqual(@as(i64, 2), try runOneTickOut(std.testing.allocator, + \\resource Out { n: int = 0 } + \\rule r() + \\ when resource Out + \\{ + \\ let mut m = ["x": 0] + \\ m.insert("k" + "", 1) + \\ m.insert("k" + "", 2) + \\ get_mut(Out).n = m.len() + \\} + )); +} + +test "a rule-arena map finds a runtime-built string key" { + try std.testing.expectEqual(@as(i64, 7), try runOneTickOut(std.testing.allocator, + \\resource Out { n: int = 0 } + \\rule r() + \\ when resource Out + \\{ + \\ let mut m = ["x": 0] + \\ m.insert("k" + "", 7) + \\ get_mut(Out).n = m["k" + ""] ?? 0 + \\} + )); +} + +test "a rule-arena set keeps one element per runtime-built string" { + try std.testing.expectEqual(@as(i64, 1), try runOneTickOut(std.testing.allocator, + \\resource Out { n: int = 0 } + \\rule r() + \\ when resource Out + \\{ + \\ let mut s: Set = Set.new() + \\ s.insert("k" + "") + \\ s.insert("k" + "") + \\ get_mut(Out).n = s.len() + \\} + )); +} + +test "a rule-arena set contains a runtime-built string" { + try std.testing.expectEqual(@as(i64, 1), try runOneTickOut(std.testing.allocator, + \\resource Out { n: int = 0 } + \\rule r() + \\ when resource Out + \\{ + \\ let mut s: Set = Set.new() + \\ s.insert("k" + "") + \\ get_mut(Out).n = if s.contains("k" + "") { 1 } else { 0 } + \\} + )); +} + +test "a resource string interpolates" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try checkCleanProgram(gpa, + \\resource Out { n: int = 0, name: string = "a" } + \\rule r() + \\ when resource Out + \\{ + \\ get_mut(Out).name = "{get(Out).name}!" + \\} + ); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + _ = try interp.runFor(&world, 1); + try expectResourceStringField(&world, "Out", "name", "a!"); +} + +test "Set.from takes a resource array" { + try std.testing.expectEqual(@as(i64, 3), try runOneTickOut(std.testing.allocator, + \\resource Out { n: int = 0, items: string[] = ["a", "b", "c"] } + \\rule r() + \\ when resource Out + \\{ + \\ get_mut(Out).n = Set.from(get(Out).items).len() + \\} + )); +} + +test "a resource array slices" { + try std.testing.expectEqual(@as(i64, 2), try runOneTickOut(std.testing.allocator, + \\resource Out { n: int = 0, items: string[] = ["a", "b", "c"] } + \\rule r() + \\ when resource Out + \\{ + \\ get_mut(Out).n = get(Out).items[0..2].len() + \\} + )); +} + +test "a resource filter runs on a resource carrying an enum field" { + try std.testing.expectEqual(@as(i64, 1), try runOneTickOut(std.testing.allocator, + \\enum Mode { a, b } + \\resource Out { n: int = 0, m: Mode = .a } + \\rule r() + \\ when resource Out { n == 0 } + \\{ + \\ get_mut(Out).n = 1 + \\} + )); +} + +test "a resource filter reads an enum field typed" { + try std.testing.expectEqual(@as(i64, 1), try runOneTickOut(std.testing.allocator, + \\enum Mode { a, b } + \\resource Out { n: int = 0, m: Mode = .b } + \\rule r() + \\ when resource Out { match m { Mode.a => false, Mode.b => true } } + \\{ + \\ get_mut(Out).n = 1 + \\} + )); +} + +test "newRunString frees the bytes it takes on an allocation failure" { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const gpa = failing.allocator(); + var world = World.init(); + defer world.deinit(gpa); + var pr = try checkCleanProgram(gpa, + \\resource Out { n: int = 0 } + ); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + try std.testing.expectEqual(interp.run_strings.items.len, interp.run_strings.capacity); + const bytes = try gpa.dupe(u8, "abc"); + failing.fail_index = failing.alloc_index; + try std.testing.expectError(error.OutOfMemory, interp.newRunString(bytes)); +} diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 8a1b7504..5575a6d5 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2630, - else => 2632, + .windows => 2645, + else => 2647, }; } From d67258486bb2a7abbf5a597eaf766c68ba38ab36 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Fri, 25 Sep 2026 16:18:18 +0200 Subject: [PATCH 070/141] fix(etch): compare a string match arm by bytes in the codegen A string literal arm emitted == on []const u8, which Zig refuses, so a valid Etch program with a string match cooked to Zig that did not compile. Both literal-arm sites emit std.mem.eql for a string pattern. Differential program 89 matches a runtime-built string as an expression and as a statement and gives the same world in both backends. Co-Authored-By: Claude Opus 5.5 --- src/etch/zig_codegen/lower.zig | 32 +++++++++++++++---- tests/etch_interp/codegen_corpus_build.zig | 2 ++ tests/etch_interp/corpus_facade.zig | 3 ++ .../etch_interp/programs/89_string_match.etch | 17 ++++++++++ .../programs/89_string_match.expected.zig | 24 ++++++++++++++ 5 files changed, 72 insertions(+), 6 deletions(-) create mode 100644 tests/etch_interp/programs/89_string_match.etch create mode 100644 tests/etch_interp/programs/89_string_match.expected.zig diff --git a/src/etch/zig_codegen/lower.zig b/src/etch/zig_codegen/lower.zig index 461d2ef4..8b535168 100644 --- a/src/etch/zig_codegen/lower.zig +++ b/src/etch/zig_codegen/lower.zig @@ -4185,9 +4185,15 @@ fn emitMatch(w: *Writer, ast: *const AstArena, ctx: *LocalCtx, data: u32) Codege switch (arm.pattern_kind) { .literal => { const lit: NodeId = @bitCast(arm.pattern_payload); - try w.print("if (__m{d} == ", .{lbl}); - try emitExpr(w, ast, ctx, lit); - try w.print(") break :blk{d} ", .{lbl}); + if (isStringPattern(ast, lit)) { + try w.print("if (std.mem.eql(u8, __m{d}, ", .{lbl}); + try emitExpr(w, ast, ctx, lit); + try w.print(")) break :blk{d} ", .{lbl}); + } else { + try w.print("if (__m{d} == ", .{lbl}); + try emitExpr(w, ast, ctx, lit); + try w.print(") break :blk{d} ", .{lbl}); + } try emitExpr(w, ast, ctx, arm.body); try w.write("; "); }, @@ -4276,9 +4282,15 @@ fn emitMatchAsStmt(w: *Writer, ast: *const AstArena, ctx: *LocalCtx, data: u32) switch (arm.pattern_kind) { .literal => { const lit: NodeId = @bitCast(arm.pattern_payload); - try w.print("if (__ms{d} == ", .{lbl}); - try emitExpr(w, ast, ctx, lit); - try w.write(") "); + if (isStringPattern(ast, lit)) { + try w.print("if (std.mem.eql(u8, __ms{d}, ", .{lbl}); + try emitExpr(w, ast, ctx, lit); + try w.write(")) "); + } else { + try w.print("if (__ms{d} == ", .{lbl}); + try emitExpr(w, ast, ctx, lit); + try w.write(") "); + } try emitArmBodyAsStmts(w, ast, ctx, arm.body, lbl, null); chained = true; }, @@ -7123,3 +7135,11 @@ fn emitTagFilterGuard(w: *Writer, tf: TagFilterInfo) CodegenError!void { // Dedicated lowering tests live under `src/etch/zig_codegen/tests/lower_test.zig`. // They are pulled into the import graph by `zig_codegen/root.zig` and run as // part of `zig build test`. + +/// A string pattern compares by bytes: Zig refuses `==` on a slice. +fn isStringPattern(ast: *const AstArena, lit: NodeId) bool { + return switch (ast.exprKind(lit)) { + .string_lit, .string_interp => true, + else => false, + }; +} diff --git a/tests/etch_interp/codegen_corpus_build.zig b/tests/etch_interp/codegen_corpus_build.zig index 15b694aa..f4ae0376 100644 --- a/tests/etch_interp/codegen_corpus_build.zig +++ b/tests/etch_interp/codegen_corpus_build.zig @@ -121,4 +121,6 @@ pub const programs = [_]CodegenProgram{ .{ .name = "p87_triple_quote_multiline_interp", .etch_path = "tests/etch_interp/programs/87_triple_quote_multiline_interp.etch" }, // Runtime integer division and remainder, float remainder, narrowing casts. .{ .name = "p88_runtime_arith", .etch_path = "tests/etch_interp/programs/88_runtime_arith.etch" }, + // A match on a runtime-built string, expression and statement. + .{ .name = "p89_string_match", .etch_path = "tests/etch_interp/programs/89_string_match.etch" }, }; diff --git a/tests/etch_interp/corpus_facade.zig b/tests/etch_interp/corpus_facade.zig index ebaf0ab0..f973da90 100644 --- a/tests/etch_interp/corpus_facade.zig +++ b/tests/etch_interp/corpus_facade.zig @@ -97,6 +97,8 @@ const p86 = @import("programs/86_triple_quote_multiline.expected.zig"); const p87 = @import("programs/87_triple_quote_multiline_interp.expected.zig"); // Runtime integer division and remainder, float remainder, narrowing casts. const p88 = @import("programs/88_runtime_arith.expected.zig"); +// A match on a runtime-built string, expression and statement. +const p89 = @import("programs/89_string_match.expected.zig"); /// Embedded list of the differential corpus programs, consumed by the /// interpreter test and by the codegen parity test. @@ -170,4 +172,5 @@ pub const programs = [_]Program{ .{ .name = "86_triple_quote_multiline", .source = @embedFile("programs/86_triple_quote_multiline.etch"), .config = p86.config, .initial = p86.initial, .expected = p86.expected }, .{ .name = "87_triple_quote_multiline_interp", .source = @embedFile("programs/87_triple_quote_multiline_interp.etch"), .config = p87.config, .initial = p87.initial, .expected = p87.expected }, .{ .name = "88_runtime_arith", .source = @embedFile("programs/88_runtime_arith.etch"), .config = p88.config, .initial = p88.initial, .expected = p88.expected }, + .{ .name = "89_string_match", .source = @embedFile("programs/89_string_match.etch"), .config = p89.config, .initial = p89.initial, .expected = p89.expected }, }; diff --git a/tests/etch_interp/programs/89_string_match.etch b/tests/etch_interp/programs/89_string_match.etch new file mode 100644 index 00000000..6d247bf0 --- /dev/null +++ b/tests/etch_interp/programs/89_string_match.etch @@ -0,0 +1,17 @@ +// A match on a runtime-built string compares bytes, as an expression and as a +// statement, in both backends: value 0 -> 1 -> 11 in one tick. +component Counter { + value: int = 0 +} + +rule pick(entity: Entity) + when entity has Counter +{ + let s = "a" + "" + entity.get_mut(Counter).value = match s { "a" => 1, _ => 2 } + match s { + "b" => { entity.get_mut(Counter).value = entity.get(Counter).value + 100 }, + "a" => { entity.get_mut(Counter).value = entity.get(Counter).value + 10 }, + _ => { entity.get_mut(Counter).value = 0 }, + } +} diff --git a/tests/etch_interp/programs/89_string_match.expected.zig b/tests/etch_interp/programs/89_string_match.expected.zig new file mode 100644 index 00000000..a145b10d --- /dev/null +++ b/tests/etch_interp/programs/89_string_match.expected.zig @@ -0,0 +1,24 @@ +const driver = @import("diff_runner"); + +/// Diff-runner fixture: one tick of the string-match rule. +pub const config: driver.Config = .{ .ticks = 1 }; + +/// Diff-runner fixture: one Counter entity (value defaults to 0). +pub const initial: driver.WorldSpec = .{ + .entities = &[_]driver.EntitySpec{ + .{ .components = &[_]driver.ComponentSpec{ + .{ .name = "Counter" }, + } }, + }, +}; + +/// Diff-runner fixture: the expression arm "a" gives 1, the statement arm "a" adds 10. +pub const expected: driver.ExpectedWorld = .{ + .entities = &[_]driver.EntitySpec{ + .{ .components = &[_]driver.ComponentSpec{ + .{ .name = "Counter", .fields = &[_]driver.FieldSpec{ + .{ .name = "value", .value = .{ .int_ = 11 } }, + } }, + } }, + }, +}; From 076bcc5624f3ea997d005fa9f87267d6351d1f92 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Fri, 25 Sep 2026 16:18:21 +0200 Subject: [PATCH 071/141] docs(brief): record decisions 2 and 4, return decision 3 Co-Authored-By: Claude Opus 5.5 --- briefs/m1.d-phase-1-debt.md | 84 +++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index 11f54956..2006cf6e 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -6558,6 +6558,53 @@ the workaround of a Zig 0.16 defect that no fix on this side removes. The upstre report is prepared in `briefs/artifacts/m1.d-zig-windows-spawn-inherit-issue.md`, not filed. +### S5/G9 nonies — decisions 2 and 4, decision 3 returned + +Every witness below was written before its fix and run red on the unfixed tree, +the value it gave matching the prediction written before the run; the unfixed code +is each form's counter-factual. + +**Decision 2** (`718a48f4`). Six parser sites appended each field as they parsed it, +so a nested struct literal committed its own fields inside the outer run — +`emit Hit { a: 1, p: Payload { v: 5 }, b: 2 }` gave `[a, v, p, b]`: emit bodies, event +payload filters (both `global_event` and `entity_event`), effect emitters, audio score +sections and anim body sub-blocks on `struct_lit_fields`, and tuple-like enum variants +on `extra` around a generic type. All six now buffer and commit once, as +`parseStructLiteral` does; every other writer of the two slabs already did. A +struct-typed event field is refused at its declaration with E0102 — the code every +other refused event field type carries, so no diagnostic is added — whatever the +declaration order, through an alias and on the builtin `Error`; a struct field of a +struct stays accepted. An emit and a filter holding a nested literal now type-check +against the event, and restoring `parseEmitStmt`'s direct append alone reddens the +emit form. + +**Decision 4**, inside `M1.D.47` (`e5cd0919`). One comparator, `valueEql` — two +strings by bytes whatever their tags, anything else by `Value.eql` — replaces the two +byte comparators and serves the seven sites `Value.eql` served: the sync and async +match literal arms, rule-arena map literal dedup, insert and index, set insert and +contains. Interpolation accepts `.string_persistent` and `.string_view`. `Set.from` and +slicing accept a resource array, each element copied into an arena value taking its +reference (§4.4). Ten witnesses. **The codegen had the same comparison broken +differently**, measured rather than inferred: a string literal arm emitted `==` on +`[]const u8`, which Zig refuses, so a valid program cooked to Zig that does not +compile. Both literal-arm sites now emit `std.mem.eql`; differential program 89 +failed the corpus compile before and gives 11 in both backends after. + +**Adjacent, closed in the session.** `entity.get(T)` with `T` not a component +(`6ee051c6`): from a rule, an out-of-bounds read, `index 2, len 1`; from a test body, +no diagnostic; now E0102, as the resource form already answered. A resource filter on +a resource carrying an enum field reached `.enum_ => unreachable`, under a comment +calling the arm provably unreached; `readFieldValue` binds the enum typed, and zeroing +its type id reddens the witness that reads it. `newRunString` leaked its bytes when +its store could not grow. Shown safe rather than changed: the other `extra` writers +buffer already; the other callers of the byte-only decoder read component fields, +which never carry an enum; the two remaining `Value.eql` sites never see a string. + +Floor 2624 → 2632 → 2647 on macOS and 2622 → 2630 → 2645 on windows, read from the +suite at each step. + +**Decision 3 is returned before writing** — B4. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree @@ -6735,6 +6782,43 @@ the arena's reset through a deferred-decrement list — delivered at G8 quater a source and at load, and the zone effects of string comparison and interpolation are the next gates. +### B4 — decision 3 needs a design the ruling does not give + +Stopped at S5/G9 nonies, after decisions 2 and 4. Hook bodies are checked nowhere: +`validatePrefab` reads annotations and instances only, `cookPrefab` parses, and +`execHookText` parses and runs — the M1.0.9 brief ruled re-checking out +(`M1.0.9-extension-hooks.md:38`), which the ruling reverses. Checking at both ends +leaves open: + +1. **At load, checked against what.** The checker reads one mutable arena and no + registry; a re-parsed hook carries no declarations. Recommended: the loaded + program's declarations, the runtime fact the hook executes against, in the + manner of G8 bis. +2. **Where the load check sits.** `activateExtension` step 1, through a new `World` + seam, is the only point before any mutation on all three paths; a check inside + `execHookText` comes after the commit on a runtime activation and after earlier + hooks on a load. Recommended: step 1, plus a pre-pass on a load so no hook runs if + one is refused. +3. **The scope a hook sees**: `requires` and the extension's own components, as a + `when` clause gates a rule, or everything. Recommended: the gated scope. +4. **Whether the cook runs the checker**, so a refused hook fails the cook as well as + `etch check`. Recommended: yes, the `cooked ⇒ loadable` invariant of HF4. +5. **The hook's context**: sync, cannot throw, and `return` — rendered by the cooker, + discarded by the interpreter. Recommended: a test body's context, `return` + refused. +6. **Undeclared events**: hooks emit them in two tests where a rule would be refused. + Recommended: hooks follow rules. + +Found on the way, all on hooks and all traced, not run: a hook's `emit` stores +string ids of the hook's own pool, freed when the hook ends, where observers and +awaiters match by the program's pool — so an event a hook emits misses its +observers or reaches another; rendering a `let` drops its type annotation, so the two +ends would check different programs; and `activateExtension` step 1 has neither the +resource-kind guard nor the alignment check `instantiate` has, so a forged extension +naming a resource adds it as a component. Three statement bodies are never checked +either, none executed: effect `on` handlers, sequence `on_start`/`on_finish`, and +keyframe emit fields. + ## Notes ### A comparison refuses the fingerprint of nothing before it concludes From 2da27b5ee9b2f7eadbca60488aa31c52a6750117 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Fri, 25 Sep 2026 21:45:48 +0200 Subject: [PATCH 072/141] fix(etch): keep a let type annotation when rendering a statement The renderer wrote a let as `let name = value`, dropping its type, so a cooked hook re-parsed at load read a different program than its source. Every type form the type parser produces now renders back; the others fail loud. Floor 2647 -> 2648 on macOS, 2645 -> 2646 on windows. Co-Authored-By: Claude Opus 5.5 --- src/etch/descriptor.zig | 65 ++++++++++++++++++++++++++++++++++ tools/weld_lint/dead_tests.zig | 4 +-- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/etch/descriptor.zig b/src/etch/descriptor.zig index c2212f87..3db315ec 100644 --- a/src/etch/descriptor.zig +++ b/src/etch/descriptor.zig @@ -2168,6 +2168,56 @@ pub fn renderStmtAlloc(gpa: std.mem.Allocator, arena: *const AstArena, stmt: Nod return try buf.toOwnedSlice(gpa); } +/// Render a type node back to its source form: every kind `parseType` produces. +fn renderType(gpa: std.mem.Allocator, arena: *const AstArena, t: NodeId, out: *std.ArrayListUnmanaged(u8)) BuildError!void { + const data = arena.typeNodeData(t); + switch (arena.typeNodeKind(t)) { + .named => try out.appendSlice(gpa, arena.strings.slice(arena.namedTypeName(t).?)), + .path => { + const p = arena.path_types.items[data]; + try out.appendSlice(gpa, arena.strings.slice(p.alias)); + try out.append(gpa, '.'); + try out.appendSlice(gpa, arena.strings.slice(p.member)); + }, + .generic => { + const g = arena.generic_type_nodes.items[data]; + try out.appendSlice(gpa, arena.strings.slice(g.name)); + try out.append(gpa, '<'); + var i: u32 = 0; + while (i < g.args_len) : (i += 1) { + if (i != 0) try out.appendSlice(gpa, ", "); + try renderType(gpa, arena, @bitCast(arena.extra.items[g.args_start + i]), out); + } + try out.append(gpa, '>'); + }, + .array, .slice => { + const a = arena.array_types.items[data]; + try renderType(gpa, arena, a.elem, out); + try out.append(gpa, '['); + if (!a.size.isNone()) try renderExpr(gpa, arena, a.size, out); + try out.append(gpa, ']'); + }, + .map_type => { + const m = arena.map_types.items[data]; + try out.append(gpa, '['); + try renderType(gpa, arena, m.key, out); + try out.appendSlice(gpa, ": "); + try renderType(gpa, arena, m.value, out); + try out.append(gpa, ']'); + }, + .set_type => { + try out.appendSlice(gpa, "Set<"); + try renderType(gpa, arena, arena.set_types.items[data].elem, out); + try out.append(gpa, '>'); + }, + .optional => { + try renderType(gpa, arena, @bitCast(data), out); + try out.append(gpa, '?'); + }, + .tuple, .function, .self, .trait_bound => return error.UnsupportedDescriptorExpr, + } +} + fn renderStmt(gpa: std.mem.Allocator, arena: *const AstArena, stmt: NodeId, out: *std.ArrayListUnmanaged(u8)) BuildError!void { switch (arena.stmtKind(stmt)) { .let_stmt => { @@ -2175,6 +2225,10 @@ fn renderStmt(gpa: std.mem.Allocator, arena: *const AstArena, stmt: NodeId, out: try out.appendSlice(gpa, "let "); if (let.is_mut) try out.appendSlice(gpa, "mut "); try out.appendSlice(gpa, arena.strings.slice(let.name)); + if (!let.type_annotation.isNone()) { + try out.appendSlice(gpa, ": "); + try renderType(gpa, arena, let.type_annotation, out); + } try out.appendSlice(gpa, " = "); try renderExpr(gpa, arena, let.value, out); }, @@ -2729,3 +2783,14 @@ test "renderFieldTypeAlloc rejects a collection field type" { const slice = try arena.addArrayType(gpa, elem, NodeId.none, span); // `string[]` → .slice try std.testing.expectError(error.UnsupportedDescriptorExpr, renderFieldTypeAlloc(gpa, &arena, slice)); } + +test "a let keeps its type annotation when rendered" { + const gpa = std.testing.allocator; + const source = "let a: int = 5; let b: int[] = []; let c: [string: int] = [\"k\": 1]; let d: Set = Set.new(); let e: int? = none; let f: m.Health = x; let g: Box = y; let h: int[3] = z"; + var block = try parser_mod.parseStmtBlock(gpa, source); + defer block.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), block.diagnostics.len); + const text = try renderStmtRunAlloc(gpa, &block.ast, block.body_start, block.body_len); + defer gpa.free(text); + try std.testing.expectEqualStrings(source, text); +} diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 5575a6d5..03a1caec 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2645, - else => 2647, + .windows => 2646, + else => 2648, }; } From 700121294a74d9c7003e28ed1a59b39b36e2a4c6 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Fri, 25 Sep 2026 22:14:25 +0200 Subject: [PATCH 073/141] fix(etch): let entity.get reach a selectively imported component 6ee051c6 refused an undeclared name under entity.get, looking only at the local symbols, so a component imported with `import lib { Health }` was refused as unknown where it had been accepted. The receiver form now consults the imported symbols as well. Floor 2648 -> 2649 on macOS, 2646 -> 2647 on windows. Co-Authored-By: Claude Opus 5.5 --- src/etch/types.zig | 5 +++++ tests/etch/import_resolve_test.zig | 19 +++++++++++++++++++ tools/weld_lint/dead_tests.zig | 4 ++-- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/etch/types.zig b/src/etch/types.zig index 84956591..8744254c 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -6497,6 +6497,11 @@ pub const TypeChecker = struct { try self.emit(.undefined_symbol, .error_, self.arena.exprSpan(id), "'{s}' is not a component", .{tname}); return ResolvedType.unknown; } + } else if (self.imported_symbols.get(mg.type_name)) |entry| { + if (entry.kind != .component) { + try self.emit(.undefined_symbol, .error_, self.arena.exprSpan(id), "'{s}' is not a component", .{tname}); + return ResolvedType.unknown; + } } else { try self.emit(.undefined_symbol, .error_, self.arena.exprSpan(id), "unknown component '{s}'", .{tname}); return ResolvedType.unknown; diff --git a/tests/etch/import_resolve_test.zig b/tests/etch/import_resolve_test.zig index a09470ac..34e09e6a 100644 --- a/tests/etch/import_resolve_test.zig +++ b/tests/etch/import_resolve_test.zig @@ -173,3 +173,22 @@ test "a test block is not exported (E0104 on import)" { try etch.validateProject(gpa, &files, &diags); try std.testing.expectEqual(@as(usize, 1), countCode(diags.items, .unknown_export)); } + +test "entity.get reaches an imported component in a test body" { + const gpa = std.testing.allocator; + const files = [_]etch.ProjectFile{ + .{ .name = "lib.etch", .source = "component Health { current: float = 100.0 }" }, + .{ .name = "main.etch", .source = + \\import lib { Health } + \\test "t" { + \\ let w = test_world() + \\ let e = w.spawn_with([Health { current: 1.0 }]) + \\ let v = e.get(Health).current + \\} + }, + }; + 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)); +} diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 03a1caec..c5125aee 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2646, - else => 2648, + .windows => 2647, + else => 2649, }; } From d236af9a3856f9f89a471dec7ade319900df75b1 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Fri, 25 Sep 2026 22:27:36 +0200 Subject: [PATCH 074/141] fix(scene): refuse a resource or a divergent layout as an entity column activateExtension's step 1 compared only the size of each column, so a forged extension naming a world resource added it to the entity as a component. It now applies the scene path's predicate: size AND alignment against the registry, and a resource refused as a column. The cook carried the same gap at every site that turns a name into an entity column: a scene entity, a variant entity, an instance body, and the two base-prefab columns, which also compared size alone. A resources block naming a component cooked too. Each of those cooked a file the loader then refused, against cooked => loadable. Two predicates now serve the five column sites and the resources block carries the inverse guard; a field override resolves only ids already on that list. Eleven red-first witnesses. Test floor 2649 -> 2660 / 2647 -> 2658, taken from the suite. Co-Authored-By: Claude Opus 5.5 --- src/core/scene/loader.zig | 57 ++++++++++++++++- src/etch/scene_cook.zig | 35 ++++++++--- tests/scene/extensions_test.zig | 35 +++++++++++ tests/scene/prefab_flatten_test.zig | 96 +++++++++++++++++++++++++++++ tools/weld_lint/dead_tests.zig | 4 +- 5 files changed, 217 insertions(+), 10 deletions(-) diff --git a/src/core/scene/loader.zig b/src/core/scene/loader.zig index 9ba2bbc8..f3887e67 100644 --- a/src/core/scene/loader.zig +++ b/src/core/scene/loader.zig @@ -590,7 +590,13 @@ pub fn activateExtension(world: *World, gpa: std.mem.Allocator, entity: EntityId while (c < comp_count) : (c += 1) { const sch = ext.schema(arch.schemaIndex(c)); const cid = world.componentId(sch.name) orelse return error.UnknownComponent; - if (sch.size != world.registry.componentSize(cid)) return error.SchemaMismatch; + if (sch.size != world.registry.componentSize(cid) or + sch.alignment != world.registry.componentAlignment(cid)) + { + return error.SchemaMismatch; + } + // A resource is no entity column, whatever the file says. + if (world.registry.componentKind(cid) == .resource) return error.SchemaMismatch; if (world.componentBytes(entity, cid) != null) return error.ExtensionComponentConflict; cids[c] = cid; values[c] = arch.componentSlot(c, 0); @@ -1493,6 +1499,55 @@ fn buildExtPrefab(gpa: std.mem.Allocator) ![]u8 { return writer.write(gpa, model, ®); } +/// Build a mono-entity extension `.prefab.bin` carrying one zeroed component +/// `name` of the given layout, as a cook with that registry would write it. +fn buildOneComponentExt(gpa: std.mem.Allocator, name: []const u8, size: u16, alignment: u16) ![]u8 { + var reg = Registry.init(); + defer reg.deinit(gpa); + const id = try registerRaw(gpa, ®, name, size, alignment); + var arena = std.heap.ArenaAllocator.init(gpa); + const a = arena.allocator(); + const names = try a.dupe([]const u8, &.{try a.dupe(u8, "ext_entity")}); + const uuids = try a.dupe([16]u8, &.{[_]u8{9} ** 16}); + const col = try a.alloc(u8, size); + @memset(col, 0); + const ids = try a.dupe(ComponentId, &.{id}); + const cols = try a.dupe([]u8, &.{col}); + const ents = try a.dupe(format.EntityEntry, &.{.{ .name = 0, .uuid = 0, .parent_uuid = format.no_parent }}); + const blocks = try a.dupe(format.ArchetypeBlock, &.{.{ .component_ids = ids, .entity_count = 1, .columns = cols, .entities = ents }}); + var model: format.CookModel = .{ .strings = names, .uuids = uuids, .resources = &.{}, .archetypes = blocks, .arena = arena }; + defer model.deinit(); + return writer.write(gpa, model, ®); +} + +test "activateExtension refuses an extension naming a resource" { + const gpa = testing.allocator; + const ext_bytes = try buildOneComponentExt(gpa, "Settings", 16, 8); + defer gpa.free(ext_bytes); + var world = World.init(); + defer world.deinit(gpa); + const base = try registerRaw(gpa, &world.registry, "ExtBase", 4, 4); + const settings = try registerStringResource(gpa, &world.registry, "Settings"); + const e = try world.spawnDynamic(gpa, &[_]ComponentId{base}); + try testing.expectError(error.SchemaMismatch, activateExtension(&world, gpa, e, "Forged", ext_bytes)); + try testing.expect(world.componentBytes(e, settings) == null); + try testing.expect(!world.hasEntityExtension(e, "Forged")); +} + +test "activateExtension refuses a component whose alignment differs" { + const gpa = testing.allocator; + const ext_bytes = try buildOneComponentExt(gpa, "ExtX", 4, 4); + defer gpa.free(ext_bytes); + var world = World.init(); + defer world.deinit(gpa); + const base = try registerRaw(gpa, &world.registry, "ExtBase", 4, 4); + const x = try registerRaw(gpa, &world.registry, "ExtX", 4, 2); + const e = try world.spawnDynamic(gpa, &[_]ComponentId{base}); + try testing.expectError(error.SchemaMismatch, activateExtension(&world, gpa, e, "Forged", ext_bytes)); + try testing.expect(world.componentBytes(e, x) == null); + try testing.expect(!world.hasEntityExtension(e, "Forged")); +} + test "activateExtension is all-or-nothing under injected OOM" { const backing = testing.allocator; const ext_bytes = try buildExtPrefab(backing); diff --git a/src/etch/scene_cook.zig b/src/etch/scene_cook.zig index d85aa8a1..e6e841db 100644 --- a/src/etch/scene_cook.zig +++ b/src/etch/scene_cook.zig @@ -77,6 +77,10 @@ pub const CookError = error{ DuplicateType, /// A scene component/resource instance names a type that was never declared. UndeclaredType, + /// An entity instance names a resource, which is no entity component. + ResourceAsComponent, + /// A `resources` block names an entity component, which is no resource. + ComponentAsResource, /// A field name in an instance body is not a field of the resolved type. UnknownField, /// A `..spread` field appeared in a component/resource instance body. @@ -836,8 +840,7 @@ const Builder = struct { var c: usize = 0; while (c < arch.component_count) : (c += 1) { const sch = acc.schema(arch.schemaIndex(c)); - const id = self.registry.idOf(sch.name) orelse return fail(diag_out, error.BaseSchemaMismatch, "base prefab uses a component the variant does not declare"); - if (self.registry.componentSize(id) != sch.size) return fail(diag_out, error.BaseSchemaMismatch, "base prefab component size disagrees with the variant registry layout"); + const id = try self.baseColumnId(sch, diag_out); ids0[c] = id; } var slot: usize = 0; @@ -903,7 +906,7 @@ const Builder = struct { for (instances) |ci| { const type_name = self.ast.strings.slice(ci.type_name); - const id = self.registry.idOf(type_name) orelse return fail(diag_out, error.UndeclaredType, "variant entity references an undeclared component type"); + const id = try self.entityComponentId(type_name, "variant entity references an undeclared component type", diag_out); if (indexOfId(ids.items, id)) |ci_idx| { // Prefab cook (`collect_crossrefs` false) → `source_uuid_idx` is // unused (Entity slots stay `dead`, no pending recorded). @@ -984,7 +987,7 @@ const Builder = struct { var blobs = try self.a().alloc([]u8, instances.len); for (instances, 0..) |ci, k| { const type_name = self.ast.strings.slice(ci.type_name); - const id = self.registry.idOf(type_name) orelse return fail(diag_out, error.UndeclaredType, "entity references an undeclared component type"); + const id = try self.entityComponentId(type_name, "entity references an undeclared component type", diag_out); ids[k] = id; blobs[k] = try self.buildComponentBlob(id, ci, uuid_idx, diag_out); } @@ -1037,7 +1040,7 @@ const Builder = struct { for (members) |m| switch (m.kind) { .component => { const ci = self.ast.component_instances.items[m.index]; - const id = self.registry.idOf(self.ast.strings.slice(ci.type_name)) orelse return fail(diag_out, error.UndeclaredType, "instance component references an undeclared component type"); + const id = try self.entityComponentId(self.ast.strings.slice(ci.type_name), "instance component references an undeclared component type", diag_out); if (indexOfId(ids.items, id)) |idx| { blobs.items[idx] = try self.mergeComponentBlob(blobs.items[idx], id, ci, uuid_idx, diag_out); } else { @@ -1092,14 +1095,31 @@ const Builder = struct { var c: usize = 0; while (c < arch.component_count) : (c += 1) { const sch = acc.schema(arch.schemaIndex(c)); - const id = self.registry.idOf(sch.name) orelse return fail(diag_out, error.BaseSchemaMismatch, "instanced prefab uses a component the scene does not declare"); - if (self.registry.componentSize(id) != sch.size) return fail(diag_out, error.BaseSchemaMismatch, "instanced prefab component size disagrees with the scene registry layout"); + const id = try self.baseColumnId(sch, diag_out); try ids.append(self.gpa, id); try blobs.append(self.gpa, try self.a().dupe(u8, arch.componentSlot(c, 0))); } } } + /// Resolve an instance's type name to an entity component id; a resource is + /// refused, since the loader refuses it as a column. + fn entityComponentId(self: *Builder, name: []const u8, undeclared_msg: []const u8, diag_out: ?*[]const u8) CookError!ComponentId { + const id = self.registry.idOf(name) orelse return fail(diag_out, error.UndeclaredType, undeclared_msg); + if (self.registry.componentKind(id) == .resource) return fail(diag_out, error.ResourceAsComponent, "entity instance names a resource, which is no entity component"); + return id; + } + + /// Resolve a base prefab's on-disk column to this registry's entity component + /// of the same size and alignment, the predicate the loader applies. + fn baseColumnId(self: *Builder, sch: accessor.Accessor.Schema, diag_out: ?*[]const u8) CookError!ComponentId { + const id = self.registry.idOf(sch.name) orelse return fail(diag_out, error.BaseSchemaMismatch, "base prefab uses a component this source does not declare"); + if (self.registry.componentKind(id) == .resource) return fail(diag_out, error.BaseSchemaMismatch, "base prefab column is declared a resource here"); + if (self.registry.componentSize(id) != sch.size or self.registry.componentAlignment(id) != sch.alignment) + return fail(diag_out, error.BaseSchemaMismatch, "base prefab column layout disagrees with this source's declaration"); + return id; + } + /// Build one component blob (`componentSize` bytes) from the type defaults /// overridden by the instance's fields. Scalar fields encode in place; an /// `.entity_` field is NOT encoded — its slot keeps the default `EntityId.dead` @@ -1218,6 +1238,7 @@ const Builder = struct { for (insts, 0..) |ci, ri| { const type_name = self.ast.strings.slice(ci.type_name); const id = self.registry.idOf(type_name) orelse return fail(diag_out, error.UndeclaredType, "resources block references an undeclared resource type"); + if (self.registry.componentKind(id) != .resource) return fail(diag_out, error.ComponentAsResource, "resources block names an entity component, which is no resource"); out[ri] = try self.buildResourceEntry(id, ci, diag_out); } return out; diff --git a/tests/scene/extensions_test.zig b/tests/scene/extensions_test.zig index 3f06d6ca..4ee69065 100644 --- a/tests/scene/extensions_test.zig +++ b/tests/scene/extensions_test.zig @@ -1094,3 +1094,38 @@ test "an ALL-SPARSE extension activates without touching the archetype" { const hb = world.componentBytes(npc, health_id).?; try std.testing.expectEqual(@as(i32, 100), std.mem.readInt(i32, hb[4..8], .little)); } + +test "an extension naming a resource does not cook" { + const gpa = std.testing.allocator; + var base = try scene_cook.cookPrefab(gpa, base_character, null, null); + defer base.deinit(gpa); + const base_bytes = try scene.writer.write(gpa, base.model, &base.registry); + defer gpa.free(base_bytes); + var base_res = OneResolver{ .name = "BaseCharacter", .bytes = base_bytes }; + const source = + \\resource Settings { x: i32 = 0 } + \\prefab "Bad" extends "BaseCharacter" { + \\ entity "mod" { uuid: "9c4f3a2b-1e7d-4a5c-b8e9-f4d2c3a1b5e6" Settings { x: 1 } } + \\} + ; + if (scene_cook.cookPrefab(gpa, source, base_res.base(), null)) |cooked| { + var c = cooked; + c.deinit(gpa); + return error.TestUnexpectedResult; + } else |err| try std.testing.expectEqual(error.ResourceAsComponent, err); +} + +test "a scene entity naming a resource does not cook" { + const gpa = std.testing.allocator; + const source = + \\resource Settings { x: i32 = 0 } + \\scene "S" { + \\ entity "e" { uuid: "9c4f3a2b-1e7d-4a5c-b8e9-f4d2c3a1b5e6" Settings { x: 1 } } + \\} + ; + if (scene_cook.cook(gpa, source, null)) |cooked| { + var c = cooked; + c.deinit(gpa); + return error.TestUnexpectedResult; + } else |err| try std.testing.expectEqual(error.ResourceAsComponent, err); +} diff --git a/tests/scene/prefab_flatten_test.zig b/tests/scene/prefab_flatten_test.zig index cc03f11f..300eb92c 100644 --- a/tests/scene/prefab_flatten_test.zig +++ b/tests/scene/prefab_flatten_test.zig @@ -234,3 +234,99 @@ test "instancing a multi-entity prefab is rejected" { var diag: []const u8 = ""; try std.testing.expectError(error.MultiEntityInstanceUnsupported, scene_cook.cookScene(gpa, src, resolver.base(), &diag)); } + +/// Cook `src` as a variant prefab `of "Torch"`, expecting the refusal `want`. +fn expectVariantRefused(src: []const u8, want: anyerror) !void { + const gpa = std.testing.allocator; + var torch = try cookTorch(gpa); + defer torch.deinit(gpa); + const torch_bytes = try scene.writer.write(gpa, torch.model, &torch.registry); + defer gpa.free(torch_bytes); + var resolver = OneResolver{ .name = "Torch", .bytes = torch_bytes }; + if (scene_cook.cookPrefab(gpa, src, resolver.base(), null)) |cooked| { + var c = cooked; + c.deinit(gpa); + return error.TestUnexpectedResult; + } else |err| try std.testing.expectEqual(want, err); +} + +/// Cook `src` as a scene instancing "Torch", expecting the refusal `want`. +fn expectSceneRefused(src: []const u8, want: anyerror) !void { + const gpa = std.testing.allocator; + var torch = try cookTorch(gpa); + defer torch.deinit(gpa); + const torch_bytes = try scene.writer.write(gpa, torch.model, &torch.registry); + defer gpa.free(torch_bytes); + var resolver = OneResolver{ .name = "Torch", .bytes = torch_bytes }; + if (scene_cook.cookScene(gpa, src, resolver.base(), null)) |cooked| { + var c = cooked; + c.deinit(gpa); + return error.TestUnexpectedResult; + } else |err| try std.testing.expectEqual(want, err); +} + +test "a variant entity naming a resource does not cook" { + try expectVariantRefused(scene_decls ++ + \\resource Settings { x: i32 = 0 } + \\prefab "V" of "Torch" { entity "root" { Settings { x: 1 } } } + , error.ResourceAsComponent); +} + +test "an instance body naming a resource does not cook" { + try expectSceneRefused(scene_decls ++ + \\resource Settings { x: i32 = 0 } + \\scene "S" { + \\ instance of "Torch" "I" { uuid: "00000000-0000-0000-0000-0000000000a1" Settings { x: 1 } } + \\} + , error.ResourceAsComponent); +} + +test "a variant declaring a base column as a resource does not cook" { + try expectVariantRefused( + \\component Transform { x: f32 = 0.0, y: f32 = 0.0, z: f32 = 0.0 } + \\resource Light { intensity: f32 = 2000.0, radius: f32 = 8.0 } + \\prefab "V" of "Torch" { entity "root" { Transform { x: 2.0 } } } + , error.BaseSchemaMismatch); +} + +test "a scene declaring an instanced column as a resource does not cook" { + try expectSceneRefused( + \\component Transform { x: f32 = 0.0, y: f32 = 0.0, z: f32 = 0.0 } + \\resource Light { intensity: f32 = 2000.0, radius: f32 = 8.0 } + \\scene "S" { + \\ instance of "Torch" "I" { uuid: "00000000-0000-0000-0000-0000000000a1" } + \\} + , error.BaseSchemaMismatch); +} + +test "a scene resources block naming a component does not cook" { + const gpa = std.testing.allocator; + const src = scene_decls ++ + \\scene "S" { + \\ resources { Transform { x: 1.0 } } + \\} + ; + if (scene_cook.cook(gpa, src, null)) |cooked| { + var c = cooked; + c.deinit(gpa); + return error.TestUnexpectedResult; + } else |err| try std.testing.expectEqual(error.ComponentAsResource, err); +} + +test "a variant whose base column alignment differs does not cook" { + try expectVariantRefused( + \\component Transform { x: f32 = 0.0, y: f32 = 0.0, z: f32 = 0.0 } + \\component Light { a: f64 = 0.0 } + \\prefab "V" of "Torch" { entity "root" { Transform { x: 2.0 } } } + , error.BaseSchemaMismatch); +} + +test "a scene whose instanced column alignment differs does not cook" { + try expectSceneRefused( + \\component Transform { x: f32 = 0.0, y: f32 = 0.0, z: f32 = 0.0 } + \\component Light { a: f64 = 0.0 } + \\scene "S" { + \\ instance of "Torch" "I" { uuid: "00000000-0000-0000-0000-0000000000a1" } + \\} + , error.BaseSchemaMismatch); +} diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index c5125aee..e33b16f5 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2647, - else => 2649, + .windows => 2658, + else => 2660, }; } From bf37458f969ebd8f045ec8cc38e802cbf9587a47 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Fri, 25 Sep 2026 22:37:59 +0200 Subject: [PATCH 075/141] feat(etch): type-check extension hooks and requires, at source and cook Decision 3, the source end. Nothing read a hook body or a requires clause: validatePrefab stopped at the component instances. Now: - every requires name must be a declared component (E1793), looked up as a component instance is, local symbol first, then import; - each hook is checked like a rule body gated on requires plus the extension's own components, with an implicit entity: Entity, synchronous and unable to throw, engine resources readable and user ones not; - a return in a hook is E1798 IllegalReturnInExtensionHook; - events follow the rule path, so an undeclared one is E0102; - cookPrefab runs the same check and refuses with HookRefused, through checkPrefabHooks, which shares check's declaration passes and reports only the prefab half, since a cook source reaches its base by resolver. Twelve witnesses, nine red first and three controls, one of them in project mode. Test floor 2660 -> 2672 / 2658 -> 2670, from the suite. Co-Authored-By: Claude Opus 5.5 --- src/etch/diagnostics.zig | 5 + src/etch/scene_cook.zig | 18 ++ src/etch/types.zig | 327 ++++++++++++++++++++++++++--- tests/etch/import_resolve_test.zig | 22 ++ tests/scene/extensions_test.zig | 23 ++ tools/weld_lint/dead_tests.zig | 4 +- 6 files changed, 366 insertions(+), 33 deletions(-) diff --git a/src/etch/diagnostics.zig b/src/etch/diagnostics.zig index 89302b4a..599477b2 100644 --- a/src/etch/diagnostics.zig +++ b/src/etch/diagnostics.zig @@ -377,6 +377,7 @@ pub const DiagnosticCode = enum { prefab_component_redefined, // E1796 PrefabComponentRedefined (RESERVED: variant/base component-shape merge is a runtime concern) prefab_remove_base_component, // W1790 PrefabRemoveBaseComponent (RESERVED: no `remove` syntax in the §24.1 grammar) extension_additive_conflict, // E1797 ExtensionAdditiveConflict (fatal cook error, strictly-additive `extends` → reject: (a) two extensions declare the same component, (b) an extension declares a component already carried by the base/an earlier extension, (c) the same extension is listed twice; guarantees `cooked ⇒ loadable`; runtime backstops `error.ExtensionComponentConflict` (a/b) / `error.ExtensionAlreadyActive` (c)) + illegal_return_in_extension_hook, // E1798 IllegalReturnInExtensionHook (`return` in an `on_attach` / `on_detach` body: a hook has no caller) // ── async / effects (9xx, etch-resolver-types.md §9.2) ── async_call_in_non_async_context, // E0901 AsyncCallInNonAsyncContext (async fn/method call, or `await`, in a non-async fn/rule) @@ -603,6 +604,7 @@ pub const DiagnosticCode = enum { .prefab_component_redefined => "E1796", .prefab_remove_base_component => "W1790", .extension_additive_conflict => "E1797", + .illegal_return_in_extension_hook => "E1798", .async_call_in_non_async_context => "E0901", .unhandled_throws_call => "E0902", .await_not_statement_head => "E0904", @@ -812,6 +814,7 @@ pub const DiagnosticCode = enum { .prefab_component_redefined => "PrefabComponentRedefined", .prefab_remove_base_component => "PrefabRemoveBaseComponent", .extension_additive_conflict => "ExtensionAdditiveConflict", + .illegal_return_in_extension_hook => "IllegalReturnInExtensionHook", .async_call_in_non_async_context => "AsyncCallInNonAsyncContext", .unhandled_throws_call => "UnhandledThrowsCall", .await_not_statement_head => "AwaitNotStatementHead", @@ -930,4 +933,6 @@ test "DiagnosticCode code and name are stable cross-version" { try std.testing.expectEqualStrings("AmbiguousEventEntityTarget", DiagnosticCode.ambiguous_event_entity_target.name()); try std.testing.expectEqualStrings("E1797", DiagnosticCode.extension_additive_conflict.code()); try std.testing.expectEqualStrings("ExtensionAdditiveConflict", DiagnosticCode.extension_additive_conflict.name()); + try std.testing.expectEqualStrings("E1798", DiagnosticCode.illegal_return_in_extension_hook.code()); + try std.testing.expectEqualStrings("IllegalReturnInExtensionHook", DiagnosticCode.illegal_return_in_extension_hook.name()); } diff --git a/src/etch/scene_cook.zig b/src/etch/scene_cook.zig index e6e841db..1195f310 100644 --- a/src/etch/scene_cook.zig +++ b/src/etch/scene_cook.zig @@ -34,6 +34,7 @@ const std = @import("std"); const ast_mod = @import("ast.zig"); const interp = @import("interp.zig"); const types_mod = @import("types.zig"); +const Diagnostic = @import("diagnostics.zig").Diagnostic; const bridge_mod = @import("ecs_bridge.zig"); const value_mod = @import("value.zig"); // `renderStmtRunAlloc` renders an extends prefab's on_attach/on_detach @@ -118,6 +119,8 @@ pub const CookError = error{ /// An `extends` prefab's `on_attach`/`on_detach` body could not be rendered to /// canonical Etch text (a construct outside the descriptor renderer's surface). HookRenderFailed, + /// An extension hook, or its `requires` clause, fails the type checker. + HookRefused, /// `prefab "Y" of "X"` but the base `X.prefab.bin` could not be resolved /// (no resolver, or the resolver returned null for the base name). BasePrefabMissing, @@ -245,6 +248,20 @@ pub const BaseResolver = struct { } }; +/// Refuse an extension hook or `requires` clause the type checker refuses, so +/// a cooked hook is one `etch check` accepts (decision 3 point 4). +fn checkHooks(gpa: std.mem.Allocator, ast: *AstArena, diag_out: ?*[]const u8) CookError!void { + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + types_mod.TypeChecker.checkPrefabHooks(gpa, ast, &diags) catch |err| return switch (err) { + error.OutOfMemory => error.OutOfMemory, + }; + if (diags.items.len > 0) return fail(diag_out, error.HookRefused, "an extension hook or its requires clause fails the type checker"); +} + /// Cook a `.prefab.etch` source into the neutral model + its registry, the same /// way `cook` handles `.scene.etch`. A prefab is a mini-scene (one `prefab` /// construct, body = `{ entity_decl }`, no `resources`/`instance`), serialized to @@ -271,6 +288,7 @@ pub fn cookPrefab( defer pr.deinit(gpa); if (pr.diagnostics.len > 0) return fail(diag_out, error.ParseFailed, "Etch parse failed"); const ast = &pr.ast; + try checkHooks(gpa, ast, diag_out); var registry = Registry.init(); errdefer registry.deinit(gpa); diff --git a/src/etch/types.zig b/src/etch/types.zig index 8744254c..448cff2e 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -547,6 +547,10 @@ pub const TypeChecker = struct { /// through to E0102 (`test_world`/`tick_until`) or E0910 (`measure`). Set/restored /// around the body in `checkTest`. in_test_body: bool = false, + /// Whether the statements being checked are an `on_attach` / `on_detach` + /// body: a `return` there is E1798, and the E1210 / E1213 messages name the + /// hook's scope. Set/restored in `checkPrefabHook`. + in_hook_body: bool = false, /// The kind of the INNERMOST `race`/`sync` branch or `branch`/`spawn` body /// enclosing the statements being checked, `null` outside any. /// Drives E0906 (a `return` is legal only in a `race` branch — @@ -771,7 +775,8 @@ pub const TypeChecker = struct { // Builtin `Error` / `ErrorCode` declarations ( // part1 §10.2) join the arena before pass 1 so they register like // ordinary declarations. Every interp / codegen driver runs through - // `check`, so the injection point is unique. + // `check`, and the cook through `checkPrefabHooks`; the injection is + // idempotent. try arena.ensureErrorBuiltins(gpa); var tc: TypeChecker = .{ .gpa = gpa, @@ -780,39 +785,76 @@ pub const TypeChecker = struct { .project = project, }; defer tc.deinit(); + try tc.runDeclarationPasses(); + try tc.validatePrefabDecls(); + try tc.pass2Resolve(); + } + + /// Every pass `check` runs before the prefab validation: the symbols, + /// imports, impls, tags, services and events a statement check reads, and + /// the construct validators. Shared with `checkPrefabHooks`. + fn runDeclarationPasses(self: *TypeChecker) !void { // E1901 runs FIRST: it decides whether the file is even allowed to // contain what it contains, and a `.d.etch` carrying a `rule` would // otherwise produce a cascade of resolution errors on a body that had // no business being parsed. Cheap either way — one walk of the item // column, and an immediate return in `.standard` mode. - try tc.checkDeclarationFileConstructs(); - try tc.checkLiteralRanges(); - try tc.collectServices(); - try tc.collectDeclaredEvents(); - try tc.pass1Collect(); - try tc.bindImports(); - try tc.validateTypeAliases(); - try tc.validateImpls(); - try tc.validateDataDecls(); - try tc.validateRoutineDecls(); - try tc.buildTags(); - try tc.validateBehaviorDecls(); - try tc.validateQuestDecls(); - try tc.validateDialogueDecls(); - try tc.validateAbilityDecls(); - try tc.validateThemeDecls(); - try tc.validateMotionDecls(); - try tc.validateInputMappingDecls(); - try tc.validateWidgetDecls(); - try tc.validateLocaleDecls(); - try tc.validateEffectDecls(); - try tc.validateAudioScoreDecls(); - try tc.validateSequenceDecls(); - try tc.validateAnimGraphDecls(); - try tc.validateShaderDecls(); - try tc.validateSceneDecls(); - try tc.validatePrefabDecls(); - try tc.pass2Resolve(); + try self.checkDeclarationFileConstructs(); + try self.checkLiteralRanges(); + try self.collectServices(); + try self.collectDeclaredEvents(); + try self.pass1Collect(); + try self.bindImports(); + try self.validateTypeAliases(); + try self.validateImpls(); + try self.validateDataDecls(); + try self.validateRoutineDecls(); + try self.buildTags(); + try self.validateBehaviorDecls(); + try self.validateQuestDecls(); + try self.validateDialogueDecls(); + try self.validateAbilityDecls(); + try self.validateThemeDecls(); + try self.validateMotionDecls(); + try self.validateInputMappingDecls(); + try self.validateWidgetDecls(); + try self.validateLocaleDecls(); + try self.validateEffectDecls(); + try self.validateAudioScoreDecls(); + try self.validateSequenceDecls(); + try self.validateAnimGraphDecls(); + try self.validateShaderDecls(); + try self.validateSceneDecls(); + } + + /// The prefab half of `check`, for the cook (decision 3 point 4): the + /// declarations are collected by `check`'s own passes, and only `requires` + /// names and hook bodies are reported into `diagnostics`. A cook source + /// reaches its base prefab through a resolver, so the rest of `check` + /// (E1791 first) does not apply to it. + pub fn checkPrefabHooks(gpa: std.mem.Allocator, arena: *AstArena, diagnostics: *std.ArrayListUnmanaged(Diagnostic)) !void { + try arena.ensureErrorBuiltins(gpa); + var scratch: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (scratch.items) |*d| d.deinit(gpa); + scratch.deinit(gpa); + } + var tc: TypeChecker = .{ + .gpa = gpa, + .arena = arena, + .diagnostics = &scratch, + .project = null, + }; + defer tc.deinit(); + try tc.runDeclarationPasses(); + tc.diagnostics = diagnostics; + const kinds = arena.items.items(.kind); + const datas = arena.items.items(.data); + var i: u28 = 0; + while (i < arena.items.len) : (i += 1) { + if (kinds[i] != .prefab_decl) continue; + try tc.checkPrefabRequiresAndHooks(arena.prefab_decls.items[datas[i]]); + } } /// `E1901 ConstructNotAllowedInDeclarationFile` (`etch-grammar.md` @@ -2382,6 +2424,79 @@ pub const TypeChecker = struct { try self.checkComponentInstance(self.arena.component_instances.items[ent.components_start + f], .prefab_component_type_unknown, .prefab_component_field_unknown, .prefab_component_field_type_invalid); } } + try self.checkPrefabRequiresAndHooks(decl); + } + + /// Whether `name` is a declared component, looked up as + /// `checkComponentInstance` does: a local symbol shadows an import. + fn isComponentName(self: *TypeChecker, name: StringId) bool { + if (self.symbols.get(name)) |sym| return sym.kind == .component; + if (self.imported_symbols.get(name)) |entry| return entry.kind == .component; + return false; + } + + /// E1793 on a `requires` name that is no component, then each hook body. + /// `requires` keeps no span per name, so the diagnostic sits on the prefab + /// name, as E1790/E1791 do. + fn checkPrefabRequiresAndHooks(self: *TypeChecker, decl: ast_mod.PrefabDecl) !void { + var r: u32 = 0; + while (r < decl.requires_len) : (r += 1) { + const req = self.arena.prefab_requires.items[decl.requires_start + r]; + if (!self.isComponentName(req)) { + try self.emit(.prefab_component_type_unknown, .error_, decl.name_span, "prefab '{s}' requires '{s}', which is not a declared component", .{ self.arena.strings.slice(decl.name), self.arena.strings.slice(req) }); + } + } + if (decl.has_on_attach) try self.checkPrefabHook(decl, decl.on_attach_start, decl.on_attach_len); + if (decl.has_on_detach) try self.checkPrefabHook(decl, decl.on_detach_start, decl.on_detach_len); + } + + /// One hook body (part2 §30.3): gated like a `when` clause on `requires` ∪ + /// the extension's own components, synchronous and unable to throw, with an + /// implicit `entity: Entity`. User resources stay unreachable, engine ones + /// readable, as in a rule with no `when resource`. Each hook gets a fresh + /// context. + fn checkPrefabHook(self: *TypeChecker, decl: ast_mod.PrefabDecl, start: u32, len: u32) !void { + var ctx: RuleCtx = .{}; + defer ctx.deinit(self.gpa); + var r: u32 = 0; + while (r < decl.requires_len) : (r += 1) { + try ctx.components_in_when.put(self.gpa, self.arena.prefab_requires.items[decl.requires_start + r], {}); + } + var e: u32 = 0; + while (e < decl.entities_len) : (e += 1) { + const ent = self.arena.scene_entities.items[decl.entities_start + e]; + var c: u32 = 0; + while (c < ent.components_len) : (c += 1) { + try ctx.components_in_when.put(self.gpa, self.arena.component_instances.items[ent.components_start + c].type_name, {}); + } + } + if (self.arena.strings.find("entity")) |eid| { + try ctx.locals.put(self.gpa, eid, .{ .type_ = .{ .builtin = .entity }, .is_mut = false }); + } + const saved_async = self.current_is_async; + const saved_susp = self.await_suspendable; + const saved_throw = self.current_can_throw; + const saved_ret = self.current_fn_return; + const saved_branch = self.conc_branch; + const saved_hook = self.in_hook_body; + self.current_is_async = false; + self.await_suspendable = true; + self.current_can_throw = false; + self.current_fn_return = null; + self.conc_branch = null; + self.in_hook_body = true; + defer { + self.current_is_async = saved_async; + self.await_suspendable = saved_susp; + self.current_can_throw = saved_throw; + self.current_fn_return = saved_ret; + self.conc_branch = saved_branch; + self.in_hook_body = saved_hook; + } + var s: u32 = 0; + while (s < len) : (s += 1) { + try self.checkStmt(&ctx, @bitCast(self.arena.extra.items[start + s])); + } } fn validateDataTable(self: *TypeChecker, table_idx: u32) !void { @@ -5779,6 +5894,9 @@ pub const TypeChecker = struct { // parent has potentially already advanced — no propagation // site (`etch-resolver-types.md` §9.2). Asymmetric by design // — do NOT generalize. + if (self.in_hook_body) { + try self.emit(.illegal_return_in_extension_hook, .error_, self.arena.stmtSpan(stmt_id), "'return' is illegal in an extension hook (on_attach / on_detach): a hook has no caller to return to", .{}); + } if (self.conc_branch) |ck| { if (ck != .race) { try self.emit(.illegal_return_in_concurrency_branch, .error_, self.arena.stmtSpan(stmt_id), "'return' is illegal in a '{s}' {s} (only a 'race' branch may return — the winner's return propagates at the race site)", .{ @tagName(ck), if (ck == .sync) "branch" else "body" }); @@ -6474,7 +6592,11 @@ pub const TypeChecker = struct { } if (ctx_opt) |ctx| { if (!ctx.unrestricted_ecs_access and !ctx.resources_in_when.contains(mg.type_name)) { - try self.emit(.resource_expected_in_when, .error_, self.arena.exprSpan(id), "resource '{s}' is not accessible — add it to the rule's when clause", .{tname}); + if (self.in_hook_body) { + try self.emit(.resource_expected_in_when, .error_, self.arena.exprSpan(id), "resource '{s}' is not accessible — an extension hook reads engine resources only", .{tname}); + } else { + try self.emit(.resource_expected_in_when, .error_, self.arena.exprSpan(id), "resource '{s}' is not accessible — add it to the rule's when clause", .{tname}); + } } } return .{ .resource = mg.type_name }; @@ -6508,7 +6630,11 @@ pub const TypeChecker = struct { } if (ctx_opt) |ctx| { if (!ctx.unrestricted_ecs_access and !ctx.components_in_when.contains(mg.type_name)) { - try self.emit(.unknown_component_in_when, .error_, self.arena.exprSpan(id), "component '{s}' is not accessible — add it to the rule's when clause", .{tname}); + if (self.in_hook_body) { + try self.emit(.unknown_component_in_when, .error_, self.arena.exprSpan(id), "component '{s}' is not accessible — add it to the prefab's requires clause", .{tname}); + } else { + try self.emit(.unknown_component_in_when, .error_, self.arena.exprSpan(id), "component '{s}' is not accessible — add it to the rule's when clause", .{tname}); + } } } return .{ .component = mg.type_name }; @@ -8887,6 +9013,145 @@ test "prefab E1793/E1794/E1795: component type + field-name + field-type checks" try expectAnyCode(r3.diagnostics.items, .prefab_component_field_type_invalid); } +const hook_base = + \\component Health { current: float = 1.0, max: float = 1.0 } + \\component Weapon { damage: float = 1.0 } + \\component Mana { v: float = 0.0 } + \\prefab "Base" { entity "r" { Health {} } } + \\ +; + +/// Check `hook_base ++ src`, requiring the source to parse clean. +fn checkHookSource(gpa: std.mem.Allocator, src: []const u8) !CheckOutcome { + var r = try parseAndCheck(gpa, src); + errdefer r.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), r.parse_diags.len); + return r; +} + +test "a hook reaching a component outside requires and its own is E1210" { + const gpa = std.testing.allocator; + var r = try checkHookSource(gpa, hook_base ++ + \\prefab "Mod" extends "Base" requires Health { + \\ entity "m" { Weapon {} } + \\ on_attach { entity.get_mut(Mana).v += 1.0 } + \\} + ); + defer r.deinit(gpa); + try expectAnyCode(r.diagnostics.items, .unknown_component_in_when); +} + +test "a hook reaching its requires and its own components is clean" { + const gpa = std.testing.allocator; + var r = try checkHookSource(gpa, hook_base ++ + \\prefab "Mod" extends "Base" requires Health { + \\ entity "m" { Weapon {} } + \\ on_attach { entity.get_mut(Health).max += entity.get(Weapon).damage } + \\ on_detach { entity.get_mut(Health).max -= entity.get(Weapon).damage } + \\} + ); + defer r.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), r.diagnostics.items.len); +} + +test "a requires naming no component is E1793" { + const gpa = std.testing.allocator; + var r = try checkHookSource(gpa, hook_base ++ + \\prefab "Mod" extends "Base" requires Ghost { + \\ entity "m" { Weapon {} } + \\} + ); + defer r.deinit(gpa); + try expectAnyCode(r.diagnostics.items, .prefab_component_type_unknown); +} + +test "a return in a hook is E1798" { + const gpa = std.testing.allocator; + var r = try checkHookSource(gpa, hook_base ++ + \\prefab "Mod" extends "Base" requires Health { + \\ entity "m" { Weapon {} } + \\ on_attach { return } + \\} + ); + defer r.deinit(gpa); + try expectAnyCode(r.diagnostics.items, .illegal_return_in_extension_hook); +} + +test "an await in a hook is E0901" { + const gpa = std.testing.allocator; + var r = try checkHookSource(gpa, hook_base ++ + \\prefab "Mod" extends "Base" requires Health { + \\ entity "m" { Weapon {} } + \\ on_attach { await wait(1.0s) } + \\} + ); + defer r.deinit(gpa); + try expectAnyCode(r.diagnostics.items, .async_call_in_non_async_context); +} + +test "a hook emitting an undeclared event is E0102" { + const gpa = std.testing.allocator; + var r = try checkHookSource(gpa, hook_base ++ + \\prefab "Mod" extends "Base" requires Health { + \\ entity "m" { Weapon {} } + \\ on_attach { emit Undeclared {} } + \\} + ); + defer r.deinit(gpa); + try expectAnyCode(r.diagnostics.items, .undefined_symbol); +} + +test "a hook calling a throws fn outside a try is E0902" { + const gpa = std.testing.allocator; + var r = try checkHookSource(gpa, hook_base ++ + \\fn risky(n: int) throws -> int { return n } + \\prefab "Mod" extends "Base" requires Health { + \\ entity "m" { Weapon {} } + \\ on_attach { let v = risky(1) } + \\} + ); + defer r.deinit(gpa); + try expectAnyCode(r.diagnostics.items, .unhandled_throws_call); +} + +test "a hook reading a user resource is E1213" { + const gpa = std.testing.allocator; + var r = try checkHookSource(gpa, hook_base ++ + \\resource Settings { v: int = 0 } + \\prefab "Mod" extends "Base" requires Health { + \\ entity "m" { Weapon {} } + \\ on_attach { let v = get(Settings).v } + \\} + ); + defer r.deinit(gpa); + try expectAnyCode(r.diagnostics.items, .resource_expected_in_when); +} + +test "a hook reading an engine resource is clean" { + const gpa = std.testing.allocator; + var r = try checkHookSource(gpa, hook_base ++ + \\prefab "Mod" extends "Base" requires Health { + \\ entity "m" { Weapon {} } + \\ on_attach { let t = get(GameTime).dt } + \\} + ); + defer r.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), r.diagnostics.items.len); +} + +test "on_detach does not see a let of on_attach" { + const gpa = std.testing.allocator; + var r = try checkHookSource(gpa, hook_base ++ + \\prefab "Mod" extends "Base" requires Health { + \\ entity "m" { Weapon {} } + \\ on_attach { let x = 1 } + \\ on_detach { let y = x } + \\} + ); + defer r.deinit(gpa); + try expectAnyCode(r.diagnostics.items, .undefined_symbol); +} + test "type-checker emits E0102 on field referencing unknown type" { const gpa = std.testing.allocator; var result = try parseAndCheck(gpa, diff --git a/tests/etch/import_resolve_test.zig b/tests/etch/import_resolve_test.zig index 34e09e6a..da05a66b 100644 --- a/tests/etch/import_resolve_test.zig +++ b/tests/etch/import_resolve_test.zig @@ -192,3 +192,25 @@ test "entity.get reaches an imported component in a test body" { try etch.validateProject(gpa, &files, &diags); try std.testing.expectEqual(@as(usize, 0), countCode(diags.items, .undefined_symbol)); } + +test "a hook reaches imported requires and own components" { + const gpa = std.testing.allocator; + const files = [_]etch.ProjectFile{ + .{ .name = "lib.etch", .source = + \\component Health { current: float = 100.0 } + \\component Weapon { damage: float = 1.0 } + }, + .{ .name = "main.etch", .source = + \\import lib { Health, Weapon } + \\prefab "Base" { entity "r" { Health {} } } + \\prefab "Mod" extends "Base" requires Health { + \\ entity "m" { Weapon {} } + \\ on_attach { entity.get_mut(Health).current += entity.get(Weapon).damage } + \\} + }, + }; + var diags: std.ArrayListUnmanaged(etch.Diagnostic) = .empty; + defer deinitDiags(gpa, &diags); + try etch.validateProject(gpa, &files, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); +} diff --git a/tests/scene/extensions_test.zig b/tests/scene/extensions_test.zig index 4ee69065..eb2bd85b 100644 --- a/tests/scene/extensions_test.zig +++ b/tests/scene/extensions_test.zig @@ -1129,3 +1129,26 @@ test "a scene entity naming a resource does not cook" { return error.TestUnexpectedResult; } else |err| try std.testing.expectEqual(error.ResourceAsComponent, err); } + +test "an extension whose hook fails the checker does not cook" { + const gpa = std.testing.allocator; + var base = try scene_cook.cookPrefab(gpa, base_character, null, null); + defer base.deinit(gpa); + const base_bytes = try scene.writer.write(gpa, base.model, &base.registry); + defer gpa.free(base_bytes); + var base_res = OneResolver{ .name = "BaseCharacter", .bytes = base_bytes }; + const source = + \\component Health { current: i32 = 100, max: i32 = 100 } + \\component Weapon { damage: i32 = 10 } + \\component Mana { v: i32 = 0 } + \\prefab "Bad" extends "BaseCharacter" requires Health { + \\ entity "mod" { uuid: "9c4f3a2b-1e7d-4a5c-b8e9-f4d2c3a1b5e6" Weapon { damage: 25 } } + \\ on_attach { entity.get_mut(Mana).v += 1 } + \\} + ; + if (scene_cook.cookPrefab(gpa, source, base_res.base(), null)) |cooked| { + var c = cooked; + c.deinit(gpa); + return error.TestUnexpectedResult; + } else |err| try std.testing.expectEqual(error.HookRefused, err); +} diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index e33b16f5..e7d3fa83 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2658, - else => 2660, + .windows => 2670, + else => 2672, }; } From 533bb8c1aeba1d381a13158f5e03716d70688302 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Fri, 25 Sep 2026 22:56:23 +0200 Subject: [PATCH 076/141] docs(brief): record decision 3's source end, return its load end Co-Authored-By: Claude Opus 5.5 --- briefs/m1.d-phase-1-debt.md | 118 ++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index 2006cf6e..581b89fa 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -6605,6 +6605,79 @@ suite at each step. **Decision 3 is returned before writing** — B4. +### S5/G9 decem — decision 3, the source end; its three hook defects + +Order as ruled: the cook's lost annotation first, then decision 3 with the two other +hook defects. Every witness was written after its prediction and run red on the +unfixed tree; each counter-factual below was predicted before its run. + +**The `let` annotation** (`2da27b5e`). The renderer wrote `let name = value`, so a +cooked hook re-parsed at load read another program than its source. Every type form +the type parser produces renders back; tuple, function, `self` and trait bounds fail +loud. **The regression it exposed next door** (`70012129`): `6ee051c6` made +`entity.get(T)` refuse an undeclared name looking at local symbols only, so a +selectively imported component was refused; the receiver form reads the imports too. + +**The kind guard** (`d236af9a`). `activateExtension` step 1 compared the SIZE alone, +so a forged extension naming a world resource added it to the entity as a component; +it now applies the scene path's predicate, size and alignment, a resource refused as a +column. **The defect was wider than the entry**, enumerated at the code: every cook +site that turns a name into an entity column had the same gap — scene entity, variant +entity, instance body, and the two base-prefab columns, which also compared size +alone — and a `resources` block naming a component cooked too, each one a file the +loader refused, against `cooked ⇒ loadable`. Two predicates now serve the five column +sites and the `resources` block carries the inverse guard; a field override resolves +only ids already on that list, so it is safe by construction. Eleven witnesses; two of the eleven were vacuous at first — `u16` is +refused on a component, the risk named in the prediction — and were rebuilt on `f64`. + +**Decision 3, points 3 to 6** (`bf37458f`). `validatePrefab` stopped at the component +instances. Every `requires` name must now be a declared component (E1793, looked up as +an instance is); each hook is checked as a rule body gated on `requires` ∪ the +extension's own components, with an implicit `entity: Entity`, synchronous (E0901), +unable to call a `throws` fn outside a `try` (E0902), engine resources readable and +user ones not (E1213), a fresh context per hook; `return` is E1798 +`IllegalReturnInExtensionHook`; an undeclared event is E0102 on the rule path. The +cook runs the same check through `checkPrefabHooks`, which shares `check`'s +declaration passes — one list, not two — and reports the prefab half only, a cook +source reaching its base by resolver. Twelve witnesses, nine red first and three +controls, one in project mode. The severity filter first written in the cook's call +was removed: no warning is reachable from a hook check, so it could not fail. + +Ten counter-factuals on the source end, each run on the full suite; every one reddened +exactly the tests predicted and nothing else: + +| Mutation | Red | +|---|---| +| scope drops the extension's own components | the clean control, the project control | +| scope drops `requires` | both controls, ten `extensions_test` cooks, `prefab_integration` | +| `entity` unbound | the E1210 witness, both controls, the same eleven cooks | +| no E1798 | the `return` witness | +| hook checked as async | the `await` witness | +| hook allowed to throw | the `throws` witness | +| one context for both hooks | the fresh-context witness | +| cook skips the check | the cook witness | +| `isComponentName` ignores imports | the project control | +| `requires` names unchecked | the E1793 witness | + +**The emit defect, executed and not fixed.** `execHookText` parses the hook into an +arena it frees at the hook's end, and every id the body produces belongs to that arena +while every table it meets is keyed by the program's. Four probes, run and not +committed, each giving its predicted value: `emit Boosted { amount: 7 }` stores one +event — the existing cardinality test passes — under id 1 where the program knows +`Boosted` as 6, so no consumer can match it; a string literal field reads back +`"current"` instead of `"hero"`; a call to a program `fn` fails the hook with +`ExtensionHookFailed`; a timer set in a hook panics when it fires, `index out of +bounds: index 0, len 0` in `execTimerBody`, reading the hook's statement indices in +the program's arena. The fix is the arena itself, and it is the load end's question +too — B5. The comment in `execHookText` stating that nothing on the path dereferences +a program id while rebound, and that `emit` enqueues by event-name id, is exact and +beside the point: the id is the wrong pool's. + +Floor 2647 → 2648 → 2649 → 2660 → 2672 on macOS and 2645 → 2646 → 2647 → 2658 → 2670 +on windows, read from the suite at each step. + +**What remains of decision 3 is returned** — B5. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree @@ -6819,6 +6892,51 @@ naming a resource adds it as a component. Three statement bodies are never check either, none executed: effect `on` handlers, sequence `on_start`/`on_finish`, and keyframe emit fields. +### B5 — the load end of decision 3 needs two rulings + +Stopped at S5/G9 decem, after points 3 to 6. Points 1 and 2 — the check at load, at +`activateExtension` step 1 — cannot be written as ruled, for two reasons measured at +the code. + +1. **`requires` is not in the `.prefab.bin`.** `format.zig` carries a hook set of + `on_attach` and `on_detach` only, with no occurrence of `requires`, so at load the + gated scope of point 3 cannot be rebuilt: only the extension's own components are + known. Nor does anything check at activation that the entity carries them — the + cook compares them to the base's schema once. Recommended: serialize `requires`, + a `format_version` 2 → 3 in `engine-scene-serialization.md`, and refuse at + activation an entity missing one. +2. **A re-parsed hook lives in a throwaway arena whose ids escape it**, measured + above: an emitted event under a foreign name, a string literal read through the + wrong pool, a program fn not found, a timer running another arena's statements. + The load check hits the same wall — the checker needs the hook and the program's + declarations in one arena. Recommended: the interpreter owns a copy of the + program arena and parses each hook text into it once, so every id stays valid for + the interpreter's life, and the load check runs over those statements. Cost: one + AST copy per interpreter that runs a hook. + +Guy's texts the source end touches: E1798 needs a row in `etch-diagnostics.md` §18.3, +whose range line 130 counts 8 codes for E1790–E1799; and the specification's own +example fails point 3 — `StealthModule` (`etch-reference-part2.md:2783`) requires +`Transform`, carries `StealthSkill` and `Camouflage`, and its hooks reach +`MovementStats`, now E1210. + +Open around it, each needing an arbitration rather than a number: +- the source check accepts a timer in a hook, which the renderer refuses at cook + (`HookRenderFailed`) — traced, not run — and the load parses and runs, measured + above: `etch check` passes what the cook refuses, and a forged file reaches the + timer panic; +- a bare `throw` in a hook passes the check, measured, as in a rule, and fails at + runtime as `ExtensionHookFailed`; +- `requires` and hooks on a non-`extends` prefab are a compile-time error by the + grammar (l.1701) and refused by the cook alone, with no code; +- a two-entity `extends` cooks, measured, and the loader refuses it; +- the event store is cleared at the tick head and a hook runs at a load or flush + boundary, so what a hook emits may be cleared before its observers read it — + traced at the recon, not run; +- a load before `bindToWorld` runs no hook — traced at the recon, not run; +- `when entity has T` refuses an imported `T` and `emit` an imported event, measured + (E0102 `'Ping' is not a declared event`), both pre-existing. + ## Notes ### A comparison refuses the fingerprint of nothing before it concludes From ff45cdacd23fbafa65062eaf37cbd78bb35274fd Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 26 Sep 2026 10:21:17 +0200 Subject: [PATCH 077/141] fix(etch): let the interpreter own its arena and parse each hook once A hook ran in a throwaway arena, so every id it produced indexed a pool freed when it ended, while the tables it met are keyed by the program's: an emitted event carried a foreign name, a string literal read another string, a program fn was not found, and a timer panicked when it fired. compile now clones the caller's arena into a box the interpreter owns and runs every pass on the clone, so the caller may free its arena as soon as compile returns. Each hook text is parsed once into that arena by parseStmtBlockInto and cached by owned text; execHookText no longer rebinds self.ast. AstArena.clone walks its fields at comptime, a type with no copy rule being a compile error; StringPool.clone copies every string and rebuilds its map on the copy. The allocation-count test of a reload now subtracts the clone's own allocations, which follow the source text. The pointer-restore assertion of the hook-mutation test, whose rebind is gone, becomes an ownership one. Floor 2672 -> 2680 / 2670 -> 2678, from the suite. Co-Authored-By: Claude Opus 5.5 --- src/etch/ast.zig | 122 +++++++++++++++++++++ src/etch/interp.zig | 188 ++++++++++++++++++++++++++------- src/etch/parser.zig | 43 ++++++-- tests/etch/hot_reload_test.zig | 31 +++++- tools/weld_lint/dead_tests.zig | 4 +- 5 files changed, 336 insertions(+), 52 deletions(-) diff --git a/src/etch/ast.zig b/src/etch/ast.zig index 9e0351d7..b216c12b 100644 --- a/src/etch/ast.zig +++ b/src/etch/ast.zig @@ -166,6 +166,21 @@ pub const StringPool = struct { return self.slices.items[id]; } + /// A copy owning its own bytes, every id unchanged. The map keys point at + /// the copy's slices, never at the source's. + pub fn clone(self: *const StringPool, gpa: std.mem.Allocator) !StringPool { + var out: StringPool = .{}; + errdefer out.deinit(gpa); + try out.slices.ensureTotalCapacity(gpa, self.slices.items.len); + try out.map.ensureTotalCapacity(gpa, @intCast(self.slices.items.len)); + for (self.slices.items, 0..) |s, id| { + const owned = try gpa.dupe(u8, s); + out.slices.appendAssumeCapacity(owned); + out.map.putAssumeCapacityNoClobber(owned, @intCast(id)); + } + return out; + } + /// Look up an already-interned string's id without inserting. Returns /// `null` if `s` was never interned. Used by consumers /// that only hold a `*const AstArena` (e.g. the interpreter resolving the @@ -2964,6 +2979,22 @@ pub const AstArena = struct { self.doc_comments.deinit(gpa); } + /// A deep copy owning every buffer, with every `NodeId`, `StringId` and + /// `(start, len)` run unchanged. A field of a type with no copy rule is a + /// compile error, so no field is copied by reference or left out. + pub fn clone(self: *const AstArena, gpa: std.mem.Allocator) !AstArena { + var out: AstArena = .{}; + errdefer out.deinit(gpa); + inline for (@typeInfo(AstArena).@"struct".fields) |f| { + switch (@typeInfo(f.type)) { + .int, .@"enum" => @field(out, f.name) = @field(self, f.name), + .@"struct" => @field(out, f.name) = try @field(self, f.name).clone(gpa), + else => @compileError("AstArena.clone has no copy rule for field " ++ f.name), + } + } + return out; + } + pub fn addItem(self: *AstArena, gpa: std.mem.Allocator, kind: ItemKind, data: u32, span: SourceSpan) !NodeId { const idx: u28 = @intCast(self.items.len); try self.items.append(gpa, .{ .kind = kind, .data = data, .span = span }); @@ -4121,3 +4152,94 @@ test "AnnotationKind.fromName recognises builtin names" { try std.testing.expectEqual(AnnotationKind.entity_target, AnnotationKind.fromName("entity_target")); try std.testing.expectEqual(AnnotationKind.custom, AnnotationKind.fromName("totally_unknown")); } + +/// Every field of `a` holds the same contents as the same field of `b`. +fn expectArenasEqual(a: *const AstArena, b: *const AstArena) !void { + inline for (@typeInfo(AstArena).@"struct".fields) |f| { + const x = @field(a, f.name); + const y = @field(b, f.name); + if (comptime f.type == StringPool) { + try std.testing.expectEqual(x.slices.items.len, y.slices.items.len); + for (x.slices.items, y.slices.items) |p, q| try std.testing.expectEqualStrings(p, q); + try std.testing.expectEqual(x.map.count(), y.map.count()); + for (x.slices.items, 0..) |p, id| try std.testing.expectEqual(@as(?StringId, @intCast(id)), y.map.get(p)); + } else switch (@typeInfo(f.type)) { + .int, .@"enum" => try std.testing.expectEqual(x, y), + .@"struct" => if (comptime @hasField(f.type, "items")) { + try std.testing.expectEqualSlices(u8, std.mem.sliceAsBytes(x.items), std.mem.sliceAsBytes(y.items)); + } else if (comptime @hasField(f.type, "bytes")) { + try std.testing.expectEqual(x.len, y.len); + for (0..x.len) |i| try std.testing.expectEqual(x.get(i), y.get(i)); + } else { + try std.testing.expectEqual(x.count(), y.count()); + var it = x.iterator(); + while (it.next()) |e| try std.testing.expectEqual(e.value_ptr.*, y.get(e.key_ptr.*).?); + }, + else => comptime unreachable, + } + } +} + +const clone_fixture = + \\/// A doc comment. + \\component Health { current: i32 = 100, max: i32 = 100 } + \\event Hit { amount: int, who: string } + \\// A plain comment. + \\@phase(.update) + \\rule regen(entity: Entity) when entity has Health { + \\ entity.get_mut(Health).current += 1 + \\ emit Hit { amount: 1, who: "regen" } + \\} +; + +test "a cloned arena equals its source and outlives it" { + const parser = @import("parser.zig"); + const gpa = std.testing.allocator; + var reference = try parser.parse(gpa, clone_fixture); + defer reference.deinit(gpa); + var source = try parser.parse(gpa, clone_fixture); + try source.ast.ensureErrorBuiltins(gpa); + try reference.ast.ensureErrorBuiltins(gpa); + var copy = copy: { + defer source.deinit(gpa); + break :copy try source.ast.clone(gpa); + }; + defer copy.deinit(gpa); + try std.testing.expect(reference.ast.doc_comments.count() > 0); + try std.testing.expect(reference.ast.leading_comments.count() > 0); + try std.testing.expect(reference.ast.error_type_name != 0); + try expectArenasEqual(&reference.ast, ©); +} + +test "a cloned arena is independent of its source" { + const parser = @import("parser.zig"); + const gpa = std.testing.allocator; + var source = try parser.parse(gpa, clone_fixture); + defer source.deinit(gpa); + var copy = try source.ast.clone(gpa); + defer copy.deinit(gpa); + const strings_before = source.ast.strings.slices.items.len; + const extra_before = source.ast.extra.items.len; + _ = try copy.strings.intern(gpa, "only_in_the_copy"); + try copy.extra.append(gpa, 7); + try std.testing.expectEqual(strings_before, source.ast.strings.slices.items.len); + try std.testing.expectEqual(extra_before, source.ast.extra.items.len); + try std.testing.expect(source.ast.strings.find("only_in_the_copy") == null); +} + +test "a clone that fails to allocate frees what it took" { + const parser = @import("parser.zig"); + const gpa = std.testing.allocator; + var source = try parser.parse(gpa, clone_fixture); + defer source.deinit(gpa); + var index: usize = 0; + while (true) : (index += 1) { + var failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = index }); + if (source.ast.clone(failing.allocator())) |copy| { + var c = copy; + c.deinit(failing.allocator()); + try std.testing.expect(index > 0); + break; + } else |err| try std.testing.expectEqual(error.OutOfMemory, err); + } +} diff --git a/src/etch/interp.zig b/src/etch/interp.zig index 5f6da475..cd4fa9a7 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -184,6 +184,9 @@ const PendingTag = struct { /// direct-programmatic paths, which run outside any query iteration. const ExtOp = enum { activate, deactivate }; +/// A hook's statement run, `extra[start .. start + len]` of the interpreter's arena. +const HookRun = struct { start: u32, len: u32 }; + const PendingExtension = struct { entity: CoreEntityId, /// Owned copy of the extension name (the AST / run-string source may not @@ -1067,7 +1070,15 @@ const ObserverCtx = struct { /// the type-checked AST against a `World` once per tick. pub const Interpreter = struct { gpa: std.mem.Allocator, + /// The program, read-only; the arena `owned_ast` owns. ast: *const AstArena, + /// A copy of the caller's arena the interpreter owns, into which every + /// hook text is parsed: every id the interpreter or a hook holds indexes + /// it, and it lives as long as they do. + owned_ast: *AstArena, + /// Each hook text run so far, parsed once into `owned_ast`: its statement + /// run, or null when the text does not parse. The keys are owned. + hook_runs: std.StringHashMapUnmanaged(?HookRun) = .empty, bridge: Bridge, rule_descs: []RuleDesc, /// Top-level `fn` declarations keyed by name, for @@ -1379,6 +1390,11 @@ pub const Interpreter = struct { self.merge_seen.deinit(self.gpa); self.gpa.free(self.observer_ctxs); self.test_msg_buf.deinit(self.gpa); + var hook_keys = self.hook_runs.keyIterator(); + while (hook_keys.next()) |k| self.gpa.free(k.*); + self.hook_runs.deinit(self.gpa); + self.owned_ast.deinit(self.gpa); + self.gpa.destroy(self.owned_ast); self.* = undefined; } @@ -1416,7 +1432,15 @@ pub const Interpreter = struct { return try interp.runFor(world, ticks); } - pub fn compile(gpa: std.mem.Allocator, ast: *const AstArena, world: *World) !Interpreter { + /// Compile a copy of `program` the interpreter owns, so `program` may be + /// freed as soon as this returns. + pub fn compile(gpa: std.mem.Allocator, program: *const AstArena, world: *World) !Interpreter { + const owned_ast = try gpa.create(AstArena); + errdefer gpa.destroy(owned_ast); + owned_ast.* = try program.clone(gpa); + errdefer owned_ast.deinit(gpa); + const ast: *const AstArena = owned_ast; + var bridge = Bridge.init(); errdefer bridge.deinit(gpa); @@ -1639,6 +1663,7 @@ pub const Interpreter = struct { return .{ .gpa = gpa, .ast = ast, + .owned_ast = owned_ast, .bridge = bridge, .rule_descs = slice, .fns = fns, @@ -1920,46 +1945,50 @@ pub const Interpreter = struct { } } + /// The statement run of `text` in the interpreter's arena, parsed on the + /// first call and cached by text; `MalformedExtensionHook` when it does + /// not parse. + fn prepareHook(self: *Interpreter, text: []const u8) !HookRun { + if (self.hook_runs.get(text)) |cached| return cached orelse error.MalformedExtensionHook; + const key = try self.gpa.dupe(u8, text); + self.hook_runs.ensureUnusedCapacity(self.gpa, 1) catch |err| { + self.gpa.free(key); + return err; + }; + var entry: ?HookRun = null; + if (parser_mod.parseStmtBlockInto(self.gpa, self.owned_ast, text)) |parsed| { + var hook_run = parsed; + defer hook_run.deinit(self.gpa); + if (hook_run.diagnostics.len == 0) entry = .{ .start = hook_run.start, .len = hook_run.len }; + } else |err| switch (err) { + error.OutOfMemory => { + self.gpa.free(key); + return error.OutOfMemory; + }, + else => {}, + } + self.hook_runs.putAssumeCapacityNoClobber(key, entry); + return entry orelse error.MalformedExtensionHook; + } + /// Execute a cooked extension hook body. `hook_text` is the /// canonical Etch statement run a `.prefab.bin` carries for an `on_attach` / /// `on_detach` hook (`descriptor.renderStmtRunAlloc`): statements joined by - /// `"; "`, no braces. Parse it into a transient `AstArena`, rebind `self.ast` - /// to it for the body's duration (the executor resolves identifiers via - /// `self.ast.strings`, so the body MUST run with `ast` pointing at the hook - /// arena), bind the implicit `entity`, run the body with the same - /// `execStmtRun` that drives every rule, and route any deferred structural + /// `"; "`, no braces. It runs from the interpreter's own arena + /// (`prepareHook`) with the implicit `entity` bound, through the same + /// `execStmtRun` that drives every rule, and routes any deferred structural /// change into the world's shared observer-deferred buffer (drained by the /// loader before `on_spawned`). Mirrors `runObserverBody` — same fresh-scope /// + store-reset discipline. No re-entrancy: a hook runs at a load/flush /// boundary, never nested inside another running hook. fn execHookText(self: *Interpreter, world: *World, entity: CoreEntityId, hook_text: []const u8) !void { - var block = parser_mod.parseStmtBlock(self.gpa, hook_text) catch |err| switch (err) { - error.OutOfMemory => return error.OutOfMemory, - // A cooked hook that fails to re-parse is a corrupt asset (the cook - // validated it via `renderStmtRunAlloc` → `HookRenderFailed`), so this - // should be unreachable in practice — surface it clearly regardless. - else => return error.MalformedExtensionHook, - }; - defer block.deinit(self.gpa); - if (block.diagnostics.len > 0) return error.MalformedExtensionHook; - - // Rebind the program AST to the hook arena for the body's duration. Safe: - // `ast` is a reassignable `*const AstArena` field; nothing on the executor - // path dereferences a *program*-arena `NodeId` while rebound (component / - // resource field access + enum shorthand resolve by NAME via the registry, - // `emit` enqueues by event-name id, and hook-arena `StringId`s resolve - // through `self.ast.strings`). - const saved_ast = self.ast; - self.ast = &block.ast; - defer self.ast = saved_ast; + const hook_run = try self.prepareHook(hook_text); var locals: Locals = .{}; defer locals.deinit(self.gpa); defer self.resetBodyStores(); - // Bind the implicit `entity` — only if the body references it (else the - // name is not interned in the hook arena and no binding is needed). - if (block.ast.strings.find("entity")) |eid| { + if (self.ast.strings.find("entity")) |eid| { try locals.put(self.gpa, eid, .{ .entity_id = @bitCast(entity) }, false); } @@ -1979,7 +2008,7 @@ pub const Interpreter = struct { self.returning = false; self.pending_error = null; - self.execStmtRun(world, &locals, block.body_start, block.body_len) catch |err| switch (err) { + self.execStmtRun(world, &locals, hook_run.start, hook_run.len) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.RuntimeFailure => { self.pending_error = null; @@ -16101,7 +16130,7 @@ test "execHookText mutates a component on the live world" { try std.testing.expectEqual(@as(i32, 150), std.mem.readInt(i32, hb[4..8], .little)); // max @4 } -test "execHookText restores self.ast and the program still steps" { +test "a hook runs in the interpreter's own arena and the program still steps" { const gpa = std.testing.allocator; var world = World.init(); defer world.deinit(gpa); @@ -16115,20 +16144,107 @@ test "execHookText restores self.ast and the program still steps" { var interp = try Interpreter.compile(gpa, &pr.ast, &world); defer interp.deinit(); + try std.testing.expect(interp.ast != &pr.ast); const cid = world.registry.idOf("Health").?; var hv = [_]i32{ 100, 100 }; const eid = try world.spawnDynamicWithValues(gpa, &[_]ComponentId{cid}, &[_][]const u8{std.mem.asBytes(&hv)}); - const program_ast = interp.ast; // == &pr.ast try interp.execHookText(&world, eid, "entity.get_mut(Health).max += 50"); - // The hook ran against a transient arena; the program AST pointer is restored. - try std.testing.expectEqual(program_ast, interp.ast); - - // The program still steps on the restored AST: the rule bumps current 100→101. _ = try interp.runFor(&world, 1); const hb = world.componentBytes(eid, cid).?; try std.testing.expectEqual(@as(i32, 101), std.mem.readInt(i32, hb[0..4], .little)); // current @0 - try std.testing.expectEqual(@as(i32, 150), std.mem.readInt(i32, hb[4..8], .little)); // max @4 (hook effect persisted) + try std.testing.expectEqual(@as(i32, 150), std.mem.readInt(i32, hb[4..8], .little)); // max @4 +} + +/// A world holding one `Health { current: 100, max: 100 }` entity, and an +/// interpreter compiled from `source` onto it. +const HookFixture = struct { + world: World, + pr: parser_mod.ParseResult, + interp: Interpreter, + health: ComponentId, + entity: CoreEntityId, + + fn init(self: *HookFixture, gpa: std.mem.Allocator, source: []const u8) !void { + self.world = World.init(); + errdefer self.world.deinit(gpa); + self.pr = try parser_mod.parse(gpa, source); + errdefer self.pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), self.pr.diagnostics.len); + self.interp = try Interpreter.compile(gpa, &self.pr.ast, &self.world); + errdefer self.interp.deinit(); + self.health = self.world.registry.idOf("Health").?; + var hv = [_]i32{ 100, 100 }; + self.entity = try self.world.spawnDynamicWithValues(gpa, &[_]ComponentId{self.health}, &[_][]const u8{std.mem.asBytes(&hv)}); + } + + fn deinit(self: *HookFixture, gpa: std.mem.Allocator) void { + self.interp.deinit(); + self.pr.deinit(gpa); + self.world.deinit(gpa); + } + + fn max(self: *HookFixture) i32 { + return std.mem.readInt(i32, self.world.componentBytes(self.entity, self.health).?[4..8], .little); + } +}; + +test "an event a hook emits carries the program's name for its type" { + const gpa = std.testing.allocator; + var f: HookFixture = undefined; + try f.init(gpa, + \\component Health { current: i32 = 100, max: i32 = 100 } + \\event Boosted { amount: int } + \\rule keep(entity: Entity) when entity has Health {} + ); + defer f.deinit(gpa); + try f.interp.execHookText(&f.world, f.entity, "emit Boosted { amount: 7 }"); + try std.testing.expectEqual(@as(usize, 1), f.interp.events.count(f.pr.ast.strings.find("Boosted").?)); +} + +test "a string literal a hook emits reads back its own bytes" { + const gpa = std.testing.allocator; + var f: HookFixture = undefined; + try f.init(gpa, + \\component Health { current: i32 = 100, max: i32 = 100 } + \\component Armor { plates: i32 = 0, weight: i32 = 0 } + \\event Named { who: string } + \\rule keep(entity: Entity) when entity has Health {} + ); + defer f.deinit(gpa); + try f.interp.execHookText(&f.world, f.entity, "emit Named { who: \"hero\" }"); + const v = f.interp.events.list.items[0].fields.items[0].value; + try std.testing.expectEqualStrings("hero", f.interp.stringBytes(v).?); +} + +test "a hook calls a function of the program" { + const gpa = std.testing.allocator; + var f: HookFixture = undefined; + try f.init(gpa, + \\component Health { current: i32 = 100, max: i32 = 100 } + \\fn seven() -> int { return 7 } + \\rule keep(entity: Entity) when entity has Health {} + ); + defer f.deinit(gpa); + try f.interp.execHookText(&f.world, f.entity, "entity.get_mut(Health).max = seven()"); + try std.testing.expectEqual(@as(i32, 7), f.max()); +} + +test "a hook text is parsed once, however often it runs" { + const gpa = std.testing.allocator; + var f: HookFixture = undefined; + try f.init(gpa, + \\component Health { current: i32 = 100, max: i32 = 100 } + \\rule keep(entity: Entity) when entity has Health {} + ); + defer f.deinit(gpa); + const before = f.interp.ast.extra.items.len; + try f.interp.execHookText(&f.world, f.entity, "entity.get_mut(Health).max += 1"); + const after_first = f.interp.ast.extra.items.len; + try f.interp.execHookText(&f.world, f.entity, "entity.get_mut(Health).max += 1"); + try std.testing.expect(after_first > before); + try std.testing.expectEqual(after_first, f.interp.ast.extra.items.len); + try std.testing.expectEqual(@as(i32, 102), f.max()); } test "execHookText emit enqueues into the dynamic event store" { diff --git a/src/etch/parser.zig b/src/etch/parser.zig index c2b2cd33..89b884c6 100644 --- a/src/etch/parser.zig +++ b/src/etch/parser.zig @@ -185,10 +185,37 @@ pub fn parseWithMode(gpa: std.mem.Allocator, source: []const u8, mode: ParseMode /// between statements. An empty fragment yields a zero-statement block with no /// diagnostics. Caller owns the arena + diagnostics (`StmtBlockResult.deinit`). pub fn parseStmtBlock(gpa: std.mem.Allocator, source: []const u8) !StmtBlockResult { - var lexer = Lexer.init(source); - errdefer lexer.deinit(gpa); var arena = try AstArena.init(gpa); errdefer arena.deinit(gpa); + const run = try parseStmtBlockInto(gpa, &arena, source); + return .{ + .ast = arena, + .body_start = run.start, + .body_len = run.len, + .diagnostics = run.diagnostics, + }; +} + +/// A statement run parsed into an existing arena: `extra[start .. start + len]` +/// and the parse diagnostics, which the caller owns. +pub const StmtRunResult = struct { + start: u32, + len: u32, + diagnostics: []Diagnostic, + + pub fn deinit(self: *StmtRunResult, gpa: std.mem.Allocator) void { + for (self.diagnostics) |*d| d.deinit(gpa); + gpa.free(self.diagnostics); + } +}; + +/// `parseStmtBlock` appending into `arena`: every name is interned into +/// `arena.strings`, so a name the arena already holds keeps its id. On an +/// error or a diagnostic the nodes already appended stay in the arena, +/// unreferenced. +pub fn parseStmtBlockInto(gpa: std.mem.Allocator, arena: *AstArena, source: []const u8) !StmtRunResult { + var lexer = Lexer.init(source); + defer lexer.deinit(gpa); const c0 = try lexer.next(gpa); const c1 = try lexer.next(gpa); @@ -197,7 +224,7 @@ pub fn parseStmtBlock(gpa: std.mem.Allocator, source: []const u8) !StmtBlockResu .gpa = gpa, .source = source, .lexer = &lexer, - .arena = &arena, + .arena = arena, .current = c0, .next_tok = c1, .next2_tok = c2, @@ -209,14 +236,10 @@ pub fn parseStmtBlock(gpa: std.mem.Allocator, source: []const u8) !StmtBlockResu defer parser.active_labels.deinit(gpa); const body = try parser.parseStmtFragment(); - - const diags = try parser.diagnostics.toOwnedSlice(gpa); - lexer.deinit(gpa); return .{ - .ast = arena, - .body_start = body.start, - .body_len = body.len, - .diagnostics = diags, + .start = body.start, + .len = body.len, + .diagnostics = try parser.diagnostics.toOwnedSlice(gpa), }; } diff --git a/tests/etch/hot_reload_test.zig b/tests/etch/hot_reload_test.zig index 44e99e9c..c61f2f43 100644 --- a/tests/etch/hot_reload_test.zig +++ b/tests/etch/hot_reload_test.zig @@ -1,8 +1,8 @@ //! Interpreter hot-reload — edit a rule body → AST swap → behaviour change, //! measured under 500 ms. //! -//! There is no in-place AST swap: the Interpreter borrows `*const AstArena` -//! and derives its compiled tables eagerly, so a reload re-parses the edited +//! There is no in-place AST swap: the Interpreter compiles its own copy of the +//! AST and derives its compiled tables eagerly, so a reload re-parses the edited //! source into a fresh AST and re-runs `Interpreter.compile` on the SAME //! `World`. Live world state (entities, component bytes) survives because the //! world is external to the interpreter and `compile` is idempotent w.r.t. @@ -149,6 +149,25 @@ const src_no_counter = \\} ; +test "an interpreter outlives the parse result it was compiled from" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try weld_etch.parseSource(gpa, src_a); + var pr_live = true; + defer if (pr_live) pr.deinit(gpa); + try typeCheckClean(gpa, &pr.ast); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + pr.deinit(gpa); + pr_live = false; + + const cid = world.registry.idOf("Counter").?; + _ = try world.spawnDynamic(gpa, &[_]ComponentId{cid}); + _ = try interp.runFor(&world, 3); + try std.testing.expectEqual(@as(i64, 3), readCounter(&world)); +} + /// Compile `src` on `world`, returning the error rather than the interpreter. fn reloadOn(gpa: std.mem.Allocator, world: *World, src: []const u8) !void { var pr = try weld_etch.parseSource(gpa, src); @@ -705,7 +724,8 @@ test "a reload with two refusals reports the first declaration's: the widened on } /// Allocations `compile` makes for `src` onto a world already running `base`, or -/// onto a fresh world when `base` is null. +/// onto a fresh world when `base` is null, less those of the interpreter's copy +/// of the arena, which follow the source text. fn compileAllocations(gpa: std.mem.Allocator, base: ?[]const u8, src: []const u8) !u64 { var base_pr = if (base) |b| try weld_etch.parseSource(gpa, b) else null; defer if (base_pr) |*p| p.deinit(gpa); @@ -722,7 +742,10 @@ fn compileAllocations(gpa: std.mem.Allocator, base: ?[]const u8, src: []const u8 var it = try Interpreter.compile(counting.allocator(), &pr.ast, &world); const n = counting.snapshot().alloc_count; it.deinit(); - return n; + var copying = CountingAllocator.init(gpa); + var copy = try pr.ast.clone(copying.allocator()); + copy.deinit(copying.allocator()); + return n - copying.snapshot().alloc_count; } test "a reload allocates nothing for the defaults of a type already registered" { diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index e7d3fa83..4fe1784f 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2670, - else => 2672, + .windows => 2678, + else => 2680, }; } From 8ae99d4c9dcb7e1b764ff76a66b22acf6b2184dd Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 26 Sep 2026 10:42:33 +0200 Subject: [PATCH 078/141] fix(scene): serialize an extension's requires, format 2 to 3 A .prefab.bin did not carry its requires clause, so nothing at load knew what an extension needs from the entity. The extensions region gains a fourth table after the hooks, requires_count then that many string-table refs, and the format goes to version 3. Activation refuses, at step 1 and before any mutation, an entity lacking a required component. The scene cook refuses the same case when it can resolve the extension, counting components from the base and from the extensions listed earlier, the order the loader activates them in: a scene would otherwise cook and fail to load. The validator reads the new table, refuses more than one hook set, which the format states and the loader relied on, and requires the region to end exactly at the cross-references, where trailing bytes passed. Found alongside: the cook opened resolver-supplied bytes for its getters without structure validation at three sites, the of variant, the requires check and the instance path. One openResolvedPrefab now does all three checks; measured before, a malformed base was silently accepted. Fourteen witnesses, nine red first; the fourteenth, on a required name's string ref, closes a gap a counter-factual showed silent. Floor 2680 -> 2694 / 2678 -> 2692, from the suite. Co-Authored-By: Claude Opus 5.5 --- src/core/scene/accessor.zig | 20 ++++- src/core/scene/format.zig | 30 ++++--- src/core/scene/loader.zig | 19 +++- src/core/scene/validate.zig | 106 +++++++++++++++++++++- src/core/scene/writer.zig | 19 ++-- src/etch/scene_cook.zig | 32 +++++-- tests/scene/extensions_test.zig | 134 +++++++++++++++++++++++++--- tests/scene/prefab_cook_test.zig | 13 +++ tests/scene/prefab_flatten_test.zig | 18 ++++ tools/weld_lint/dead_tests.zig | 4 +- 10 files changed, 344 insertions(+), 51 deletions(-) diff --git a/src/core/scene/accessor.zig b/src/core/scene/accessor.zig index 3174aed2..8e8397de 100644 --- a/src/core/scene/accessor.zig +++ b/src/core/scene/accessor.zig @@ -154,12 +154,14 @@ pub const Accessor = struct { // ── Entity Extensions region (SHAPE A) ── // - // `@ extensions_offset`, three self-delimiting sub-tables in order: + // `@ extensions_offset`, four self-delimiting sub-tables in order, the last + // ending at `crossrefs_offset`: // Entity Extensions Table — `ext_count:u32` then per entity // `{ uuid_ordinal:u32, extension_count:u32, extension_ids:[…]u32 }` // Prefab ID Table — `prefab_id_count:u32` then `[…]u32` string-table offsets // Hooks — `hook_count:u32` then `[…]{ on_attach_ref:u32, on_detach_ref:u32 }` // (string-table offsets; 0 = absent). `hook_count ∈ {0,1}`. + // Requires — `requires_count:u32` then `[…]u32` string-table offsets. /// A view over one Entity Extensions Table entry. pub const ExtEntry = struct { @@ -240,10 +242,24 @@ pub const Accessor = struct { }; } + /// File offset of the requires table's `requires_count` (past the hooks). + fn requiresStart(self: Accessor) usize { + return self.hooksStart() + 4 + @as(usize, self.hookCount()) * 8; + } + + pub fn requiresCount(self: Accessor) u32 { + return self.readU32(self.requiresStart()); + } + + /// The `i`-th component name an `extends` prefab requires, in source order. + pub fn requiredName(self: Accessor, i: u32) []const u8 { + return self.stringAt(self.readU32(self.requiresStart() + 4 + @as(usize, i) * 4)); + } + // ── Cross-references Table ── /// Number of entity→entity cross-reference entries (`0` for a scene with no - /// `Entity` field references, and for every v1 file). + /// `Entity` field references). pub fn crossrefsCount(self: Accessor) u32 { return self.readU32(self.header.crossrefs_offset); } diff --git a/src/core/scene/format.zig b/src/core/scene/format.zig index 7fe6e300..eb86e3a0 100644 --- a/src/core/scene/format.zig +++ b/src/core/scene/format.zig @@ -58,17 +58,12 @@ pub const FieldKind = registry_mod.FieldKind; /// `src/modules/asset_pipeline/format/runtime_bin.zig`). pub const magic = [4]u8{ 'W', 'S', 'C', 'N' }; -/// `.scene.bin` binary format version (the codec/layout version — bumped on any -/// breaking layout change). Distinct from `content_version` (the authored -/// scene's `version:` field, opaque to the codec). -/// -/// **2**: the reserved sections became real — the cross-references table -/// and the `extensions_offset` region (Entity Extensions Table + Prefab ID Table -/// + hooks) went from bare count-placeholders (`[0]`) to full structures. A break -/// vs v1: a v1 file fails `BadVersion` and must be re-cooked -/// (`.scene.bin`/`.prefab.bin` are deterministic build artifacts, no prod files -/// anywhere). -pub const format_version: u16 = 2; +/// `.scene.bin` / `.prefab.bin` binary format version (the codec/layout version — +/// bumped on any breaking layout change). Distinct from `content_version` (the +/// authored scene's `version:` field, opaque to the codec). A file of another +/// version fails `BadVersion` and is re-cooked: both are deterministic build +/// artifacts. +pub const format_version: u16 = 3; /// `SceneHeader` size — the fixed 64-byte (cache-line) prefix every file opens /// with. All section offsets in the header are relative to the file start. @@ -369,6 +364,10 @@ pub const CookModel = struct { /// `extends` prefab hooks — `hook_count ∈ {0,1}`. Empty for a /// scene and for `of`/standalone prefabs. Serialized to the hooks sub-section. hooks: []const HookSet = &.{}, + /// An `extends` prefab's `requires` names, as `CookModel.strings` indices in + /// source order. Empty for a scene and for `of`/standalone prefabs. + /// Serialized to the requires table, the extensions region's last. + requires: []const u32 = &.{}, /// The authored scene's `version:` field (0 if absent). Propagated to /// `SceneHeader.content_version` — opaque to the codec, for the game's own /// scene-versioning/migration. @@ -400,7 +399,7 @@ test "CookModel arena round-trips an empty model" { test "format magic + version constants are stable" { try std.testing.expectEqualSlices(u8, "WSCN", &magic); - try std.testing.expectEqual(@as(u16, 2), format_version); + try std.testing.expectEqual(@as(u16, 3), format_version); } test "SceneHeader writeTo/read round-trips little-endian" { @@ -433,6 +432,13 @@ test "SceneHeader.read rejects bad magic, short input, bad version" { try std.testing.expectError(error.BadVersion, SceneHeader.read(&buf)); } +test "SceneHeader.read refuses a file of the previous format version" { + var buf: [header_size]u8 = undefined; + (SceneHeader{}).writeTo(&buf); + std.mem.writeInt(u16, buf[4..6], 2, .little); + try std.testing.expectError(error.BadVersion, SceneHeader.read(&buf)); +} + test "columnOffset aligns each column to its component alignment" { // Two columns: sz=8/al=8 then sz=1/al=1, 4 entities. region starts at 0. const sizes = [_]u16{ 8, 1 }; diff --git a/src/core/scene/loader.zig b/src/core/scene/loader.zig index f3887e67..12f7d4c7 100644 --- a/src/core/scene/loader.zig +++ b/src/core/scene/loader.zig @@ -553,8 +553,9 @@ fn extEntityArchetype(ext: Accessor) !Accessor.Archetype { /// (`error.ExtensionAlreadyActive` — closes the hook-only /// re-activation gap); resolve the strict mono-entity archetype /// (`extEntityArchetype`: `total == 0`/`> 1` rejected). -/// 1. Prevalidate with ZERO mutation: resolve every `ComponentId`; size-check; -/// conflict-check each against the entity. +/// 1. Prevalidate with ZERO mutation: resolve every `ComponentId`; check its +/// size, alignment and kind; conflict-check each against the entity; check +/// the entity carries every component the extension requires. /// 2. Reserve the extension-record capacity (fallible, no observable mutation). /// 3. Grouped add — the SINGLE fallible component mutation, itself atomic /// (`world.addComponentsDynamic`): at most ONE archetype migration, never @@ -601,6 +602,7 @@ pub fn activateExtension(world: *World, gpa: std.mem.Allocator, entity: EntityId cids[c] = cid; values[c] = arch.componentSlot(c, 0); } + if (!requiresMet(world, entity, ext)) return error.RequiresNotSatisfied; // Step 2 — reserve the extension-record capacity (fallible, no observable // mutation). `owned` is freed if we abort before committing it. @@ -620,13 +622,24 @@ pub fn activateExtension(world: *World, gpa: std.mem.Allocator, entity: EntityId try world.dispatchOnAttach(entity, name, on_attach_text); } +/// Whether `entity` carries every component the extension `ext` requires. +fn requiresMet(world: *World, entity: EntityId, ext: Accessor) bool { + var ri: u32 = 0; + while (ri < ext.requiresCount()) : (ri += 1) { + const cid = world.componentId(ext.requiredName(ri)) orelse return false; + if (world.componentBytes(entity, cid) == null) return false; + } + return true; +} + /// Runtime extension activation entry, reached from Etch /// `entity.activate_extension("X")` (the interpreter resolves the name through /// the bridge's `ExtensionResolver`). Reuses the shared `activateExtension` /// path (atomic prevalidate → reserve → grouped add → record → `on_attach`). /// Unknown name → `error.UnknownExtension`; a component the entity already /// carries → `error.ExtensionComponentConflict` (the normative additive-conflict -/// reject policy — see `engine-scene-serialization.md`). +/// reject policy — see `engine-scene-serialization.md`); a required component +/// the entity lacks → `error.RequiresNotSatisfied`. pub fn runtimeActivate(world: *World, gpa: std.mem.Allocator, entity: EntityId, name: []const u8, resolver: ExtensionResolver) !void { const bytes = resolver.resolve(name) orelse return error.UnknownExtension; try activateExtension(world, gpa, entity, name, bytes); diff --git a/src/core/scene/validate.zig b/src/core/scene/validate.zig index 40e29479..be7e5b21 100644 --- a/src/core/scene/validate.zig +++ b/src/core/scene/validate.zig @@ -295,9 +295,10 @@ const Validator = struct { } } - /// Walk the Entity Extensions Table + Prefab ID Table + hooks within - /// `[ex, cr)`, validating every span and every string ref (prefab names, - /// hook text). Returns `prefab_id_count` for the per-entity id check. + /// Walk the Entity Extensions Table + Prefab ID Table + hooks + requires, + /// which fill `[ex, cr)` exactly, validating every span and every string ref + /// (prefab names, hook text, required names). Returns `prefab_id_count` for + /// the per-entity id check. fn validateExtensionsRegion(self: *const Validator, ex: usize, cr: usize) StructureError!u32 { // Entity Extensions Table. if (try add(ex, 4) > cr) return error.MalformedScene; @@ -322,9 +323,10 @@ const Validator = struct { try self.checkStringRef(try readU32(self.bytes, try add(pids_off, try mul(p, 4)))); } } - // Hooks (`hook_count ∈ {0,1}` today; refs are string-table offsets, 0 = absent). + // Hooks (`hook_count ∈ {0,1}`; refs are string-table offsets, 0 = absent). if (try add(pids_end, 4) > cr) return error.MalformedScene; const hook_count = try readU32(self.bytes, pids_end); + if (hook_count > 1) return error.MalformedScene; const hooks_off = try add(pids_end, 4); const hooks_end = try add(hooks_off, try mul(hook_count, 8)); if (hooks_end > cr) return error.MalformedScene; @@ -338,6 +340,17 @@ const Validator = struct { if (d_ref != 0) try self.checkStringRef(d_ref); } } + // Requires, the region's last table. + if (try add(hooks_end, 4) > cr) return error.MalformedScene; + const req_count = try readU32(self.bytes, hooks_end); + const req_off = try add(hooks_end, 4); + if (try add(req_off, try mul(req_count, 4)) != cr) return error.MalformedScene; + { + var r: usize = 0; + while (r < req_count) : (r += 1) { + try self.checkStringRef(try readU32(self.bytes, try add(req_off, try mul(r, 4)))); + } + } return pid_count; } @@ -555,6 +568,8 @@ fn walkAll(acc: accessor_mod.Accessor) void { if (h.on_attach) |v| touch(v); if (h.on_detach) |v| touch(v); } + var qi: u32 = 0; + while (qi < acc.requiresCount()) : (qi += 1) touch(acc.requiredName(qi)); // Cross-references. var xi: u32 = 0; while (xi < acc.crossrefsCount()) : (xi += 1) { @@ -813,3 +828,86 @@ test "validator rejects non-ascending / duplicate schema indices in an archetype refixHash(buf); try testing.expectError(error.MalformedScene, openAndValidate(buf)); } + +/// One archetype `[Pos]`, one entity, `hook_sets` hook sets whose texts are +/// model strings 1 and 2, and model string 3 required `requires` times. +fn buildHookedScene(gpa: std.mem.Allocator, reg: *Registry, hook_sets: usize, requires: usize) ![]u8 { + const pos = try registerPod(gpa, reg, "Pos", 8, 4); + var arena = std.heap.ArenaAllocator.init(gpa); + const a = arena.allocator(); + const strings = try a.dupe([]const u8, &.{ try a.dupe(u8, "E0"), try a.dupe(u8, "attach"), try a.dupe(u8, "detach"), try a.dupe(u8, "Health") }); + const uuids = try a.dupe([16]u8, &.{[_]u8{7} ** 16}); + const col = try a.alloc(u8, 8); + @memset(col, 0); + const cols = try a.dupe([]u8, &.{col}); + const ids = try a.dupe(format.ComponentId, &.{pos}); + const ents = try a.dupe(format.EntityEntry, &.{.{ .name = 0, .uuid = 0, .parent_uuid = format.no_parent }}); + const blocks = try a.dupe(format.ArchetypeBlock, &.{.{ .component_ids = ids, .entity_count = 1, .columns = cols, .entities = ents }}); + const hooks = try a.alloc(format.HookSet, hook_sets); + for (hooks) |*h| h.* = .{ .on_attach = 1, .on_detach = 2 }; + const req = try a.alloc(u32, requires); + @memset(req, 3); + var model: format.CookModel = .{ .strings = strings, .uuids = uuids, .resources = &.{}, .archetypes = blocks, .hooks = hooks, .requires = req, .arena = arena }; + defer model.deinit(); + return writer.write(gpa, model, reg); +} + +test "validator accepts one hook set" { + const gpa = testing.allocator; + var reg = Registry.init(); + defer reg.deinit(gpa); + const bytes = try buildHookedScene(gpa, ®, 1, 0); + defer gpa.free(bytes); + try openAndValidate(bytes); +} + +test "validator refuses more than one hook set" { + const gpa = testing.allocator; + var reg = Registry.init(); + defer reg.deinit(gpa); + const bytes = try buildHookedScene(gpa, ®, 2, 0); + defer gpa.free(bytes); + try testing.expectError(error.MalformedScene, openAndValidate(bytes)); +} + +test "validator refuses bytes between the extensions region and the cross-references" { + const gpa = testing.allocator; + var reg = Registry.init(); + defer reg.deinit(gpa); + const base = try buildRichScene(gpa, ®); + defer gpa.free(base); + const cr = (try accessor_mod.Accessor.open(base)).header.crossrefs_offset; + const buf = try gpa.alloc(u8, base.len + 4); + defer gpa.free(buf); + @memcpy(buf[0..cr], base[0..cr]); + @memset(buf[cr..][0..4], 0); + @memcpy(buf[cr + 4 ..], base[cr..]); + std.mem.writeInt(u32, buf[48..52], cr + 4, .little); + refixHash(buf); + try testing.expectError(error.MalformedScene, openAndValidate(buf)); +} + +test "validator refuses a requires count past the extensions region" { + const gpa = testing.allocator; + var reg = Registry.init(); + defer reg.deinit(gpa); + const bytes = try buildHookedScene(gpa, ®, 1, 0); + defer gpa.free(bytes); + const cr = (try accessor_mod.Accessor.open(bytes)).header.crossrefs_offset; + std.mem.writeInt(u32, bytes[cr - 4 ..][0..4], 5, .little); // requires_count, the region's last word + refixHash(bytes); + try testing.expectError(error.MalformedScene, openAndValidate(bytes)); +} + +test "validator refuses a required name past the string table" { + const gpa = testing.allocator; + var reg = Registry.init(); + defer reg.deinit(gpa); + const bytes = try buildHookedScene(gpa, ®, 1, 1); + defer gpa.free(bytes); + try openAndValidate(bytes); + const cr = (try accessor_mod.Accessor.open(bytes)).header.crossrefs_offset; + std.mem.writeInt(u32, bytes[cr - 4 ..][0..4], 0xFFFFFFF0, .little); // the one required name's ref + refixHash(bytes); + try testing.expectError(error.MalformedScene, openAndValidate(bytes)); +} diff --git a/src/core/scene/writer.zig b/src/core/scene/writer.zig index 70859982..6c38e0e5 100644 --- a/src/core/scene/writer.zig +++ b/src/core/scene/writer.zig @@ -10,7 +10,7 @@ //! [Schema Registry] §10 — one SchemaEntry per distinct type //! [Resources Block] per resource: schema-index + data + string refs //! [Archetype Blocks] per archetype: schema mask + entity meta + SoA columns -//! [Entity Extensions Table] +//! [Extensions region] extensions, prefab ids, hooks, requires //! [Cross-references Table] //! ``` //! `hash` covers everything after the header. Component identity on disk is the @@ -229,16 +229,13 @@ const Writer = struct { } } - /// Cross-references Table @ `crossrefs_offset`: `count: u32` then `count` - /// `CrossRefEntry` (16 B). The model carries `component_id`; here it is - /// converted to the file-local Schema Registry index (`id_to_index`) — the - /// on-disk entry never stores a runtime `ComponentId`. - /// Entity Extensions region @ `extensions_offset` (SHAPE A) — three + /// Entity Extensions region @ `extensions_offset` (SHAPE A) — four /// self-delimiting sub-tables: the Entity Extensions Table (per-entity active /// extensions), the Prefab ID Table (dedup'd extension names → string-table - /// offsets), and the hooks (`extends` prefab `on_attach`/`on_detach` text refs; + /// offsets), the hooks (`extends` prefab `on_attach`/`on_detach` text refs; /// `0` = absent — safe because a prefab's entity name is interned before its - /// hooks, so no hook text lands at string-table offset 0). + /// hooks, so no hook text lands at string-table offset 0), and the requires + /// (the `extends` prefab's required component names, string-table offsets). fn writeExtensionsRegion(self: *Writer) WriteError!void { // Entity Extensions Table. try self.appendU32(try u32From(self.model.ext_entries.len)); @@ -256,8 +253,14 @@ const Writer = struct { try self.appendU32(if (h.on_attach) |idx| self.model_str_ref[idx] else 0); try self.appendU32(if (h.on_detach) |idx| self.model_str_ref[idx] else 0); } + try self.appendU32(try u32From(self.model.requires.len)); + for (self.model.requires) |str_idx| try self.appendU32(self.model_str_ref[str_idx]); } + /// Cross-references Table @ `crossrefs_offset`: `count: u32` then `count` + /// `CrossRefEntry` (16 B). The model carries `component_id`; here it is + /// converted to the file-local Schema Registry index (`id_to_index`) — the + /// on-disk entry never stores a runtime `ComponentId`. fn writeCrossRefs(self: *Writer) WriteError!void { try self.appendU32(try u32From(self.model.cross_refs.len)); for (self.model.cross_refs) |cr| { diff --git a/src/etch/scene_cook.zig b/src/etch/scene_cook.zig index 1195f310..45ff12a7 100644 --- a/src/etch/scene_cook.zig +++ b/src/etch/scene_cook.zig @@ -124,7 +124,8 @@ pub const CookError = error{ /// `prefab "Y" of "X"` but the base `X.prefab.bin` could not be resolved /// (no resolver, or the resolver returned null for the base name). BasePrefabMissing, - /// The resolved base `.prefab.bin` bytes failed `accessor.open`/`verifyHash`. + /// The resolved base `.prefab.bin` bytes failed `accessor.open`, `verifyHash` + /// or structure validation. BasePrefabCorrupt, /// A base prefab component's name is unknown to the variant's registry, or /// its on-disk size disagrees with the variant registry's layout. @@ -222,6 +223,16 @@ pub fn cookScene( return .{ .model = model, .registry = registry }; } +/// Open resolver-supplied `.prefab.bin` bytes for the accessor's getters: +/// magic and version, content hash, then structure (`accessor.zig`'s trust +/// contract). +fn openResolvedPrefab(bytes: []const u8, diag_out: ?*[]const u8) CookError!accessor.Accessor { + const acc = accessor.Accessor.open(bytes) catch return fail(diag_out, error.BasePrefabCorrupt, "a resolved .prefab.bin failed to open (magic/version)"); + if (!acc.verifyHash()) return fail(diag_out, error.BasePrefabCorrupt, "a resolved .prefab.bin fails its content hash"); + validate.structure(acc.bytes, acc.header) catch return fail(diag_out, error.BasePrefabCorrupt, "a resolved .prefab.bin is structurally invalid"); + return acc; +} + fn fail(diag_out: ?*[]const u8, err: CookError, msg: []const u8) CookError { if (diag_out) |d| d.* = msg; return err; @@ -662,6 +673,13 @@ const Builder = struct { // invalid) would otherwise panic on `schemaCount`/`schema` reads. validate.structure(acc.bytes, acc.header) catch continue; + // A required component comes from the base or an extension listed + // earlier, the order in which the loader activates them. + var ri: u32 = 0; + while (ri < acc.requiresCount()) : (ri += 1) { + if (!counts.contains(acc.requiredName(ri))) return fail(diag_out, error.RequiresNotSatisfied, "an entity activates an extension requiring a component that neither the entity nor an extension listed before it carries"); + } + // Each component the extension declares (its `.prefab.bin` schema table; // names unique per prefab) bumps that name's distinct-declarant count. // Reaching 2 is the conflict — form (a) if the prior declarant was another @@ -747,8 +765,7 @@ const Builder = struct { const base_name = self.ast.strings.slice(pd.relation_target); const resolver = base_resolver orelse return fail(diag_out, error.BasePrefabMissing, "`of` variant cooked without a base-prefab resolver"); const base_bytes = resolver.resolve(base_name) orelse return fail(diag_out, error.BasePrefabMissing, "`of` variant references a base prefab the resolver does not know"); - var acc = accessor.Accessor.open(base_bytes) catch return fail(diag_out, error.BasePrefabCorrupt, "base prefab .prefab.bin failed to open (magic/version)"); - if (!acc.verifyHash()) return fail(diag_out, error.BasePrefabCorrupt, "base prefab .prefab.bin content hash mismatch"); + const acc = try openResolvedPrefab(base_bytes, diag_out); try self.reconstructBase(acc, &entities, diag_out); try self.mergeVariantEntities(prefab_entities, &entities, diag_out); } else { @@ -770,6 +787,8 @@ const Builder = struct { const archetypes = try self.groupArchetypes(entities.items); const content_version = try self.versionFromNode(pd.version, diag_out); const hooks = if (pd.relation == .extends) try self.buildExtendsHooks(pd, base_resolver, diag_out) else &[_]format.HookSet{}; + const requires = try self.a().alloc(u32, pd.requires_len); + for (requires, 0..) |*r, ri| r.* = try self.internString(self.ast.strings.slice(self.ast.prefab_requires.items[pd.requires_start + ri])); return .{ .strings = try self.a().dupe([]const u8, self.strings.items), @@ -778,6 +797,7 @@ const Builder = struct { .archetypes = archetypes, .content_version = content_version, .hooks = hooks, + .requires = requires, .arena = self.arena, }; } @@ -816,8 +836,7 @@ const Builder = struct { const base_name = self.ast.strings.slice(pd.relation_target); const resolver = base_resolver orelse return fail(diag_out, error.BasePrefabMissing, "`extends … requires` needs a base-prefab resolver to validate against X"); const base_bytes = resolver.resolve(base_name) orelse return fail(diag_out, error.BasePrefabMissing, "`extends` references a base prefab the resolver does not know"); - var acc = accessor.Accessor.open(base_bytes) catch return fail(diag_out, error.BasePrefabCorrupt, "base prefab .prefab.bin failed to open (magic/version)"); - if (!acc.verifyHash()) return fail(diag_out, error.BasePrefabCorrupt, "base prefab .prefab.bin content hash mismatch"); + const acc = try openResolvedPrefab(base_bytes, diag_out); var ri: u32 = 0; while (ri < pd.requires_len) : (ri += 1) { const req = self.ast.strings.slice(self.ast.prefab_requires.items[pd.requires_start + ri]); @@ -1037,8 +1056,7 @@ const Builder = struct { const prefab_name = self.ast.strings.slice(inst.prefab_name); const resolver = base_resolver orelse return fail(diag_out, error.BasePrefabMissing, "scene `instance of` cooked without a prefab resolver (no --prefab-dir?)"); const base_bytes = resolver.resolve(prefab_name) orelse return fail(diag_out, error.BasePrefabMissing, "`instance of` references a prefab the resolver does not know"); - var acc = accessor.Accessor.open(base_bytes) catch return fail(diag_out, error.BasePrefabCorrupt, "instanced prefab .prefab.bin failed to open (magic/version)"); - if (!acc.verifyHash()) return fail(diag_out, error.BasePrefabCorrupt, "instanced prefab .prefab.bin content hash mismatch"); + const acc = try openResolvedPrefab(base_bytes, diag_out); // Identity next — cross-ref pendings recorded while applying the body need // the source entity's uuid ordinal (the instance's, not the prefab's). diff --git a/tests/scene/extensions_test.zig b/tests/scene/extensions_test.zig index eb2bd85b..7efa9204 100644 --- a/tests/scene/extensions_test.zig +++ b/tests/scene/extensions_test.zig @@ -185,7 +185,7 @@ test "extends prefab cooks with components, hooks and requires" { var acc = try Accessor.open(bytes); try std.testing.expect(acc.verifyHash()); - try std.testing.expectEqual(@as(u16, 2), acc.header.version); // format v2 + try std.testing.expectEqual(@as(u16, 3), acc.header.version); // The added component (Weapon) is in an archetype. try std.testing.expectEqual(@as(u32, 1), acc.archetypeCount()); @@ -705,18 +705,6 @@ fn cookCombatModule(gpa: std.mem.Allocator) ![]const u8 { return scene.writer.write(gpa, cooked.model, &cooked.registry); } -/// Compile + bind an interpreter declaring `Health` + `Weapon` (WITH fields, so a -/// hook's `Health.max` resolves) into `world`, registering the real on_attach / -/// on_detach execution seam. The caller owns `pr` (parse result) and `interp`. -const HookEnv = struct { - pr: parser.ParseResult, - interp: Interpreter, - fn deinit(self: *HookEnv, gpa: std.mem.Allocator) void { - self.interp.deinit(); - self.pr.deinit(gpa); - } -}; - fn spawnHealth(world: *World, gpa: std.mem.Allocator, current: i32, max: i32) !EntityId { const cid = world.componentId("Health").?; var hv = [_]i32{ current, max }; @@ -1152,3 +1140,123 @@ test "an extension whose hook fails the checker does not cook" { return error.TestUnexpectedResult; } else |err| try std.testing.expectEqual(error.HookRefused, err); } + +const ext_healthy = // Healthy: declares Health + \\component Health { current: i32 = 100, max: i32 = 100 } + \\prefab "Healthy" extends "Base" { + \\ entity "m" { uuid: "00000000-0000-0000-0000-0000000000c5" Health { current: 10, max: 10 } } + \\} +; + +/// Cook a one-entity scene carrying `Marker` and activating `extensions`, the +/// extension bytes resolved from `ext_healthy` and `cookCombatModule`. +fn cookMarkerScene(gpa: std.mem.Allocator, extensions: []const u8) !scene_cook.Cooked { + const healthy = try prefabBytes(gpa, ext_healthy); + defer gpa.free(healthy); + const combat = try cookCombatModule(gpa); + defer gpa.free(combat); + var mr = MultiResolver{ .names = &.{ "Healthy", "CombatModule" }, .blobs = &.{ healthy, combat } }; + const src = try std.fmt.allocPrint(gpa, + \\component Marker {{ v: i32 = 0 }} + \\scene "S" {{ + \\ entity "npc" {{ + \\ uuid: "00000000-0000-0000-0000-0000000000f1" + \\ extensions: {s} + \\ Marker {{ v: 1 }} + \\ }} + \\}} + , .{extensions}); + defer gpa.free(src); + return scene_cook.cookScene(gpa, src, mr.base(), null); +} + +test "a cooked extends prefab carries its requires in source order" { + const gpa = std.testing.allocator; + const base_src = + \\component Health { current: i32 = 100, max: i32 = 100 } + \\component Mana { v: i32 = 0 } + \\prefab "Caster" { + \\ entity "root" { uuid: "7b3e2f1a-42a3-4f2b-8c9d-a3f2b1c98d4f" Health { current: 1, max: 1 } Mana { v: 1 } } + \\} + ; + const base_bytes = try prefabBytes(gpa, base_src); + defer gpa.free(base_bytes); + var res = OneResolver{ .name = "Caster", .bytes = base_bytes }; + const src = + \\component Health { current: i32 = 100, max: i32 = 100 } + \\component Mana { v: i32 = 0 } + \\component Weapon { damage: i32 = 10 } + \\prefab "Spells" extends "Caster" requires Mana, Health { + \\ entity "mod" { uuid: "9c4f3a2b-1e7d-4a5c-b8e9-f4d2c3a1b5e6" Weapon { damage: 25 } } + \\} + ; + var cooked = try scene_cook.cookPrefab(gpa, src, res.base(), null); + defer cooked.deinit(gpa); + const bytes = try scene.writer.write(gpa, cooked.model, &cooked.registry); + defer gpa.free(bytes); + const acc = try Accessor.open(bytes); + try std.testing.expectEqual(@as(u32, 2), acc.requiresCount()); + try std.testing.expectEqualStrings("Mana", acc.requiredName(0)); + try std.testing.expectEqualStrings("Health", acc.requiredName(1)); +} + +test "activation refuses an entity missing a required component" { + const gpa = std.testing.allocator; + const combat_bytes = try cookCombatModule(gpa); + defer gpa.free(combat_bytes); + var world = World.init(); + defer world.deinit(gpa); + _ = try world.registry.registerComponentRaw(gpa, .{ .name = "Health", .size = 8, .alignment = 4, .default_bytes = &[_]u8{0} ** 8, .fields = &.{} }); + const weapon_id = try world.registry.registerComponentRaw(gpa, .{ .name = "Weapon", .size = 4, .alignment = 4, .default_bytes = &[_]u8{0} ** 4, .fields = &.{} }); + const marker_id = try world.registry.registerComponentRaw(gpa, .{ .name = "Marker", .size = 4, .alignment = 4, .default_bytes = &[_]u8{0} ** 4, .fields = &.{} }); + const eid = try world.spawnDynamic(gpa, &[_]ComponentId{marker_id}); + + var res = OneResolver{ .name = "CombatModule", .bytes = combat_bytes }; + try std.testing.expectError(error.RequiresNotSatisfied, scene.loader.runtimeActivate(&world, gpa, eid, "CombatModule", res.ext())); + try std.testing.expect(world.componentBytes(eid, weapon_id) == null); + try std.testing.expect(!world.hasEntityExtension(eid, "CombatModule")); +} + +test "a scene activating an extension whose requirement the entity lacks does not cook" { + const gpa = std.testing.allocator; + if (cookMarkerScene(gpa, "[\"CombatModule\"]")) |cooked| { + var c = cooked; + c.deinit(gpa); + return error.TestUnexpectedResult; + } else |err| try std.testing.expectEqual(error.RequiresNotSatisfied, err); +} + +test "a requirement an earlier extension provides is satisfied" { + const gpa = std.testing.allocator; + var cooked = try cookMarkerScene(gpa, "[\"Healthy\", \"CombatModule\"]"); + cooked.deinit(gpa); +} + +test "a requirement only a later extension provides does not cook" { + const gpa = std.testing.allocator; + if (cookMarkerScene(gpa, "[\"CombatModule\", \"Healthy\"]")) |cooked| { + var c = cooked; + c.deinit(gpa); + return error.TestUnexpectedResult; + } else |err| try std.testing.expectEqual(error.RequiresNotSatisfied, err); +} + +test "an extends requires over a malformed but rehashed base does not cook" { + const gpa = std.testing.allocator; + var base = try scene_cook.cookPrefab(gpa, base_character, null, null); + defer base.deinit(gpa); + const base_bytes = try scene.writer.write(gpa, base.model, &base.registry); + defer gpa.free(base_bytes); + const bad = try gpa.dupe(u8, base_bytes); + defer gpa.free(bad); + std.mem.writeInt(u32, bad[20..24], 0xFFFF, .little); // schema_count, outside the hashed bytes + var res = OneResolver{ .name = "BaseCharacter", .bytes = bad }; + const src = + \\component Health { current: i32 = 100, max: i32 = 100 } + \\component Weapon { damage: i32 = 10 } + \\prefab "CombatModule" extends "BaseCharacter" requires Health { + \\ entity "mod" { uuid: "9c4f3a2b-1e7d-4a5c-b8e9-f4d2c3a1b5e6" Weapon { damage: 25 } } + \\} + ; + try std.testing.expectError(error.BasePrefabCorrupt, scene_cook.cookPrefab(gpa, src, res.base(), null)); +} diff --git a/tests/scene/prefab_cook_test.zig b/tests/scene/prefab_cook_test.zig index 4643d425..79d4fddc 100644 --- a/tests/scene/prefab_cook_test.zig +++ b/tests/scene/prefab_cook_test.zig @@ -171,3 +171,16 @@ test "of variant without a resolver errors BasePrefabMissing" { var diag: []const u8 = ""; try std.testing.expectError(error.BasePrefabMissing, scene_cook.cookPrefab(gpa, variant_src, null, &diag)); } + +test "an of variant over a malformed but rehashed base does not cook" { + const gpa = std.testing.allocator; + var base = try scene_cook.cookPrefab(gpa, standalone_src, null, null); + defer base.deinit(gpa); + const base_bytes = try scene.writer.write(gpa, base.model, &base.registry); + defer gpa.free(base_bytes); + const bad = try gpa.dupe(u8, base_bytes); + defer gpa.free(bad); + std.mem.writeInt(u32, bad[20..24], 0xFFFF, .little); // schema_count, outside the hashed bytes + var resolver = OneResolver{ .name = "WallTorch", .bytes = bad }; + try std.testing.expectError(error.BasePrefabCorrupt, scene_cook.cookPrefab(gpa, variant_src, resolver.base(), null)); +} diff --git a/tests/scene/prefab_flatten_test.zig b/tests/scene/prefab_flatten_test.zig index 300eb92c..da648401 100644 --- a/tests/scene/prefab_flatten_test.zig +++ b/tests/scene/prefab_flatten_test.zig @@ -330,3 +330,21 @@ test "a scene whose instanced column alignment differs does not cook" { \\} , error.BaseSchemaMismatch); } + +test "an instance of a malformed but rehashed prefab does not cook" { + const gpa = std.testing.allocator; + var torch = try cookTorch(gpa); + defer torch.deinit(gpa); + const torch_bytes = try scene.writer.write(gpa, torch.model, &torch.registry); + defer gpa.free(torch_bytes); + const bad = try gpa.dupe(u8, torch_bytes); + defer gpa.free(bad); + std.mem.writeInt(u32, bad[20..24], 0xFFFF, .little); // schema_count, outside the hashed bytes + var resolver = OneResolver{ .name = "Torch", .bytes = bad }; + const src = scene_decls ++ + \\scene "S" { + \\ instance of "Torch" "I" { uuid: "00000000-0000-0000-0000-0000000000a1" } + \\} + ; + try std.testing.expectError(error.BasePrefabCorrupt, scene_cook.cookScene(gpa, src, resolver.base(), null)); +} diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 4fe1784f..d8470ec2 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2678, - else => 2680, + .windows => 2692, + else => 2694, }; } From 2d5217f164cd82047aa2675a737a72d4e112df86 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 26 Sep 2026 11:06:34 +0200 Subject: [PATCH 079/141] feat(scene): check extension hooks at load against the loaded program Decision 3, points 1 and 2. A World seam, ExtensionCheckFn, is registered by the interpreter at bindToWorld: it parses each hook text once into the interpreter's own arena and type-checks it there, against the program's declarations, gated on the extension's own components and its requires, in a hook's context. A refusal is ExtensionHookRefused. The loader fires it before any mutation: at activation step 1 on both hooks, at deactivation step 1 on on_detach, and, at load, in a pre-pass over every activation before the first hook runs, which also checks each requirement against the entity and the extensions listed before it there. The checker's hook body is shared by the source and the load ends, parameterised by its scope. Its literal range check now covers a hook's literals: the declaration passes reported them into a discarded list and marked them reported, so the cook accepted an out-of-range literal etch check refuses. The load end bounds it to the expressions the hook's parse appended, all hooks sharing one arena; the cook end to the body's bytes. Verdicts are cached per text and scope. Fourteen witnesses, nine red first. Floor 2694 -> 2707 / 2692 -> 2705, from the suite. Co-Authored-By: Claude Opus 5.5 --- src/core/ecs/world.zig | 46 +++++- src/core/scene/loader.zig | 79 ++++++++- src/etch/interp.zig | 77 ++++++++- src/etch/scene_cook.zig | 2 +- src/etch/types.zig | 119 +++++++++++-- tests/scene/extensions_test.zig | 284 ++++++++++++++++++++++++++++++++ tools/weld_lint/dead_tests.zig | 4 +- 7 files changed, 582 insertions(+), 29 deletions(-) diff --git a/src/core/ecs/world.zig b/src/core/ecs/world.zig index 0533bc12..2a7b6395 100644 --- a/src/core/ecs/world.zig +++ b/src/core/ecs/world.zig @@ -106,8 +106,8 @@ const EntityIdentityStore = entity_mod.EntityIdentityStore; /// pointer the Etch bridge registers; the scene loader fires it after adding an /// extension's components, passing the entity, the extension name, and the cooked /// `on_attach` Etch source text (`null` if absent). The -/// Etch bridge registers the callback, which re-parses + runs the -/// text — the seam itself still only fires whatever callback is registered. +/// Etch bridge registers the callback, which parses the text once and runs it +/// — the seam itself only fires whatever callback is registered. pub const ExtensionAttachFn = *const fn ( ctx: ?*anyopaque, world: *World, @@ -135,6 +135,29 @@ pub const ExtensionDetachFn = *const fn ( /// A registered `on_detach` callback + its opaque context. const DetachHook = struct { ctx: ?*anyopaque, func: ExtensionDetachFn }; +/// An extension's hook texts and the component names its hooks may reach: its +/// own components and its `requires`. +pub const ExtensionHooks = struct { + on_attach: ?[]const u8, + on_detach: ?[]const u8, + scope: []const []const u8, +}; + +/// The extension hook check seam. The Etch bridge registers a callback that +/// refuses a hook its type checker refuses against the loaded program; the +/// loader fires it before any mutation, at activation, at deactivation, and for +/// every activation of a load before the load runs a hook. +pub const ExtensionCheckFn = *const fn ( + ctx: ?*anyopaque, + world: *World, + entity: EntityId, + extension_name: []const u8, + hooks: ExtensionHooks, +) anyerror!void; + +/// A registered hook check + its opaque context. +const CheckHook = struct { ctx: ?*anyopaque, func: ExtensionCheckFn }; + /// Top-level ECS world — single archetype list, shared identity, shared /// registry, shared resources. pub const World = struct { @@ -215,6 +238,10 @@ pub const World = struct { /// removing an extension's components. `null` until registered (last wins). detach_hook: ?DetachHook = null, + /// The extension hook check seam. `null` until the Etch bridge registers it + /// (last wins); unregistered, no hook is checked. + check_hook: ?CheckHook = null, + /// Per-entity active-extension set: an entity → the OWNED copies of /// the names of the extensions currently active on it, in activation order. /// Populated by `addEntityExtension` inside the shared activate path (so load @@ -386,8 +413,8 @@ pub const World = struct { /// extension `extension_name`, passing the cooked `on_attach_text` (the Etch /// hook source; `null` if the extension has no `on_attach`). No-op if no hook /// is registered. The loader calls this after adding the extension's - /// components. The registered callback (the Etch bridge) re-parses + - /// executes the text; here the seam just fires it. + /// components. The registered callback (the Etch bridge) runs the text; here + /// the seam just fires it. pub fn dispatchOnAttach(self: *World, entity: EntityId, extension_name: []const u8, on_attach_text: ?[]const u8) anyerror!void { if (self.attach_hook) |h| try h.func(h.ctx, self, entity, extension_name, on_attach_text); } @@ -406,6 +433,17 @@ pub const World = struct { if (self.detach_hook) |h| try h.func(h.ctx, self, entity, extension_name, on_detach_text); } + /// Register the extension hook check (one per world, last registration wins). + pub fn registerExtensionCheck(self: *World, ctx: ?*anyopaque, callback: ExtensionCheckFn) void { + self.check_hook = .{ .ctx = ctx, .func = callback }; + } + + /// Fire the extension hook check for `entity`'s extension `extension_name`. + /// No-op if no check is registered. + pub fn dispatchExtensionCheck(self: *World, entity: EntityId, extension_name: []const u8, hooks: ExtensionHooks) anyerror!void { + if (self.check_hook) |h| try h.func(h.ctx, self, entity, extension_name, hooks); + } + /// Record `name` as an active extension on `entity` (storing an /// OWNED copy). Called inside the shared activate path after the extension's /// components are added. A name already present is not duplicated (the diff --git a/src/core/scene/loader.zig b/src/core/scene/loader.zig index 12f7d4c7..5417797e 100644 --- a/src/core/scene/loader.zig +++ b/src/core/scene/loader.zig @@ -502,12 +502,15 @@ fn resolveCrossRefs(world: *World, acc: Accessor, remap: []const ComponentId, uu /// active extensions** (so an extension-free scene needs no resolver). The /// `on_attach` hook EXECUTION runs inside the registered seam's callback /// (the Etch bridge); here `dispatchOnAttach` fires it with the cooked hook text. +/// Every activation is checked first (`preflightExtensions`), so no hook runs +/// when one would be refused. fn applyExtensions(world: *World, gpa: std.mem.Allocator, acc: Accessor, uuid_to_entity: UuidMap, ext_resolver: ?ExtensionResolver) !void { const count = acc.extensionsCount(); if (count == 0) return; const ucount = uuidCount(acc); const resolver = ext_resolver orelse return error.MissingExtensionResolver; const pid_count = acc.prefabIdCount(); + try preflightExtensions(world, gpa, acc, uuid_to_entity, resolver); var i: u32 = 0; while (i < count) : (i += 1) { @@ -525,6 +528,36 @@ fn applyExtensions(world: *World, gpa: std.mem.Allocator, acc: Accessor, uuid_to } } +/// Every activation of the Entity Extensions Table, checked before the first +/// runs: the extension resolves and opens, its requirements are met by the +/// entity or by an extension listed before it on that entity, and the world's +/// hook check accepts its hooks. +fn preflightExtensions(world: *World, gpa: std.mem.Allocator, acc: Accessor, uuid_to_entity: UuidMap, resolver: ExtensionResolver) !void { + const ucount = uuidCount(acc); + const pid_count = acc.prefabIdCount(); + var provided: std.ArrayListUnmanaged([]const u8) = .empty; + defer provided.deinit(gpa); + var i: u32 = 0; + while (i < acc.extensionsCount()) : (i += 1) { + const e = acc.extension(i); + if (e.uuid_ordinal >= ucount) return error.MalformedScene; + const entity = uuid_to_entity.get(acc.uuidAt(e.uuid_ordinal).*) orelse return error.MalformedScene; + provided.clearRetainingCapacity(); + var j: u32 = 0; + while (j < e.extension_count) : (j += 1) { + const pid = e.extensionId(j); + if (pid >= pid_count) return error.MalformedScene; + const name = acc.prefabName(pid); + const ext = try openVerified(resolver.resolve(name) orelse return error.UnknownExtension); + _ = try extEntityArchetype(ext); + if (!requiresMet(world, entity, ext, provided.items)) return error.RequiresNotSatisfied; + try checkHooks(world, gpa, entity, name, ext, true); + var s: u32 = 0; + while (s < ext.schemaCount()) : (s += 1) try provided.append(gpa, ext.schema(s).name); + } + } +} + /// The single archetype block of a mono-entity extension prefab. STRICT cardinality — /// `total == 0` → `error.EmptyExtension`, `total > 1` → /// `error.MultiEntityExtensionUnsupported`, else the one archetype whose @@ -555,7 +588,8 @@ fn extEntityArchetype(ext: Accessor) !Accessor.Archetype { /// (`extEntityArchetype`: `total == 0`/`> 1` rejected). /// 1. Prevalidate with ZERO mutation: resolve every `ComponentId`; check its /// size, alignment and kind; conflict-check each against the entity; check -/// the entity carries every component the extension requires. +/// the entity carries every component the extension requires; fire the +/// world's hook check on both hooks. /// 2. Reserve the extension-record capacity (fallible, no observable mutation). /// 3. Grouped add — the SINGLE fallible component mutation, itself atomic /// (`world.addComponentsDynamic`): at most ONE archetype migration, never @@ -602,7 +636,8 @@ pub fn activateExtension(world: *World, gpa: std.mem.Allocator, entity: EntityId cids[c] = cid; values[c] = arch.componentSlot(c, 0); } - if (!requiresMet(world, entity, ext)) return error.RequiresNotSatisfied; + if (!requiresMet(world, entity, ext, &.{})) return error.RequiresNotSatisfied; + try checkHooks(world, gpa, entity, name, ext, true); // Step 2 — reserve the extension-record capacity (fallible, no observable // mutation). `owned` is freed if we abort before committing it. @@ -622,16 +657,44 @@ pub fn activateExtension(world: *World, gpa: std.mem.Allocator, entity: EntityId try world.dispatchOnAttach(entity, name, on_attach_text); } -/// Whether `entity` carries every component the extension `ext` requires. -fn requiresMet(world: *World, entity: EntityId, ext: Accessor) bool { +/// Whether every component the extension `ext` requires is carried by `entity` +/// or named in `provided`. +fn requiresMet(world: *World, entity: EntityId, ext: Accessor, provided: []const []const u8) bool { var ri: u32 = 0; - while (ri < ext.requiresCount()) : (ri += 1) { - const cid = world.componentId(ext.requiredName(ri)) orelse return false; + next: while (ri < ext.requiresCount()) : (ri += 1) { + const name = ext.requiredName(ri); + for (provided) |p| if (std.mem.eql(u8, p, name)) continue :next; + const cid = world.componentId(name) orelse return false; if (world.componentBytes(entity, cid) == null) return false; } return true; } +/// Fire the world's hook check on the hooks of `ext` — its `on_attach` only +/// when `with_attach` — in the scope of its own components and its requires. +fn checkHooks(world: *World, gpa: std.mem.Allocator, entity: EntityId, name: []const u8, ext: Accessor, with_attach: bool) !void { + if (ext.hookCount() == 0) return; + const hook = ext.hook(0); + const scope = try gpa.alloc([]const u8, ext.schemaCount() + ext.requiresCount()); + defer gpa.free(scope); + var n: usize = 0; + var s: u32 = 0; + while (s < ext.schemaCount()) : (s += 1) { + scope[n] = ext.schema(s).name; + n += 1; + } + var r: u32 = 0; + while (r < ext.requiresCount()) : (r += 1) { + scope[n] = ext.requiredName(r); + n += 1; + } + try world.dispatchExtensionCheck(entity, name, .{ + .on_attach = if (with_attach) hook.on_attach else null, + .on_detach = hook.on_detach, + .scope = scope, + }); +} + /// Runtime extension activation entry, reached from Etch /// `entity.activate_extension("X")` (the interpreter resolves the name through /// the bridge's `ExtensionResolver`). Reuses the shared `activateExtension` @@ -652,7 +715,8 @@ pub fn runtimeActivate(world: *World, gpa: std.mem.Allocator, entity: EntityId, /// Prepare/commit order — the hook-ordering guarantee is REAL: /// 1. Prevalidate with ZERO mutation: extension active (`ExtensionNotActive`), /// bytes valid, strict mono-entity archetype, declared components resolvable; -/// collect the ones currently present. +/// collect the ones currently present; fire the world's hook check on +/// `on_detach`. /// 2. PREPARE the grouped remove — all the fallible work (target archetype, /// capacity, reserved dst slot), no observable mutation yet. /// 3. Fire `on_detach` FIRST (it still reads the present components). If it @@ -687,6 +751,7 @@ pub fn deactivateExtension(world: *World, gpa: std.mem.Allocator, entity: Entity n += 1; } } + try checkHooks(world, gpa, entity, name, ext, false); const on_detach_text: ?[]const u8 = if (ext.hookCount() > 0) ext.hook(0).on_detach else null; diff --git a/src/etch/interp.zig b/src/etch/interp.zig index cd4fa9a7..668a5f33 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -52,6 +52,7 @@ const DynamicArchetype = weld_core.ecs.archetype_dynamic.DynamicArchetype; const Chunk = weld_core.ecs.archetype_dynamic.Chunk; const World = weld_core.ecs.world.World; const DynamicQuery = weld_core.ecs.world.DynamicQuery; +const ExtensionHooks = weld_core.ecs.world.ExtensionHooks; const CoreEntityId = weld_core.ecs.entity.EntityId; const Tick = weld_core.ecs.tick.Tick; const initial_tick = weld_core.ecs.tick.initial_tick; @@ -184,8 +185,24 @@ const PendingTag = struct { /// direct-programmatic paths, which run outside any query iteration. const ExtOp = enum { activate, deactivate }; -/// A hook's statement run, `extra[start .. start + len]` of the interpreter's arena. -const HookRun = struct { start: u32, len: u32 }; +const HookRun = types_mod.TypeChecker.HookRun; + +/// `text` and each scope name, each behind its `u32` length: one key per +/// (text, scope) pair, whatever bytes they hold. +fn hookVerdictKey(gpa: std.mem.Allocator, text: []const u8, scope: []const []const u8) ![]u8 { + var len: usize = 4 + text.len; + for (scope) |name| len += 4 + name.len; + const key = try gpa.alloc(u8, len); + var at = putPart(key, 0, text); + for (scope) |name| at = putPart(key, at, name); + return key; +} + +fn putPart(key: []u8, at: usize, part: []const u8) usize { + std.mem.writeInt(u32, key[at..][0..4], @intCast(part.len), .little); + @memcpy(key[at + 4 ..][0..part.len], part); + return at + 4 + part.len; +} const PendingExtension = struct { entity: CoreEntityId, @@ -1079,6 +1096,9 @@ pub const Interpreter = struct { /// Each hook text run so far, parsed once into `owned_ast`: its statement /// run, or null when the text does not parse. The keys are owned. hook_runs: std.StringHashMapUnmanaged(?HookRun) = .empty, + /// Whether the type checker accepts a hook text in a scope: keyed by the + /// owned text and scope names (`hookVerdictKey`). + hook_verdicts: std.StringHashMapUnmanaged(bool) = .empty, bridge: Bridge, rule_descs: []RuleDesc, /// Top-level `fn` declarations keyed by name, for @@ -1393,6 +1413,9 @@ pub const Interpreter = struct { var hook_keys = self.hook_runs.keyIterator(); while (hook_keys.next()) |k| self.gpa.free(k.*); self.hook_runs.deinit(self.gpa); + var verdict_keys = self.hook_verdicts.keyIterator(); + while (verdict_keys.next()) |k| self.gpa.free(k.*); + self.hook_verdicts.deinit(self.gpa); self.owned_ast.deinit(self.gpa); self.gpa.destroy(self.owned_ast); self.* = undefined; @@ -1822,6 +1845,7 @@ pub const Interpreter = struct { // so a program with no observer rules still wires its hook execution. world.registerOnAttach(self, &extensionAttachTrampoline); world.registerOnDetach(self, &extensionDetachTrampoline); + world.registerExtensionCheck(self, &extensionCheckTrampoline); var n: usize = 0; for (self.rule_descs) |rd| { @@ -1956,10 +1980,19 @@ pub const Interpreter = struct { return err; }; var entry: ?HookRun = null; + const expr_from: u32 = @intCast(self.owned_ast.exprs.len); + const type_from: u32 = @intCast(self.owned_ast.type_nodes.len); if (parser_mod.parseStmtBlockInto(self.gpa, self.owned_ast, text)) |parsed| { var hook_run = parsed; defer hook_run.deinit(self.gpa); - if (hook_run.diagnostics.len == 0) entry = .{ .start = hook_run.start, .len = hook_run.len }; + if (hook_run.diagnostics.len == 0) entry = .{ + .start = hook_run.start, + .len = hook_run.len, + .expr_from = expr_from, + .expr_to = @intCast(self.owned_ast.exprs.len), + .type_from = type_from, + .type_to = @intCast(self.owned_ast.type_nodes.len), + }; } else |err| switch (err) { error.OutOfMemory => { self.gpa.free(key); @@ -2043,6 +2076,44 @@ pub const Interpreter = struct { if (text) |t| try self.execHookText(world, entity, t); } + /// Top-level trampoline matching `World.ExtensionCheckFn`: refuse, with + /// `ExtensionHookRefused`, any of the extension's hooks this program's type + /// checker refuses in the hooks' scope. + fn extensionCheckTrampoline(ctx: ?*anyopaque, world: *World, entity: CoreEntityId, name: []const u8, hooks: ExtensionHooks) anyerror!void { + _ = .{ world, entity, name }; + const self: *Interpreter = @ptrCast(@alignCast(ctx.?)); + if (hooks.on_attach) |t| try self.checkHook(t, hooks.scope); + if (hooks.on_detach) |t| try self.checkHook(t, hooks.scope); + } + + /// `text` checked once per scope against this program, from the + /// interpreter's own arena. + fn checkHook(self: *Interpreter, text: []const u8, scope: []const []const u8) !void { + const hook_run = try self.prepareHook(text); + const key = try hookVerdictKey(self.gpa, text, scope); + if (self.hook_verdicts.get(key)) |accepted| { + self.gpa.free(key); + if (!accepted) return error.ExtensionHookRefused; + return; + } + self.hook_verdicts.ensureUnusedCapacity(self.gpa, 1) catch |err| { + self.gpa.free(key); + return err; + }; + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(self.gpa); + diags.deinit(self.gpa); + } + types_mod.TypeChecker.checkHookRun(self.gpa, self.owned_ast, hook_run, scope, &diags) catch |err| { + self.gpa.free(key); + return err; + }; + const accepted = diags.items.len == 0; + self.hook_verdicts.putAssumeCapacityNoClobber(key, accepted); + if (!accepted) return error.ExtensionHookRefused; + } + /// Materialise an observer value binding (`value`/`old`/`new`) as a struct /// value over the component's raw bytes. Mirrors the `@on_event` /// payload binding: one `StructField` per declared field that is referenced diff --git a/src/etch/scene_cook.zig b/src/etch/scene_cook.zig index 45ff12a7..c7ee70f9 100644 --- a/src/etch/scene_cook.zig +++ b/src/etch/scene_cook.zig @@ -260,7 +260,7 @@ pub const BaseResolver = struct { }; /// Refuse an extension hook or `requires` clause the type checker refuses, so -/// a cooked hook is one `etch check` accepts (decision 3 point 4). +/// a cooked hook is one `etch check` accepts. fn checkHooks(gpa: std.mem.Allocator, ast: *AstArena, diag_out: ?*[]const u8) CookError!void { var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; defer { diff --git a/src/etch/types.zig b/src/etch/types.zig index 448cff2e..648f565d 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -827,11 +827,10 @@ pub const TypeChecker = struct { try self.validateSceneDecls(); } - /// The prefab half of `check`, for the cook (decision 3 point 4): the - /// declarations are collected by `check`'s own passes, and only `requires` - /// names and hook bodies are reported into `diagnostics`. A cook source - /// reaches its base prefab through a resolver, so the rest of `check` - /// (E1791 first) does not apply to it. + /// The prefab half of `check`, for the cook: the declarations are collected + /// by `check`'s own passes, and only `requires` names and hook bodies are + /// reported into `diagnostics`. A cook source reaches its base prefab through + /// a resolver, so the rest of `check` (E1791 first) does not apply to it. pub fn checkPrefabHooks(gpa: std.mem.Allocator, arena: *AstArena, diagnostics: *std.ArrayListUnmanaged(Diagnostic)) !void { try arena.ensureErrorBuiltins(gpa); var scratch: std.ArrayListUnmanaged(Diagnostic) = .empty; @@ -848,15 +847,58 @@ pub const TypeChecker = struct { defer tc.deinit(); try tc.runDeclarationPasses(); tc.diagnostics = diagnostics; + tc.range_reported.clearRetainingCapacity(); const kinds = arena.items.items(.kind); const datas = arena.items.items(.data); var i: u28 = 0; while (i < arena.items.len) : (i += 1) { if (kinds[i] != .prefab_decl) continue; - try tc.checkPrefabRequiresAndHooks(arena.prefab_decls.items[datas[i]]); + const decl = arena.prefab_decls.items[datas[i]]; + if (decl.has_on_attach) try tc.checkLiteralRangesIn(tc.bodyBytes(decl.on_attach_start, decl.on_attach_len)); + if (decl.has_on_detach) try tc.checkLiteralRangesIn(tc.bodyBytes(decl.on_detach_start, decl.on_detach_len)); + try tc.checkPrefabRequiresAndHooks(decl); } } + /// A statement run already in `arena`: `extra[start .. start + len]`, and the + /// expression and type-node indices its parse appended. + pub const HookRun = struct { start: u32, len: u32, expr_from: u32, expr_to: u32, type_from: u32, type_to: u32 }; + + /// A hook run checked as a prefab hook is, against `arena`'s declarations, + /// its scope the components `scope` names. Only the run's diagnostics reach + /// `diagnostics`. + pub fn checkHookRun(gpa: std.mem.Allocator, arena: *AstArena, run: HookRun, scope: []const []const u8, diagnostics: *std.ArrayListUnmanaged(Diagnostic)) !void { + try arena.ensureErrorBuiltins(gpa); + var scratch: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (scratch.items) |*d| d.deinit(gpa); + scratch.deinit(gpa); + } + var tc: TypeChecker = .{ + .gpa = gpa, + .arena = arena, + .diagnostics = &scratch, + .project = null, + }; + defer tc.deinit(); + try tc.runDeclarationPasses(); + tc.diagnostics = diagnostics; + tc.range_reported.clearRetainingCapacity(); + try tc.checkLiteralRangesIn(.{ .indices = .{ .expr_from = run.expr_from, .expr_to = run.expr_to, .type_from = run.type_from, .type_to = run.type_to } }); + var names: std.AutoHashMapUnmanaged(StringId, void) = .empty; + defer names.deinit(gpa); + for (scope) |name| if (arena.strings.find(name)) |id| try names.put(gpa, id, {}); + try tc.checkHookBody(&names, run.start, run.len); + } + + /// The source bytes a statement run spans. + fn bodyBytes(self: *TypeChecker, start: u32, len: u32) LiteralWindow { + if (len == 0) return .{ .bytes = .{ .from = 0, .to = 0 } }; + const first = self.arena.stmtSpan(@bitCast(self.arena.extra.items[start])); + const last = self.arena.stmtSpan(@bitCast(self.arena.extra.items[start + len - 1])); + return .{ .bytes = .{ .from = first.byte_start, .to = last.byte_end } }; + } + /// `E1901 ConstructNotAllowedInDeclarationFile` (`etch-grammar.md` /// §20.1/§20.2). A no-op outside a declaration file. /// @@ -2456,20 +2498,29 @@ pub const TypeChecker = struct { /// readable, as in a rule with no `when resource`. Each hook gets a fresh /// context. fn checkPrefabHook(self: *TypeChecker, decl: ast_mod.PrefabDecl, start: u32, len: u32) !void { - var ctx: RuleCtx = .{}; - defer ctx.deinit(self.gpa); + var scope: std.AutoHashMapUnmanaged(StringId, void) = .empty; + defer scope.deinit(self.gpa); var r: u32 = 0; while (r < decl.requires_len) : (r += 1) { - try ctx.components_in_when.put(self.gpa, self.arena.prefab_requires.items[decl.requires_start + r], {}); + try scope.put(self.gpa, self.arena.prefab_requires.items[decl.requires_start + r], {}); } var e: u32 = 0; while (e < decl.entities_len) : (e += 1) { const ent = self.arena.scene_entities.items[decl.entities_start + e]; var c: u32 = 0; while (c < ent.components_len) : (c += 1) { - try ctx.components_in_when.put(self.gpa, self.arena.component_instances.items[ent.components_start + c].type_name, {}); + try scope.put(self.gpa, self.arena.component_instances.items[ent.components_start + c].type_name, {}); } } + try self.checkHookBody(&scope, start, len); + } + + /// A hook body gated on the components `scope` holds, in a hook's context. + fn checkHookBody(self: *TypeChecker, scope: *const std.AutoHashMapUnmanaged(StringId, void), start: u32, len: u32) !void { + var ctx: RuleCtx = .{}; + defer ctx.deinit(self.gpa); + var names = scope.keyIterator(); + while (names.next()) |id| try ctx.components_in_when.put(self.gpa, id.*, {}); if (self.arena.strings.find("entity")) |eid| { try ctx.locals.put(self.gpa, eid, .{ .type_ = .{ .builtin = .entity }, .is_mut = false }); } @@ -4437,8 +4488,38 @@ pub const TypeChecker = struct { /// lexer never includes the sign, so a literal under a unary minus is judged /// negated: `-9223372036854775808` fits. fn checkLiteralRanges(self: *TypeChecker) !void { + return self.checkLiteralRangesIn(.all); + } + + /// Which literals a range check covers. + const LiteralWindow = union(enum) { + all, + /// Expression and type-node indices: a run parsed on its own. + indices: struct { expr_from: u32, expr_to: u32, type_from: u32, type_to: u32 }, + /// Source bytes: a body inside a parsed source. + bytes: struct { from: u32, to: u32 }, + + fn holdsExpr(w: LiteralWindow, index: usize, span: SourceSpan) bool { + return switch (w) { + .all => true, + .indices => |r| index >= r.expr_from and index < r.expr_to, + .bytes => |r| span.byte_start >= r.from and span.byte_end <= r.to, + }; + } + + fn holdsType(w: LiteralWindow, index: usize, size_span: SourceSpan) bool { + return switch (w) { + .all => true, + .indices => |r| index >= r.type_from and index < r.type_to, + .bytes => |r| size_span.byte_start >= r.from and size_span.byte_end <= r.to, + }; + } + }; + + fn checkLiteralRangesIn(self: *TypeChecker, window: LiteralWindow) !void { const kinds = self.arena.exprs.items(.kind); const datas = self.arena.exprs.items(.data); + const spans = self.arena.exprs.items(.span); var negated: std.AutoHashMapUnmanaged(u32, void) = .empty; defer negated.deinit(self.gpa); for (kinds, datas) |k, d| { @@ -4446,7 +4527,8 @@ pub const TypeChecker = struct { const u = self.arena.unary_exprs.items[d]; if (u.op == .neg and self.arena.exprKind(u.operand) == .int_lit) try negated.put(self.gpa, u.operand.index, {}); } - for (kinds, datas, 0..) |k, d, i| { + for (kinds, datas, spans, 0..) |k, d, span, i| { + if (!window.holdsExpr(i, span)) continue; const id: NodeId = .{ .category = .expr, .index = @intCast(i) }; const text = self.arena.strings.slice(d); const fits = switch (k) { @@ -4462,9 +4544,10 @@ pub const TypeChecker = struct { } const tkinds = self.arena.type_nodes.items(.kind); const tdatas = self.arena.type_nodes.items(.data); - for (tkinds, tdatas) |k, d| { + for (tkinds, tdatas, 0..) |k, d, i| { if (k != .array) continue; const size = self.arena.array_types.items[d].size; + if (!window.holdsType(i, self.arena.exprSpan(size))) continue; if (self.constArrayLen(size) != null) continue; try self.emit(.not_const_evaluable, .error_, self.arena.exprSpan(size), "array size must be a non-negative integer literal", .{}); } @@ -9139,6 +9222,18 @@ test "a hook reading an engine resource is clean" { try std.testing.expectEqual(@as(usize, 0), r.diagnostics.items.len); } +test "a hook literal out of range is refused at the source" { + const gpa = std.testing.allocator; + var r = try checkHookSource(gpa, hook_base ++ + \\prefab "Mod" extends "Base" requires Health { + \\ entity "m" { Weapon {} } + \\ on_attach { let big = 99999999999999999999 } + \\} + ); + defer r.deinit(gpa); + try expectAnyCode(r.diagnostics.items, .type_mismatch); +} + test "on_detach does not see a let of on_attach" { const gpa = std.testing.allocator; var r = try checkHookSource(gpa, hook_base ++ diff --git a/tests/scene/extensions_test.zig b/tests/scene/extensions_test.zig index 7efa9204..f665447d 100644 --- a/tests/scene/extensions_test.zig +++ b/tests/scene/extensions_test.zig @@ -1260,3 +1260,287 @@ test "an extends requires over a malformed but rehashed base does not cook" { ; try std.testing.expectError(error.BasePrefabCorrupt, scene_cook.cookPrefab(gpa, src, res.base(), null)); } + +const Registry = weld_core.ecs.registry.Registry; +const scene_format = scene.format; + +/// The `.prefab.bin` of a one-entity extension carrying `own` (a component of +/// `own_size` bytes, 4-aligned), requiring `requires`, with the given hook +/// texts: what a cook would write, without the cook's checks. +fn forgedExtension(gpa: std.mem.Allocator, own: []const u8, own_size: u16, requires: []const []const u8, on_attach: ?[]const u8, on_detach: ?[]const u8) ![]u8 { + var reg = Registry.init(); + defer reg.deinit(gpa); + const zeros = [_]u8{0} ** 16; + const comp = try reg.registerComponentRaw(gpa, .{ .name = own, .size = own_size, .alignment = 4, .default_bytes = zeros[0..own_size], .fields = &.{} }); + var arena = std.heap.ArenaAllocator.init(gpa); + const a = arena.allocator(); + var strings: std.ArrayListUnmanaged([]const u8) = .empty; + try strings.append(a, "mod"); + var hook: scene_format.HookSet = .{ .on_attach = null, .on_detach = null }; + if (on_attach) |t| { + hook.on_attach = @intCast(strings.items.len); + try strings.append(a, t); + } + if (on_detach) |t| { + hook.on_detach = @intCast(strings.items.len); + try strings.append(a, t); + } + const req = try a.alloc(u32, requires.len); + for (requires, req) |name, *r| { + r.* = @intCast(strings.items.len); + try strings.append(a, name); + } + const col = try a.alloc(u8, own_size); + @memset(col, 0); + const ids = try a.dupe(ComponentId, &.{comp}); + const cols = try a.dupe([]u8, &.{col}); + const ents = try a.dupe(scene_format.EntityEntry, &.{.{ .name = 0, .uuid = 0, .parent_uuid = scene_format.no_parent }}); + const blocks = try a.dupe(scene_format.ArchetypeBlock, &.{.{ .component_ids = ids, .entity_count = 1, .columns = cols, .entities = ents }}); + const hooks = try a.dupe(scene_format.HookSet, &.{hook}); + const uuids = try a.dupe([16]u8, &.{[_]u8{9} ** 16}); + var model: scene_format.CookModel = .{ .strings = strings.items, .uuids = uuids, .resources = &.{}, .archetypes = blocks, .hooks = hooks, .requires = req, .arena = arena }; + defer model.deinit(); + return scene.writer.write(gpa, model, ®); +} + +const hook_program = + \\component Health { current: i32 = 100, max: i32 = 100 } + \\component Weapon { damage: i32 = 0 } + \\component Mana { v: i32 = 0 } + \\component Marker { v: i32 = 0 } + \\event Attached { } + \\rule keep(entity: Entity) when entity has Health { } +; + +/// A world running `hook_program` with its seams bound, holding one entity +/// that carries `Health` and `Mana`. +const HookWorld = struct { + world: World, + pr: parser.ParseResult, + interp: Interpreter, + entity: EntityId, + + fn init(self: *HookWorld, gpa: std.mem.Allocator) !void { + self.world = World.init(); + errdefer self.world.deinit(gpa); + self.pr = try parser.parse(gpa, hook_program); + errdefer self.pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), self.pr.diagnostics.len); + self.interp = try Interpreter.compile(gpa, &self.pr.ast, &self.world); + errdefer self.interp.deinit(); + try self.interp.bindToWorld(&self.world); + self.entity = try self.spawnHealthMana(gpa); + } + + fn deinit(self: *HookWorld, gpa: std.mem.Allocator) void { + self.interp.deinit(); + self.pr.deinit(gpa); + self.world.deinit(gpa); + } + + fn spawnHealthMana(self: *HookWorld, gpa: std.mem.Allocator) !EntityId { + return self.world.spawnDynamic(gpa, &[_]ComponentId{ self.world.componentId("Health").?, self.world.componentId("Mana").? }); + } + + fn mana(self: *HookWorld, e: EntityId) i32 { + return std.mem.readInt(i32, self.world.componentBytes(e, self.world.componentId("Mana").?).?[0..4], .little); + } + + fn weapon(self: *HookWorld, e: EntityId) ?i32 { + const b = self.world.componentBytes(e, self.world.componentId("Weapon").?) orelse return null; + return std.mem.readInt(i32, b[0..4], .little); + } +}; + +test "a hook reaching outside its scope is refused at activation" { + const gpa = std.testing.allocator; + var h: HookWorld = undefined; + try h.init(gpa); + defer h.deinit(gpa); + const bytes = try forgedExtension(gpa, "Weapon", 4, &.{"Health"}, "entity.get_mut(Mana).v += 1", null); + defer gpa.free(bytes); + try std.testing.expectError(error.ExtensionHookRefused, scene.loader.activateExtension(&h.world, gpa, h.entity, "Forged", bytes)); + try std.testing.expectEqual(@as(i32, 0), h.mana(h.entity)); + try std.testing.expect(h.weapon(h.entity) == null); + try std.testing.expect(!h.world.hasEntityExtension(h.entity, "Forged")); +} + +test "a return in a hook is refused at activation" { + const gpa = std.testing.allocator; + var h: HookWorld = undefined; + try h.init(gpa); + defer h.deinit(gpa); + const bytes = try forgedExtension(gpa, "Weapon", 4, &.{"Health"}, "return", null); + defer gpa.free(bytes); + try std.testing.expectError(error.ExtensionHookRefused, scene.loader.activateExtension(&h.world, gpa, h.entity, "Forged", bytes)); + try std.testing.expect(h.weapon(h.entity) == null); +} + +test "an on_detach the checker refuses is refused at activation" { + const gpa = std.testing.allocator; + var h: HookWorld = undefined; + try h.init(gpa); + defer h.deinit(gpa); + const bytes = try forgedExtension(gpa, "Weapon", 4, &.{"Health"}, "entity.get_mut(Health).max += 1", "entity.get_mut(Mana).v -= 1"); + defer gpa.free(bytes); + try std.testing.expectError(error.ExtensionHookRefused, scene.loader.activateExtension(&h.world, gpa, h.entity, "Forged", bytes)); + try std.testing.expect(h.weapon(h.entity) == null); +} + +test "deactivation checks the on_detach of the bytes it is given" { + const gpa = std.testing.allocator; + var h: HookWorld = undefined; + try h.init(gpa); + defer h.deinit(gpa); + const good = try forgedExtension(gpa, "Weapon", 4, &.{"Health"}, null, "entity.get_mut(Health).max -= 1"); + defer gpa.free(good); + const bad = try forgedExtension(gpa, "Weapon", 4, &.{"Health"}, null, "entity.get_mut(Mana).v -= 1"); + defer gpa.free(bad); + try scene.loader.activateExtension(&h.world, gpa, h.entity, "Forged", good); + try std.testing.expectError(error.ExtensionHookRefused, scene.loader.deactivateExtension(&h.world, gpa, h.entity, "Forged", bad)); + try std.testing.expectEqual(@as(i32, 0), h.mana(h.entity)); + try std.testing.expect(h.weapon(h.entity) != null); + try std.testing.expect(h.world.hasEntityExtension(h.entity, "Forged")); +} + +test "a hook reading its own component is accepted and runs" { + const gpa = std.testing.allocator; + var h: HookWorld = undefined; + try h.init(gpa); + defer h.deinit(gpa); + const bytes = try forgedExtension(gpa, "Weapon", 4, &.{"Health"}, "entity.get_mut(Weapon).damage += 1", null); + defer gpa.free(bytes); + try scene.loader.activateExtension(&h.world, gpa, h.entity, "Forged", bytes); + try std.testing.expectEqual(@as(?i32, 1), h.weapon(h.entity)); +} + +test "a hook literal out of range is refused at activation" { + const gpa = std.testing.allocator; + var h: HookWorld = undefined; + try h.init(gpa); + defer h.deinit(gpa); + const bytes = try forgedExtension(gpa, "Weapon", 4, &.{"Health"}, "entity.get_mut(Health).max += 99999999999999999999", null); + defer gpa.free(bytes); + try std.testing.expectError(error.ExtensionHookRefused, scene.loader.activateExtension(&h.world, gpa, h.entity, "Forged", bytes)); + try std.testing.expect(h.weapon(h.entity) == null); +} + +test "a hook refused for its literal does not refuse another hook" { + const gpa = std.testing.allocator; + var h: HookWorld = undefined; + try h.init(gpa); + defer h.deinit(gpa); + const bad = try forgedExtension(gpa, "Weapon", 4, &.{"Health"}, "entity.get_mut(Health).max += 99999999999999999999", null); + defer gpa.free(bad); + const good = try forgedExtension(gpa, "Weapon", 4, &.{"Health"}, "entity.get_mut(Health).max += 1", null); + defer gpa.free(good); + const other = try h.spawnHealthMana(gpa); + try std.testing.expectError(error.ExtensionHookRefused, scene.loader.activateExtension(&h.world, gpa, h.entity, "Bad", bad)); + try scene.loader.activateExtension(&h.world, gpa, other, "Good", good); + try std.testing.expect(h.weapon(other) != null); +} + +test "one hook text is judged in each scope it arrives with" { + const gpa = std.testing.allocator; + var h: HookWorld = undefined; + try h.init(gpa); + defer h.deinit(gpa); + const in_scope = try forgedExtension(gpa, "Weapon", 4, &.{"Mana"}, "entity.get_mut(Mana).v += 1", null); + defer gpa.free(in_scope); + const out_of_scope = try forgedExtension(gpa, "Weapon", 4, &.{"Health"}, "entity.get_mut(Mana).v += 1", null); + defer gpa.free(out_of_scope); + const other = try h.spawnHealthMana(gpa); + try scene.loader.activateExtension(&h.world, gpa, h.entity, "WithMana", in_scope); + try std.testing.expectError(error.ExtensionHookRefused, scene.loader.activateExtension(&h.world, gpa, other, "WithHealth", out_of_scope)); +} + +test "a hook literal out of range does not cook" { + const gpa = std.testing.allocator; + const src = + \\component Weapon { damage: i32 = 10 } + \\prefab "Big" extends "Base" { + \\ entity "m" { uuid: "00000000-0000-0000-0000-0000000000c6" Weapon { damage: 1 } } + \\ on_attach { entity.get_mut(Weapon).damage += 99999999999999999999 } + \\} + ; + if (scene_cook.cookPrefab(gpa, src, null, null)) |cooked| { + var c = cooked; + c.deinit(gpa); + return error.TestUnexpectedResult; + } else |err| try std.testing.expectEqual(error.HookRefused, err); +} + +/// Cook a two-entity scene: `a` carries Marker and activates `a_ext`, `b` +/// carries Marker and Mana and activates `b_ext`. +fn cookHookScene(gpa: std.mem.Allocator, a_ext: []const u8, b_ext: []const u8) ![]u8 { + const src = try std.fmt.allocPrint(gpa, + \\component Marker {{ v: i32 = 0 }} + \\component Mana {{ v: i32 = 0 }} + \\scene "S" {{ + \\ entity "a" {{ uuid: "00000000-0000-0000-0000-0000000000a1" extensions: {s} Marker {{ v: 1 }} }} + \\ entity "b" {{ uuid: "00000000-0000-0000-0000-0000000000b2" extensions: {s} Marker {{ v: 1 }} Mana {{ v: 0 }} }} + \\}} + , .{ a_ext, b_ext }); + defer gpa.free(src); + var cooked = try scene_cook.cook(gpa, src, null); + defer cooked.deinit(gpa); + return scene.writer.write(gpa, cooked.model, &cooked.registry); +} + +test "a load runs no hook when one of its hooks is refused" { + const gpa = std.testing.allocator; + var h: HookWorld = undefined; + try h.init(gpa); + defer h.deinit(gpa); + const good = try forgedExtension(gpa, "Weapon", 4, &.{}, "emit Attached { }", null); + defer gpa.free(good); + const bad = try forgedExtension(gpa, "Weapon", 4, &.{}, "entity.get_mut(Mana).v += 1", null); + defer gpa.free(bad); + const scene_bytes = try cookHookScene(gpa, "[\"Good\"]", "[\"Bad\"]"); + defer gpa.free(scene_bytes); + var mr = MultiResolver{ .names = &.{ "Good", "Bad" }, .blobs = &.{ good, bad } }; + if (scene.loader.loadFromBytes(&h.world, gpa, scene_bytes, mr.ext())) |loaded| { + var l = loaded; + l.deinit(gpa); + return error.TestUnexpectedResult; + } else |err| try std.testing.expectEqual(error.ExtensionHookRefused, err); + try std.testing.expectEqual(@as(usize, 0), h.interp.events.list.items.len); +} + +test "a load runs no hook when a requirement is missing" { + const gpa = std.testing.allocator; + var h: HookWorld = undefined; + try h.init(gpa); + defer h.deinit(gpa); + const good = try forgedExtension(gpa, "Weapon", 4, &.{}, "emit Attached { }", null); + defer gpa.free(good); + const needs = try forgedExtension(gpa, "Weapon", 4, &.{"Health"}, null, null); + defer gpa.free(needs); + const scene_bytes = try cookHookScene(gpa, "[\"Good\"]", "[\"NeedsHealth\"]"); + defer gpa.free(scene_bytes); + var mr = MultiResolver{ .names = &.{ "Good", "NeedsHealth" }, .blobs = &.{ good, needs } }; + if (scene.loader.loadFromBytes(&h.world, gpa, scene_bytes, mr.ext())) |loaded| { + var l = loaded; + l.deinit(gpa); + return error.TestUnexpectedResult; + } else |err| try std.testing.expectEqual(error.RequiresNotSatisfied, err); + try std.testing.expectEqual(@as(usize, 0), h.interp.events.list.items.len); +} + +test "a load credits a requirement an earlier extension provides" { + const gpa = std.testing.allocator; + var h: HookWorld = undefined; + try h.init(gpa); + defer h.deinit(gpa); + const healthy = try forgedExtension(gpa, "Health", 8, &.{}, null, null); + defer gpa.free(healthy); + const needs = try forgedExtension(gpa, "Weapon", 4, &.{"Health"}, null, null); + defer gpa.free(needs); + const scene_bytes = try cookHookScene(gpa, "[\"Healthy\", \"NeedsHealth\"]", "[\"Healthy\"]"); + defer gpa.free(scene_bytes); + var mr = MultiResolver{ .names = &.{ "Healthy", "NeedsHealth" }, .blobs = &.{ healthy, needs } }; + var loaded = try scene.loader.loadFromBytes(&h.world, gpa, scene_bytes, mr.ext()); + defer loaded.deinit(gpa); + const a = loaded.uuid_to_entity.get(uuidBytes(0xa1)).?; + try std.testing.expect(h.weapon(a) != null); +} diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index d8470ec2..712dde6a 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2692, - else => 2694, + .windows => 2705, + else => 2707, }; } From 81c9b8cc0554a051ce3eeb9e4f760218fa520b20 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 26 Sep 2026 11:28:18 +0200 Subject: [PATCH 080/141] docs(brief): record the load end of decision 3 Co-Authored-By: Claude Opus 5.5 --- briefs/m1.d-phase-1-debt.md | 94 +++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index 581b89fa..3ccdc58b 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -6678,6 +6678,97 @@ on windows, read from the suite at each step. **What remains of decision 3 is returned** — B5. +### S5/G9 undecies — the load end of decision 3 + +Ruled on B5: the interpreter owns a copy of the program's arena and parses each hook +text into it once; `requires` is serialized, `format_version` 2 → 3; then the load +end, points 1 and 2. Every witness below was written after its prediction and run red +on the unfixed tree; every counter-factual was predicted before its run. + +**The arena** (`ff45cdac`). `compile` clones the caller's arena into a box the +interpreter owns and runs every pass on the clone, so the caller may free its arena as +soon as `compile` returns; `parseStmtBlockInto` parses a hook text into that arena once +and `prepareHook` caches its run by owned text; `execHookText` no longer rebinds +`self.ast`. `AstArena.clone` walks its fields at comptime, a field of a type with no +copy rule failing to compile; `StringPool.clone` copies every string and rebuilds its +map on the copy. Six witnesses red first, each at its predicted value — an event a hook +emits stored under its hook-pool id 1 against the program's 6; a string literal read +back `"current"`; a program `fn` not found; no growth of the arena on a first run; the +interpreter's arena the caller's; and an interpreter used after its parse result was +freed, `Segmentation fault at address 0xaaaaaaaaaaaaaaaa` — plus three clone tests, +one an allocation-failure sweep. Two tests change, declared: the reload test counting +`compile`'s allocations now subtracts the clone's own, which follow the source text +and not the registry; the hook test asserting `self.ast` was restored after a rebind +that no longer exists now asserts ownership. A guard of mine assumed the `annotations` +map populated: measured, it has no writer in `src/etch`. + +Counter-factuals: no scalar copied reddens the clone test and the two tests that throw +a Zig error through the builtin `Error`; no errdefer leaks in the clone sweep and in +the four `compile` sweeps; no cache lookup crashed two tests, **refuting the +prediction in form** — the mutation kept `putAssumeCapacityNoClobber`, so it measured +the cache's invariant and not "parsed once"; redone without the insert, it reddens the +parse-once test alone; the reload count without the subtraction reddens that test +alone; a pointer field added to `AstArena` fails to compile with "AstArena.clone has +no copy rule for field probe". + +**`requires` serialized** (`8ae99d4c`). The extensions region gains a fourth table +after the hooks, `requires_count:u32` then that many string-table refs, and the +format goes to 3. Activation refuses at step 1, before any mutation, an entity missing +a required component; the scene cook refuses the same case when it resolves the +extension, counting components from the base and from the extensions listed before +it, the order the loader activates them. The validator reads the table, refuses more +than one hook set — the format stated it and the loader relied on it — and requires +the region to end exactly at the cross-references, where trailing bytes passed. +**Found alongside and swept as a class**: the cook opened resolver-supplied bytes for +its getters without structure validation at three sites, `of`, the `requires` check +and `instance of`; one `openResolvedPrefab` now does all three checks. **Predicted as a +panic and measured as a silent acceptance**: each malformed base (`schema_count` +65535) cooked, the getters walking only what they needed before reaching an index +past the table. Fourteen witnesses, nine red first; two assertions move with the +version, 2 → 3, declared. + +Counter-factuals: the writer dropping the names reddens the four `requires` +witnesses — at first with two steps hung under the load of the batch itself, six +tests uncounted, so "nothing else" could not be claimed; rerun at +`--test-timeout 180s`, clean, 2694 collected, the same four. The hook-count, region-end, +loader, scene-cook and structure mutations each redden exactly their witnesses. The +validator without the required-name ref check was **predicted silent and was**: a +coverage gap, closed by a witness that then crashes under the same mutation, the +getter reading at `0xFFFFFFF0` — the out-of-bounds read the validator exists to stop. + +**The load end** (`2d5217f1`), points 1 and 2. A `World` seam, `ExtensionCheckFn`, +registered by the interpreter at `bindToWorld`: it parses a hook text once into the +interpreter's own arena and type-checks it there against the program's declarations, +gated on the extension's own components and its `requires`, in a hook's context; a +refusal is `ExtensionHookRefused`. The loader fires it before any mutation — at +activation step 1 on both hooks, at deactivation step 1 on `on_detach`, and, at load, +in a pre-pass over every activation before the first hook runs, which also checks each +requirement against the entity and the extensions listed before it there. The hook +body check is one function for the source and the load ends, parameterised by its +scope; verdicts are cached per text and scope. **Found while writing it, a gap of +`bf37458f`**: the cook's hook check sent the declaration passes' diagnostics to a +discarded list, and the literal range pass among them marked each literal reported, +so a hook literal past `int` cooked while `etch check` refused it. The range check now +covers a hook's literals, bounded to the body's bytes at the cook and, at load — every +hook sharing one arena — to the expressions the hook's parse appended. Fourteen +witnesses, nine red first, each at its predicted value; one, the same text judged in +two scopes, added before the counter-factuals because nothing else would have noticed +a cache keyed on the text alone. + +Counter-factuals: without the activation check, exactly the six activation-level +witnesses; without the deactivation check, its witness; without the pre-pass, the two +load witnesses; the pre-pass crediting nothing, the earlier-extension control; the +scope without its own components, the own-component control; without `requires`, +eight tests, every interpreter-backed `CombatModule` activation among them; the two +literal checks and the window each redden exactly theirs. Three mutations **measured +the wrong thing and were redone**: removing a scope loop left its counter unused, a +compile error at 62 of 329 steps, twice; and keying verdicts on the text alone first +returned a key shorter than its allocation, eight crashes in the free, then did not +compile — at the right length it reddens the two-scope witness alone. + +Floor 2672 → 2680 → 2694 → 2707 on macOS and 2670 → 2678 → 2692 → 2705 on windows, +read from the suite at each step. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree @@ -6937,6 +7028,9 @@ Open around it, each needing an arbitration rather than a number: - `when entity has T` refuses an imported `T` and `emit` an imported event, measured (E0102 `'Ping' is not a declared event`), both pre-existing. +Both rulings taken on 2026-09-26 and delivered at S5/G9 undecies; the open points +above are ruled and not yet delivered. + ## Notes ### A comparison refuses the fingerprint of nothing before it concludes From 760c10b86c25f37271cb4a274d846162783fd65d Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 27 Sep 2026 05:09:26 +0200 Subject: [PATCH 081/141] fix(etch): compare a cloned arena's lists by field, not padding bytes The clone test compared each list's elements as raw bytes against a separate parse, and a slab element's padding is uninitialized: on ubuntu-24.04 / Debug, at both precisions, byte 9 of a 12-byte element read 0x05 in one parse and 0x20 in the other. The lists now compare field by field. The clone copied the bytes it was given; the instrument compared bytes that carry no content. Co-Authored-By: Claude Opus 5.5 --- src/etch/ast.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/etch/ast.zig b/src/etch/ast.zig index b216c12b..f0d26161 100644 --- a/src/etch/ast.zig +++ b/src/etch/ast.zig @@ -4166,7 +4166,7 @@ fn expectArenasEqual(a: *const AstArena, b: *const AstArena) !void { } else switch (@typeInfo(f.type)) { .int, .@"enum" => try std.testing.expectEqual(x, y), .@"struct" => if (comptime @hasField(f.type, "items")) { - try std.testing.expectEqualSlices(u8, std.mem.sliceAsBytes(x.items), std.mem.sliceAsBytes(y.items)); + try std.testing.expectEqualDeep(x.items, y.items); } else if (comptime @hasField(f.type, "bytes")) { try std.testing.expectEqual(x.len, y.len); for (0..x.len) |i| try std.testing.expectEqual(x.get(i), y.get(i)); From 90512a805387033fd20b6155eb336e89bc2dc4fd Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 27 Sep 2026 06:25:23 +0200 Subject: [PATCH 082/141] docs(brief): record the clone test's padding red and its fix Co-Authored-By: Claude Opus 5.5 --- briefs/m1.d-phase-1-debt.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index 3ccdc58b..b2821d5a 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -6766,6 +6766,16 @@ compile error at 62 of 329 steps, twice; and keying verdicts on the text alone f returned a key shorter than its allocation, eight crashes in the free, then did not compile — at the right length it reddens the two-scope witness alone. +**CI refused `ff45cdac` on `ubuntu-24.04 / Debug`, at both precisions, and the cause +was my instrument.** The clone test compared each list's elements as raw bytes against +a separate parse, and a slab element's padding is uninitialized: byte 9 of a 12-byte +element read `0x05` in one parse and `0x20` in the other, where macOS/aarch64 happened +to agree. The lists compare field by field since `760c10b8`; a counter-factual dropping +one list from the clone reddens the test by a failed comparison, not a crash. Two +pushes then died with exit 141 after every hook passed: git opens the SSH connection +before the pre-push hook and it idled out; one keepalive, for that command only, +carried the third. + Floor 2672 → 2680 → 2694 → 2707 on macOS and 2670 → 2678 → 2692 → 2705 on windows, read from the suite at each step. From ef673b5f2f715a6f37fd523c9cd27675ad3cd89d Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 27 Sep 2026 07:32:01 +0200 Subject: [PATCH 083/141] refactor(etch): remove the annotation map no code reads or writes AstArena.annotations had no writer and no reader: the only access was its own deinit, and the generic clone walk copied it empty. Measured by grep over src/, tests/, tools/ and bench/, and by the compiler, which builds the full suite with the field gone. Annotations live in annot_pool, reached through each declaration's own range. The AnnotationSpan type existed only as the map's value type and goes with it. Co-Authored-By: Claude Opus 5.5 --- src/etch/ast.zig | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/etch/ast.zig b/src/etch/ast.zig index f0d26161..b0d6ac65 100644 --- a/src/etch/ast.zig +++ b/src/etch/ast.zig @@ -12,8 +12,9 @@ //! component declaration). Each reference is a `(start, len)` pair on //! the side slab. //! - `StringPool` interns identifier names and string literal contents. -//! - `AnnotationMap`: hash table keyed by `NodeId` → `AnnotationSpan` -//! (range in `annot_pool`). +//! - `annot_pool` holds every parsed annotation. A declaration carries its +//! own `(annotations_extra, annotations_len)` range into it, and each +//! annotation its `(args_start, args_len)` range into `annot_args`. //! - `comment_spans` is a parallel slab — not attached to NodeIds, //! kept for a future trivia attachment. //! - `StableId` is absent (left at zero). It is owed by the editor, which @@ -2749,8 +2750,6 @@ pub const AstArena = struct { generic_params: std.ArrayListUnmanaged(GenericParam) = .empty, generic_bounds: std.ArrayListUnmanaged(GenericBound) = .empty, - // Annotation storage. - annotations: std.AutoHashMapUnmanaged(NodeId, AnnotationSpan) = .empty, annot_pool: std.ArrayListUnmanaged(Annotation) = .empty, annot_args: std.ArrayListUnmanaged(AnnotationArg) = .empty, @@ -2789,11 +2788,6 @@ pub const AstArena = struct { /// the prelude into every program. `maxInt(u32)` = none injected. builtin_fields_from: u32 = std.math.maxInt(u32), - pub const AnnotationSpan = struct { - start: u32, - len: u32, - }; - /// `(start, len)` slice into a span pool (`comment_spans` for /// `leading_comments`, `doc_comment_spans` for `doc_comments`). pub const SpanRange = struct { @@ -2970,7 +2964,6 @@ pub const AstArena = struct { self.generic_type_nodes.deinit(gpa); self.generic_params.deinit(gpa); self.generic_bounds.deinit(gpa); - self.annotations.deinit(gpa); self.annot_pool.deinit(gpa); self.annot_args.deinit(gpa); self.comment_spans.deinit(gpa); From 9a492c6f34185d820ea9eb389227178808da0905 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 27 Sep 2026 07:41:37 +0200 Subject: [PATCH 084/141] fix(etch): refuse to run an interpreter moved after it bound its world bindToWorld hands the interpreter's own address to the world, as the context of the three extension seams and of every observer rule. A copy made after the bind kept running while the world called back into the original, and the idempotent flag travelled with the copy, so a later bind was a silent no-op. The interpreter now records the address it bound, and stepOnce and a second bindToWorld refuse with InterpreterMovedAfterBind from any other address. tickOnAcc, the one site that moved a bound interpreter, now compiles into a caller-owned slot and binds there. Red first: the two moved-copy tests failed with "found void" before the guard; with the guard and the old helper, the tickOnAcc re-tick test failed with InterpreterMovedAfterBind. Floor 2707 to 2710. Co-Authored-By: Claude Opus 5.5 --- src/etch/interp.zig | 145 ++++++++++++++++++++++++--------- tools/weld_lint/dead_tests.zig | 4 +- 2 files changed, 107 insertions(+), 42 deletions(-) diff --git a/src/etch/interp.zig b/src/etch/interp.zig index 668a5f33..b7c8ee88 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -1351,8 +1351,9 @@ pub const Interpreter = struct { /// set; the registry holds `&observer_ctxs[k]` as its opaque `ctx`. Freed at /// `deinit` (the interpreter must outlive any observer-firing flush). observer_ctxs: []ObserverCtx = &.{}, - /// Set once observers have been registered (idempotent `bindToWorld`). - observers_bound: bool = false, + /// The address `bindToWorld` registered into the world, which holds it as + /// the hooks' and observers' context. Non-null once bound. + bound_at: ?*const Interpreter = null, /// Non-null while an observer body runs: the registry's deferred /// command buffer. Structural mutations issued by the body (tag mutations) /// route here instead of `pending_tags`, so they apply at the NEXT flush — @@ -1710,11 +1711,10 @@ pub const Interpreter = struct { } pub fn runFor(self: *Interpreter, world: *World, ticks: u32) !RuntimeReport { - // Register this program's observer rules into the world's - // `ObserverRegistry` — lazily, once, now that `self` is at a - // stable address (the caller holds the interpreter; `compile` returns by - // value). A test that drives a Tier-0 flush directly calls `bindToWorld` - // itself before flushing. + // Bound here and not in `compile`, which returns by value: the world + // keeps this address, so the interpreter must not move once it runs. A + // test that drives a Tier-0 flush directly calls `bindToWorld` itself + // before flushing. try self.bindToWorld(world); var report: RuntimeReport = .{}; var t: u32 = 0; @@ -1832,11 +1832,12 @@ pub const Interpreter = struct { /// Register this program's observer rules into `world`'s `ObserverRegistry`. /// Idempotent: the first call allocates one `ObserverCtx` per observer rule and /// registers a trampoline keyed on the rule's lifecycle kind + target component; - /// later calls no-op. Called lazily by `runFor`, or explicitly by a test that - /// drives a Tier-0 flush before any tick. + /// later calls no-op, and refuse from a copy of the bound interpreter. Called + /// lazily by `runFor`, or explicitly by a test that drives a Tier-0 flush + /// before any tick. pub fn bindToWorld(self: *Interpreter, world: *World) !void { - if (self.observers_bound) return; - self.observers_bound = true; + if (self.bound_at != null) return self.checkNotMoved(); + self.bound_at = self; // register the extension hook seams so the loader's // `dispatchOnAttach` / runtime `deactivate_extension` reach `execHookText`. @@ -1871,6 +1872,12 @@ pub const Interpreter = struct { } } + /// Refuse to run from an address other than the one the world holds: the + /// world would call back into the interpreter this one was copied from. + fn checkNotMoved(self: *const Interpreter) error{InterpreterMovedAfterBind}!void { + if (self.bound_at) |at| if (at != self) return error.InterpreterMovedAfterBind; + } + /// Top-level trampoline matching `observers.ObserverFn`: unpack /// the `ObserverCtx` and run the observer rule body with `entity` + the /// lifecycle value(s) bound. @@ -2152,6 +2159,7 @@ pub const Interpreter = struct { } pub fn stepOnce(self: *Interpreter, world: *World, report: *RuntimeReport) !void { + try self.checkNotMoved(); // Advance `current_tick` (and clear the dirty bitsets) at the start of // the tick when change detection is live, so a write this tick stamps // `changedTick = current_tick > last_run_tick` and a `changed` filter @@ -17211,17 +17219,17 @@ test "a rule parameter typed by an alias of a scalar is bound as that scalar" { try std.testing.expect(locals.get(dt).? == .float_); } -/// One tick of `source` over one entity carrying `Acc`, unchecked. The caller -/// owns the returned interpreter. -fn tickOnAcc(gpa: std.mem.Allocator, world: *World, pr: *const parser_mod.ParseResult) !struct { interp: Interpreter, report: RuntimeReport, bytes: []u8 } { +/// One tick of `source` over one entity carrying `Acc`, unchecked, by an +/// interpreter compiled into `interp`, which the caller then owns. +fn tickOnAcc(gpa: std.mem.Allocator, world: *World, pr: *const parser_mod.ParseResult, interp: *Interpreter) !struct { report: RuntimeReport, bytes: []u8 } { try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); - var interp = try Interpreter.compile(gpa, &pr.ast, world); + interp.* = try Interpreter.compile(gpa, &pr.ast, world); errdefer interp.deinit(); const cid = world.registry.idOf("Acc").?; const e = try world.spawnDynamic(gpa, &[_]ComponentId{cid}); const report = try interp.runFor(world, 1); const off = world.registry.findField(cid, "out").?.offset; - return .{ .interp = interp, .report = report, .bytes = world.componentBytes(e, cid).?[off..] }; + return .{ .report = report, .bytes = world.componentBytes(e, cid).?[off..] }; } /// Asserts `report` holds exactly one runtime error, of `kind`. @@ -17230,6 +17238,51 @@ fn expectRuntimeError(report: RuntimeReport, kind: RuntimeErrorKind) !void { try std.testing.expectEqual(kind, (report.last_error orelse return error.TestExpectedTypedError).kind); } +test "an interpreter moved after it bound its world refuses to tick" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, "component Acc { out: int = 0 }\n"); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + try interp.bindToWorld(&world); + var moved = interp; + var report: RuntimeReport = .{}; + try std.testing.expectError(error.InterpreterMovedAfterBind, moved.stepOnce(&world, &report)); +} + +test "an interpreter moved after it bound its world refuses to bind again" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, "component Acc { out: int = 0 }\n"); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + try interp.bindToWorld(&world); + var moved = interp; + try std.testing.expectError(error.InterpreterMovedAfterBind, moved.bindToWorld(&world)); +} + +test "an interpreter tickOnAcc ran ticks again" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, + \\component Acc { out: int = 0 } + \\rule r(entity: Entity) when entity has Acc { + \\ entity.get_mut(Acc).out += 1 + \\} + ); + defer pr.deinit(gpa); + var interp: Interpreter = undefined; + const run = try tickOnAcc(gpa, &world, &pr, &interp); + defer interp.deinit(); + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 2), std.mem.readInt(i64, run.bytes[0..8], .little)); +} + test "an overflowing addition panics or wraps by build mode" { const gpa = std.testing.allocator; var world = World.init(); @@ -17242,8 +17295,9 @@ test "an overflowing addition panics or wraps by build mode" { \\} ); defer pr.deinit(gpa); - var run = try tickOnAcc(gpa, &world, &pr); - defer run.interp.deinit(); + var interp: Interpreter = undefined; + const run = try tickOnAcc(gpa, &world, &pr, &interp); + defer interp.deinit(); if (value_mod.overflow_wraps) { try std.testing.expectEqual(@as(u64, 0), run.report.runtime_errors); try std.testing.expectEqual(std.math.minInt(i64), std.mem.readInt(i64, run.bytes[0..8], .little)); @@ -17263,8 +17317,9 @@ test "an overflowing compound assignment reports IntegerOverflow" { \\} ); defer pr.deinit(gpa); - var run = try tickOnAcc(gpa, &world, &pr); - defer run.interp.deinit(); + var interp: Interpreter = undefined; + const run = try tickOnAcc(gpa, &world, &pr, &interp); + defer interp.deinit(); if (value_mod.overflow_wraps) { try std.testing.expectEqual(std.math.minInt(i64), std.mem.readInt(i64, run.bytes[0..8], .little)); } else try expectRuntimeError(run.report, .IntegerOverflow); @@ -17282,8 +17337,9 @@ test "negating the int minimum panics or wraps by build mode" { \\} ); defer pr.deinit(gpa); - var run = try tickOnAcc(gpa, &world, &pr); - defer run.interp.deinit(); + var interp: Interpreter = undefined; + const run = try tickOnAcc(gpa, &world, &pr, &interp); + defer interp.deinit(); if (value_mod.overflow_wraps) { try std.testing.expectEqual(std.math.minInt(i64), std.mem.readInt(i64, run.bytes[0..8], .little)); } else try expectRuntimeError(run.report, .IntegerOverflow); @@ -17298,8 +17354,9 @@ test "the int minimum literal evaluates" { \\rule r(entity: Entity) when entity has Acc { entity.get_mut(Acc).out = -9223372036854775808 } ); defer pr.deinit(gpa); - var run = try tickOnAcc(gpa, &world, &pr); - defer run.interp.deinit(); + var interp: Interpreter = undefined; + const run = try tickOnAcc(gpa, &world, &pr, &interp); + defer interp.deinit(); try std.testing.expectEqual(@as(u64, 0), run.report.runtime_errors); try std.testing.expectEqual(std.math.minInt(i64), std.mem.readInt(i64, run.bytes[0..8], .little)); } @@ -17313,8 +17370,9 @@ test "a literal with a trailing separator evaluates" { \\rule r(entity: Entity) when entity has Acc { entity.get_mut(Acc).out = 1_000_ } ); defer pr.deinit(gpa); - var run = try tickOnAcc(gpa, &world, &pr); - defer run.interp.deinit(); + var interp: Interpreter = undefined; + const run = try tickOnAcc(gpa, &world, &pr, &interp); + defer interp.deinit(); try std.testing.expectEqual(@as(u64, 0), run.report.runtime_errors); try std.testing.expectEqual(@as(i64, 1000), std.mem.readInt(i64, run.bytes[0..8], .little)); } @@ -17331,8 +17389,9 @@ test "a narrowing cast panics or wraps by build mode" { \\} ); defer pr.deinit(gpa); - var run = try tickOnAcc(gpa, &world, &pr); - defer run.interp.deinit(); + var interp: Interpreter = undefined; + const run = try tickOnAcc(gpa, &world, &pr, &interp); + defer interp.deinit(); if (value_mod.overflow_wraps) { try std.testing.expectEqual(@as(i32, -1294967296), std.mem.readInt(i32, run.bytes[0..4], .little)); } else try expectRuntimeError(run.report, .IntegerOverflow); @@ -17350,8 +17409,9 @@ test "a float beyond the integer range fails its cast in every mode" { \\} ); defer pr.deinit(gpa); - var run = try tickOnAcc(gpa, &world, &pr); - defer run.interp.deinit(); + var interp: Interpreter = undefined; + const run = try tickOnAcc(gpa, &world, &pr, &interp); + defer interp.deinit(); try expectRuntimeError(run.report, .IntegerOverflow); } @@ -17367,8 +17427,9 @@ test "a cast to f32 rounds to f32" { \\} ); defer pr.deinit(gpa); - var run = try tickOnAcc(gpa, &world, &pr); - defer run.interp.deinit(); + var interp: Interpreter = undefined; + const run = try tickOnAcc(gpa, &world, &pr, &interp); + defer interp.deinit(); try std.testing.expectEqual(@as(u64, 0), run.report.runtime_errors); const want: f64 = @as(f32, 0.1); try std.testing.expectEqual(want, @as(f64, @bitCast(std.mem.readInt(u64, run.bytes[0..8], .little)))); @@ -17386,8 +17447,9 @@ test "a store outside an i32 field panics or wraps by build mode" { \\} ); defer pr.deinit(gpa); - var run = try tickOnAcc(gpa, &world, &pr); - defer run.interp.deinit(); + var interp: Interpreter = undefined; + const run = try tickOnAcc(gpa, &world, &pr, &interp); + defer interp.deinit(); if (value_mod.overflow_wraps) { try std.testing.expectEqual(@as(i32, -2), std.mem.readInt(i32, run.bytes[0..4], .little)); } else try expectRuntimeError(run.report, .IntegerOverflow); @@ -17406,8 +17468,9 @@ test "an inclusive range ending at the int maximum terminates" { \\} ); defer pr.deinit(gpa); - var run = try tickOnAcc(gpa, &world, &pr); - defer run.interp.deinit(); + var interp: Interpreter = undefined; + const run = try tickOnAcc(gpa, &world, &pr, &interp); + defer interp.deinit(); try std.testing.expectEqual(@as(u64, 0), run.report.runtime_errors); try std.testing.expectEqual(@as(i64, 2), std.mem.readInt(i64, run.bytes[0..8], .little)); } @@ -17428,8 +17491,9 @@ test "an omitted struct field takes the zero of its type" { \\} ); defer pr.deinit(gpa); - var run = try tickOnAcc(gpa, &world, &pr); - defer run.interp.deinit(); + var interp: Interpreter = undefined; + const run = try tickOnAcc(gpa, &world, &pr, &interp); + defer interp.deinit(); try std.testing.expectEqual(@as(u64, 0), run.report.runtime_errors); try std.testing.expectEqual(@as(f64, 1.5), @as(f64, @bitCast(std.mem.readInt(u64, run.bytes[0..8], .little)))); } @@ -17450,8 +17514,9 @@ test "an omitted struct field takes its string default" { \\} ); defer pr.deinit(gpa); - var run = try tickOnAcc(gpa, &world, &pr); - defer run.interp.deinit(); + var interp: Interpreter = undefined; + const run = try tickOnAcc(gpa, &world, &pr, &interp); + defer interp.deinit(); try std.testing.expectEqual(@as(u64, 0), run.report.runtime_errors); try std.testing.expectEqual(@as(i64, 2), std.mem.readInt(i64, run.bytes[0..8], .little)); } diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 712dde6a..cb5572e8 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2705, - else => 2707, + .windows => 2708, + else => 2710, }; } From 75b4f39bac7fa412bc19a80b3a2718090a24b509 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 27 Sep 2026 07:51:18 +0200 Subject: [PATCH 085/141] fix(etch): keep a deferred extension refusal from ending the tick flushPendingExtensions took the whole batch, then applied each op with try: the first op the loader refused dropped every later op of the batch and ended stepOnce before flushStructural, so the tick's structural changes waited a tick and runFor itself failed. The hook check refusals added this milestone make that path more frequent. Each op is now applied on its own. A refusal, or a hook failure, is a runtime error of the tick, counted as ExtensionOpFailed at the extension name of the call; the rest of the batch and the structural flush still run. Only an allocation failure ends the tick, as it does for a rule body. Red first: a batch whose first op is refused failed with ExtensionAlreadyActive. The allocation sweep, green on the old code, pins the new classification. Its first version crashed in DynamicQuery.maybeRescan, which panics on an allocation failure by design, and was narrowed to the call and the flush by a warm-up tick. Floor 2710 to 2712. Co-Authored-By: Claude Opus 5.5 --- src/etch/ecs_bridge.zig | 4 +- src/etch/interp.zig | 52 ++++++++++++------- src/etch/value.zig | 4 ++ tests/scene/extensions_test.zig | 89 +++++++++++++++++++++++++++++++++ tools/weld_lint/dead_tests.zig | 4 +- 5 files changed, 130 insertions(+), 23 deletions(-) diff --git a/src/etch/ecs_bridge.zig b/src/etch/ecs_bridge.zig index 2888e8e3..c41f0177 100644 --- a/src/etch/ecs_bridge.zig +++ b/src/etch/ecs_bridge.zig @@ -84,8 +84,8 @@ pub const Bridge = struct { /// optional runtime extension resolver (name → cooked `.prefab.bin` /// bytes). Borrowed, not owned — set when the interpreter is bound, used by - /// `entity.activate_extension` / `deactivate_extension`. Absent → those - /// methods fail with `error.MissingExtensionResolver`. + /// `entity.activate_extension` / `deactivate_extension`. Absent, those + /// methods fail the body that calls them. ext_resolver: ?ExtensionResolver = null, pub fn init() Bridge { diff --git a/src/etch/interp.zig b/src/etch/interp.zig index b7c8ee88..8028f1fb 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -213,6 +213,8 @@ const PendingExtension = struct { /// resolver's backing outlives the tick. bytes: []const u8, op: ExtOp, + /// The extension name at the call, where a refusal at the flush is reported. + span: SourceSpan, }; /// Resolved view of a `when` clause node. The pool of these is a LOCAL of @@ -1823,8 +1825,8 @@ pub const Interpreter = struct { /// give the interpreter a runtime extension resolver (name → cooked /// `.prefab.bin` bytes, the same interface the scene loader receives) so an /// Etch `entity.activate_extension("X")` / `deactivate_extension("X")` - /// resolves the extension at runtime. Absent → those methods fail with - /// `error.MissingExtensionResolver`. + /// resolves the extension at runtime. Absent, those methods fail the body + /// that calls them. pub fn setExtensionResolver(self: *Interpreter, resolver: scene_loader.ExtensionResolver) void { self.bridge.ext_resolver = resolver; } @@ -2228,7 +2230,7 @@ pub const Interpreter = struct { try self.flushPendingTags(world); // Apply deferred extension activate/deactivate at the same boundary // — same never-mid-walk discipline. - try self.flushPendingExtensions(world); + try self.flushPendingExtensions(world, report); // Apply deferred structural mutations (spawn/despawn/add/remove) last, so // any extension hook's structural change (enqueued just above) drains in // the same boundary, with observers firing per op. @@ -4271,18 +4273,29 @@ pub const Interpreter = struct { /// Tier-0 `on_attach`/`on_detach` seam via the loader's bytes-taking /// `activateExtension` / `deactivateExtension`. The batch is snapshotted /// (`toOwnedSlice`) so a hook fired during apply that enqueues more ops does - /// NOT drain recursively — new ops wait for the next flush. - fn flushPendingExtensions(self: *Interpreter, world: *World) !void { + /// NOT drain recursively — new ops wait for the next flush. An op the loader + /// refuses, or whose hook fails, is a runtime error of the tick and the rest + /// of the batch still applies; only an allocation failure ends the tick. + fn flushPendingExtensions(self: *Interpreter, world: *World, report: *RuntimeReport) !void { if (self.pending_extensions.items.len == 0) return; const batch = try self.pending_extensions.toOwnedSlice(self.gpa); defer { for (batch) |pe| self.gpa.free(pe.name); self.gpa.free(batch); } - for (batch) |pe| switch (pe.op) { - .activate => try scene_loader.activateExtension(world, self.gpa, pe.entity, pe.name, pe.bytes), - .deactivate => try scene_loader.deactivateExtension(world, self.gpa, pe.entity, pe.name, pe.bytes), - }; + for (batch) |pe| { + const applied = switch (pe.op) { + .activate => scene_loader.activateExtension(world, self.gpa, pe.entity, pe.name, pe.bytes), + .deactivate => scene_loader.deactivateExtension(world, self.gpa, pe.entity, pe.name, pe.bytes), + }; + applied catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => { + report.runtime_errors += 1; + report.last_error = .{ .kind = .ExtensionOpFailed, .span = pe.span }; + }, + }; + } } /// Resolve a `tag_path` operand node to its leaf bit via the global table, @@ -5215,12 +5228,14 @@ pub const Interpreter = struct { /// mutate an archetype mid-`iterateArchetype`. Missing resolver / unknown name /// surface as `RuntimeFailure` (the interp's failure channel). The name is /// dup'd (the AST / run-string source may not outlive the flush). - fn enqueueExtension(self: *Interpreter, entity: CoreEntityId, name: []const u8, op: ExtOp) StmtError!void { + fn enqueueExtension(self: *Interpreter, world: *World, locals: *Locals, entity: CoreEntityId, mc: ast_mod.MethodCall, op: ExtOp) StmtError!void { + const name = try self.extensionNameArg(world, locals, mc); + const span = self.ast.exprSpan(@bitCast(self.ast.extra.items[mc.args_start])); const resolver = self.bridge.ext_resolver orelse return error.RuntimeFailure; const bytes = resolver.resolve(name) orelse return error.RuntimeFailure; const name_dup = try self.gpa.dupe(u8, name); errdefer self.gpa.free(name_dup); - try self.pending_extensions.append(self.gpa, .{ .entity = entity, .name = name_dup, .bytes = bytes, .op = op }); + try self.pending_extensions.append(self.gpa, .{ .entity = entity, .name = name_dup, .bytes = bytes, .op = op, .span = span }); } /// the Tier-0 `CommandBuffer` that a body's structural @@ -5642,18 +5657,16 @@ pub const Interpreter = struct { const mname = self.ast.strings.slice(mc.method_name); // runtime extension API on an entity receiver. Checked // before the `impl Trait for Entity` lookup (these are builtin - // methods, not user traits). activate/deactivate route through the - // shared loader entries; a missing resolver / unknown extension / - // component conflict surfaces as the interp's `RuntimeFailure` - // (the loader path keeps the named `MissingExtensionResolver` etc.). + // methods, not user traits). activate/deactivate ENQUEUE, never an + // immediate structural mutation (we may be mid-iteration): a + // missing resolver or unknown extension fails the call, and a + // refusal at the tick boundary is a runtime error of that tick. if (std.mem.eql(u8, mname, "activate_extension")) { - // B1: ENQUEUE (deferred to the tick boundary) — never an - // immediate structural mutation here (we may be mid-iteration). - try self.enqueueExtension(@bitCast(eid), try self.extensionNameArg(world, locals, mc), .activate); + try self.enqueueExtension(world, locals, @bitCast(eid), mc, .activate); return Value{ .unit = {} }; } if (std.mem.eql(u8, mname, "deactivate_extension")) { - try self.enqueueExtension(@bitCast(eid), try self.extensionNameArg(world, locals, mc), .deactivate); + try self.enqueueExtension(world, locals, @bitCast(eid), mc, .deactivate); return Value{ .unit = {} }; } if (std.mem.eql(u8, mname, "has_extension")) { @@ -7027,6 +7040,7 @@ fn defaultFailureMessage(kind: RuntimeErrorKind) []const u8 { .UncaughtThrow => "uncaught throw", .AssertFailed => "assertion failed", .StaleComponentRef => "component ref outlived its entity", + .ExtensionOpFailed => "extension activation or deactivation failed", }; } diff --git a/src/etch/value.zig b/src/etch/value.zig index d2757239..3964abe6 100644 --- a/src/etch/value.zig +++ b/src/etch/value.zig @@ -291,6 +291,10 @@ pub const RuntimeErrorKind = enum { /// Its own kind rather than `UnsupportedExpr`, because §5.3 c requires a /// CLEAR message: the expression is supported and the handle is not. StaleComponentRef, + /// A deferred `activate_extension` / `deactivate_extension` the tick + /// boundary refused, or whose hook failed. The span covers the extension name + /// at the call. + ExtensionOpFailed, }; /// Whether integer overflow wraps rather than panics: `ReleaseFast` and diff --git a/tests/scene/extensions_test.zig b/tests/scene/extensions_test.zig index f665447d..d17db50d 100644 --- a/tests/scene/extensions_test.zig +++ b/tests/scene/extensions_test.zig @@ -924,6 +924,95 @@ test "multi-entity rule activate_extension defers without corrupting iteration" } } +test "a refused deferred activation leaves the rest of its tick applied" { + const gpa = std.testing.allocator; + const combat_bytes = try cookCombatModule(gpa); + defer gpa.free(combat_bytes); + + var world = World.init(); + defer world.deinit(gpa); + + const prog = + \\component Health { current: i32 = 100, max: i32 = 100 } + \\component Weapon { damage: i32 = 0 } + \\component Marker { v: i32 = 0 } + \\component Done { v: i32 = 0 } + \\rule again(entity: Entity) when entity has Weapon { + \\ entity.activate_extension("CombatModule") + \\} + \\rule first(entity: Entity) when entity has Health and not entity has Weapon { + \\ entity.activate_extension("CombatModule") + \\} + \\rule mark(entity: Entity) when entity has Marker { + \\ entity.add(Done { v: 1 }) + \\} + ; + var pr = try parser.parse(gpa, prog); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + try interp.bindToWorld(&world); + var res = OneResolver{ .name = "CombatModule", .bytes = combat_bytes }; + interp.setExtensionResolver(res.ext()); + + const active = try spawnHealth(&world, gpa, 100, 100); + try scene.loader.runtimeActivate(&world, gpa, active, "CombatModule", res.ext()); + const fresh = try spawnHealth(&world, gpa, 100, 100); + const marked = try world.spawnDynamic(gpa, &[_]ComponentId{world.componentId("Marker").?}); + + const report = try interp.runFor(&world, 1); + + try std.testing.expectEqual(@as(i32, 150), healthMax(&world, active)); + try std.testing.expectEqual(@as(i32, 150), healthMax(&world, fresh)); + try std.testing.expect(world.hasEntityExtension(fresh, "CombatModule")); + try std.testing.expect(world.componentBytes(marked, world.componentId("Done").?) != null); + try std.testing.expectEqual(@as(u64, 1), report.runtime_errors); + const failure = report.last_error orelse return error.TestExpectedTypedError; + try std.testing.expectEqualStrings("ExtensionOpFailed", @tagName(failure.kind)); + try std.testing.expectEqual(@as(u32, @intCast(std.mem.indexOf(u8, prog, "\"CombatModule\"").?)), failure.span.byte_start); +} + +test "an allocation failure in a deferred activation ends the tick" { + const gpa = std.testing.allocator; + const combat_bytes = try cookCombatModule(gpa); + defer gpa.free(combat_bytes); + + const prog = + \\component Health { current: i32 = 100, max: i32 = 100 } + \\component Weapon { damage: i32 = 0 } + \\rule first(entity: Entity) when entity has Health and not entity has Weapon { + \\ entity.activate_extension("CombatModule") + \\} + ; + var pr = try parser.parse(gpa, prog); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + + var tick_allocations: usize = 0; + while (true) : (tick_allocations += 1) { + var failing = std.testing.FailingAllocator.init(gpa, .{}); + const fa = failing.allocator(); + var world = World.init(); + defer world.deinit(fa); + var interp = try Interpreter.compile(fa, &pr.ast, &world); + defer interp.deinit(); + const fresh = try spawnHealth(&world, fa, 100, 100); + // Unresolved, the call fails and the rule's selection still takes in the + // entity's archetype, so the measured tick allocates only in the call and + // the flush: a selection rescan panics on an allocation failure. + _ = try interp.runFor(&world, 1); + var res = OneResolver{ .name = "CombatModule", .bytes = combat_bytes }; + interp.setExtensionResolver(res.ext()); + failing.fail_index = failing.alloc_index + tick_allocations; + if (interp.runFor(&world, 1)) |report| { + try std.testing.expectEqual(@as(u64, 0), report.runtime_errors); + try std.testing.expect(world.hasEntityExtension(fresh, "CombatModule")); + if (!failing.has_induced_failure) break; + } else |err| try std.testing.expectEqual(error.OutOfMemory, err); + } +} + test "on_attach-issued structural command is drained before on_spawned" { const gpa = std.testing.allocator; const combat_bytes = try cookCombatModule(gpa); diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index cb5572e8..7fc312b4 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2708, - else => 2710, + .windows => 2710, + else => 2712, }; } From 0dfd5d04bcf67e08348b840ddb9e3295a0269c80 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 27 Sep 2026 08:11:43 +0200 Subject: [PATCH 086/141] fix(etch): resolve a cook's imports as etch check resolves them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cook ignored every import: a scene or prefab importing its components, the form etch-grammar.md §21.2 prescribes, was refused with UndeclaredType where etch check accepted it, and an import check refuses was cooked without a word. validateProject's parse and indexes move to project.zig, shared by a project cook. The cooked file's imports are bound by the checker's own bindImports, and what it refuses is ImportRefused, as are an import cycle and a project file that does not parse. Each component imported by name is registered from the file that declares it, under its own name, with the requisites its module declares; an alias is spelled by that name at every lookup and in the cooked hook text, so the loader checks and runs the hook against the program's declaration. A name two declarations share is DuplicateType, runtime identity being the name. @requires names a component its own module declares, in the cooked file as in an imported one, as etch check requires. checkFieldOverride resolved local components only, so an instance override of an imported component failed etch check (E1783) while the instance literal passed; both now go through one lookup. The single-file cooks are one-file projects. scene_cook takes --module for the project's other files. Red first: with a cook that ignored the project, ten of the new tests failed as predicted and the two vacuous ones passed. Floor 2712 to 2728. Co-Authored-By: Claude Opus 5.5 --- build.zig | 3 + src/etch/parser.zig | 5 +- src/etch/project.zig | 294 ++++++++++++++++++++++ src/etch/root.zig | 298 +---------------------- src/etch/scene_cook.zig | 245 +++++++++++++++---- src/etch/types.zig | 182 ++++++++------ tests/scene/import_cook_test.zig | 405 +++++++++++++++++++++++++++++++ tools/scene_cook/main.zig | 37 ++- tools/weld_lint/dead_tests.zig | 4 +- 9 files changed, 1044 insertions(+), 429 deletions(-) create mode 100644 src/etch/project.zig create mode 100644 tests/scene/import_cook_test.zig diff --git a/build.zig b/build.zig index 2378513e..31ab3b8b 100644 --- a/build.zig +++ b/build.zig @@ -1048,6 +1048,9 @@ pub fn build(b: *std.Build) void { // descriptors, the cook and its binary tables, `applyExtensions` and the // `on_attach` dispatch at load. .{ .path = "tests/scene/extensions_test.zig", .scene = true, .dedicated_step = "test-extensions" }, + // A scene or prefab importing its components cooks as `etch check` + // resolves it, and a refused import refuses the cook. + .{ .path = "tests/scene/import_cook_test.zig", .scene = true, .dedicated_step = "test-import-cook" }, // capstone: prefab instances + per-field override + // cross-ref + active extension in one scene, cook → load → ECS. .{ .path = "tests/scene/prefab_integration_test.zig", .scene = true, .dedicated_step = "test-prefab-integration" }, diff --git a/src/etch/parser.zig b/src/etch/parser.zig index 89b884c6..cff9814b 100644 --- a/src/etch/parser.zig +++ b/src/etch/parser.zig @@ -113,9 +113,8 @@ pub fn parse(gpa: std.mem.Allocator, source: []const u8) !ParseResult { /// /// The mode is a parameter here rather than on `parse` for a measured reason: /// `parse` has about twenty call sites across `src/`, `tools/` and `tests/`, -/// and only two of them — `root.zig`'s `validateProject` and `scene_cook.zig` — -/// hold a filename at all. Threading a parameter through the other eighteen -/// would buy nothing. +/// and only `project.zig`'s `Project.init` holds a filename at all. Threading a +/// parameter through the others would buy nothing. pub fn parseWithMode(gpa: std.mem.Allocator, source: []const u8, mode: ParseMode) !ParseResult { var lexer = Lexer.init(source); // Without this `errdefer`, an OOM coming from `lexer.next` or diff --git a/src/etch/project.zig b/src/etch/project.zig new file mode 100644 index 00000000..ee1ee1c6 --- /dev/null +++ b/src/etch/project.zig @@ -0,0 +1,294 @@ +//! A set of Etch source files parsed together and indexed for cross-file +//! resolution: every file's arena, its module path, its exports table, the +//! project-wide prefab index, and an order in which to check the files. Built +//! once and shared by `validateProject` and the project cook, so the two resolve +//! an import through the same tables. + +const std = @import("std"); + +const ast = @import("ast.zig"); +const parser = @import("parser.zig"); +const types = @import("types.zig"); +const diagnostics_mod = @import("diagnostics.zig"); + +const Ast = ast.AstArena; +const NodeId = ast.NodeId; +const StringId = ast.StringId; +const SourceSpan = @import("token.zig").SourceSpan; +const TypeChecker = types.TypeChecker; +const Diagnostic = diagnostics_mod.Diagnostic; + +/// One source file of a multi-file Etch project. `name` is its path: the module +/// path is derived from it (`deriveModulePath`) and so is the parse mode +/// (`parser.modeForPath`). +pub const ProjectFile = struct { + name: []const u8, + source: []const u8, +}; + +/// The parsed files and their indexes. Owns every arena; the maps' keys point +/// into those arenas' string pools. +pub const Project = struct { + gpa: std.mem.Allocator, + /// One arena per file, in input order. + arenas: std.ArrayListUnmanaged(Ast) = .empty, + /// Per file, whether its parse reported a diagnostic. + parse_failed: []bool = &.{}, + module_paths: std.ArrayListUnmanaged([]u8) = .empty, + module_index: std.StringHashMapUnmanaged(usize) = .empty, + /// Every prefab name the project declares (E1786 / E1791). + prefabs: std.StringHashMapUnmanaged(void) = .empty, + /// The cross-scene UUID tracker (E1782), filled as the files are checked. + uuids: std.StringHashMapUnmanaged(void) = .empty, + exports: std.ArrayListUnmanaged(TypeChecker.ExportTable) = .empty, + /// Dependencies first; input order when the imports form a cycle, which has + /// no such order. + order: []usize = &.{}, + has_cycle: bool = false, + + /// Parse every file and build the indexes. Parse diagnostics and the E0108 + /// of an import cycle go to `diags_out` (caller-owned). + pub fn init(gpa: std.mem.Allocator, files: []const ProjectFile, diags_out: *std.ArrayListUnmanaged(Diagnostic)) !Project { + var self: Project = .{ .gpa = gpa }; + errdefer self.deinit(); + const n = files.len; + + self.parse_failed = try gpa.alloc(bool, n); + @memset(self.parse_failed, false); + try self.arenas.ensureTotalCapacity(gpa, n); + for (files, 0..) |f, idx| { + const pr = try parser.parseWithMode(gpa, f.source, parser.modeForPath(f.name)); + // Each parse diagnostic moves into `diags_out` (its message + // transfers), then only the vacated slice is freed — never + // `pr.deinit`, which would free the arena kept below. + self.parse_failed[idx] = pr.diagnostics.len > 0; + for (pr.diagnostics) |d| try diags_out.append(gpa, d); + gpa.free(pr.diagnostics); + self.arenas.appendAssumeCapacity(pr.ast); + } + + for (self.arenas.items) |*a| { + const kinds = a.items.items(.kind); + const datas = a.items.items(.data); + var i: usize = 0; + while (i < a.items.len) : (i += 1) { + if (kinds[i] != .prefab_decl) continue; + try self.prefabs.put(gpa, a.strings.slice(a.prefab_decls.items[datas[i]].name), {}); + } + } + + try self.module_paths.ensureTotalCapacity(gpa, n); + for (files, 0..) |f, idx| { + const mp = try deriveModulePath(gpa, f.name); + self.module_paths.appendAssumeCapacity(mp); + // A duplicate module path maps to the last file; the graph only + // needs a consistent node identity. + try self.module_index.put(gpa, mp, idx); + } + + try self.buildOrder(diags_out); + + try self.exports.ensureTotalCapacity(gpa, n); + for (self.arenas.items, 0..) |*a, idx| { + var table: TypeChecker.ExportTable = .empty; + errdefer table.deinit(gpa); + try buildExports(gpa, a, idx, &table); + self.exports.appendAssumeCapacity(table); + } + return self; + } + + pub fn deinit(self: *Project) void { + const gpa = self.gpa; + // The maps' keys point into the arenas' string pools: maps first. + for (self.exports.items) |*t| t.deinit(gpa); + self.exports.deinit(gpa); + self.uuids.deinit(gpa); + self.prefabs.deinit(gpa); + self.module_index.deinit(gpa); + for (self.module_paths.items) |p| gpa.free(p); + self.module_paths.deinit(gpa); + gpa.free(self.order); + gpa.free(self.parse_failed); + for (self.arenas.items) |*a| a.deinit(gpa); + self.arenas.deinit(gpa); + self.* = undefined; + } + + /// The context a file's check resolves cross-file references through. + pub fn context(self: *Project) TypeChecker.ProjectContext { + return .{ + .prefabs = &self.prefabs, + .uuids = &self.uuids, + .module_index = &self.module_index, + .exports = self.exports.items, + .arenas = self.arenas.items, + }; + } + + /// The import graph's dependency-first order, by iterative DFS: post-order + /// lists dependencies first, and a back edge to a node still on the stack + /// closes a cycle, reported as E0108 at the import that closes it. + fn buildOrder(self: *Project, diags_out: *std.ArrayListUnmanaged(Diagnostic)) !void { + const gpa = self.gpa; + const n = self.arenas.items.len; + + // Edge importer → imported, for each import whose target is a file of + // the set. A target that names no file is an import-resolution concern, + // 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 (self.arenas.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 target_path = try TypeChecker.importPath(gpa, a, a.import_decls.items[datas[i]]); + defer gpa.free(target_path); + if (self.module_index.get(target_path)) |v| { + try adj.items[u].append(gpa, .{ .to = v, .span = spans[i] }); + } + } + } + + // 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); + 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 => { + self.has_cycle = true; + const msg = try std.fmt.allocPrint( + gpa, + "import cycle detected: module '{s}' imports '{s}', which closes a cycle back to '{s}'", + .{ self.module_paths.items[frame.node], self.module_paths.items[edge.to], self.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 => {}, + } + } else { + colors[frame.node] = 2; + order.appendAssumeCapacity(frame.node); + _ = stack.pop(); + } + } + } + + self.order = try gpa.alloc(usize, n); + for (self.order, 0..) |*o, k| o.* = if (self.has_cycle) k else order.items[k]; + } +}; + +/// 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 take their basename +/// as the module label, and the reason they are not import *targets* differs by +/// extension: +/// - `.scene.etch` / `.prefab.etch` / `.layer.etch` / `.manifest.etch` declare +/// no top-level types (`etch-grammar.md` §21.2 bounds them to one scene or +/// prefab plus imports), so there is nothing to import FROM them. +/// - `.d.etch` declares nothing BUT top-level constructs (§20.4). It is not an +/// import target for the opposite reason: a `service` is resolved from the +/// compiler's global declaration table (`etch-abi-zig.md` §8.3), never +/// through the per-module export index, so it is never named in an `import`. +/// Either way the label only identifies the file as a node in the dependency +/// graph. +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; +} + +/// Build module `a`'s exports table: every top-level symbol-bearing declaration +/// (component / resource / struct / enum / trait / event / fn / type-alias / +/// const) keyed by its interned name's bytes → `{ kind, visibility, arena_index, +/// item_id }`. 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 }, + // Always public: a const cannot carry `private`. `test` blocks live + // in their own name space (`TypeChecker.test_symbols`) and are never + // exported. + .const_decl => .{ .name = a.const_decls.items[datas[i]].name, .kind = .const_ }, + else => null, + }; + if (nk) |e| { + // Last decl wins on a same-name dup (an intra-file dup is E0101 in + // pass 1); the table only needs a single resolvable entry. + const vis: TypeChecker.Visibility = switch (a.itemVisibility(item_id)) { + .public => .public, + .private => .private, + }; + try table.put(gpa, a.strings.slice(e.name), .{ + .kind = e.kind, + .visibility = vis, + .arena_index = arena_index, + .item_id = item_id, + }); + } + } +} diff --git a/src/etch/root.zig b/src/etch/root.zig index 5e43837c..715485e9 100644 --- a/src/etch/root.zig +++ b/src/etch/root.zig @@ -265,110 +265,11 @@ pub fn typeCheck(gpa: std.mem.Allocator, arena: *Ast, diags_out: *std.ArrayListU try TypeChecker.check(gpa, arena, diags_out); } -/// One source file of a multi-file Etch project, fed to `validateProject`. -/// `name` is the caller's label (path); the validator does not interpret it. -pub const ProjectFile = struct { - name: []const u8, - 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 take their basename -/// as the module label, and the reason they are not import *targets* differs by -/// extension — the single justification this comment used to give was true of -/// only one family: -/// - `.scene.etch` / `.prefab.etch` / `.layer.etch` / `.manifest.etch` declare -/// no top-level types (§21.3 bounds them to one scene/prefab plus imports), -/// so there is nothing to import FROM them. -/// - `.d.etch` declares nothing BUT top-level constructs (§20.4). It is not an -/// import target for the opposite reason: a `service` is resolved from the -/// compiler's global declaration table (`etch-abi-zig.md` §8.3), never -/// through the per-module export index, so it is never named in an `import`. -/// Either way the label only identifies the file as a node in the dependency -/// graph. -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. -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); -} - -/// Build module `a`'s public exports table: 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 }, - // a top-level `const` is exportable (always public — a - // const cannot carry a `private` prefix). `test` blocks are NOT - // listed: they live in a dedicated test-name namespace - // (`TypeChecker.test_symbols`) and are - // never exported. - .const_decl => .{ .name = a.const_decls.items[datas[i]].name, .kind = .const_ }, - else => null, - }; - if (nk) |e| { - // Last decl wins on a same-name dup (an intra-file dup is E0101 in - // pass 1); the exports table only needs a single resolvable entry. - // read the item's visibility: a `private` declaration_body - // is recorded `.private`, which makes the dormant `E0107` check in - // `bindImports` reachable when another module imports it. - const vis: TypeChecker.Visibility = switch (a.itemVisibility(item_id)) { - .public => .public, - .private => .private, - }; - try table.put(gpa, a.strings.slice(e.name), .{ - .kind = e.kind, - .visibility = vis, - .arena_index = arena_index, - .item_id = item_id, - }); - } - } -} +/// A set of source files parsed and indexed together (`validateProject`, the +/// project cook). +pub const project = @import("project.zig"); +/// One source file of a multi-file project. +pub const ProjectFile = project.ProjectFile; /// Cross-file scene/prefab validation. Parses every project file, /// builds the byte-keyed global prefab-name index and a shared cross-scene @@ -381,192 +282,19 @@ fn buildExports(gpa: std.mem.Allocator, a: *const Ast, arena_index: usize, table /// - `E1782 DuplicateUUID` (cross-scene) — the same entity/instance UUID in /// two scenes (same or different file). /// -/// Every file's parse + type-check diagnostics accumulate in `diags_out` -/// (caller-owned; each owns its `primary_message`). Deliberately BOUNDED — -/// enumerate files, index prefab names + scene UUIDs, resolve the three -/// references. No general dependency graph, no watch mode, no incremental -/// invalidation. +/// The files are checked dependencies first along the import graph, which +/// also resolves every `import` (E0103 / E0104 / E0107 / E0108). Every file's +/// parse + type-check diagnostics accumulate in `diags_out` (caller-owned; each +/// owns its `primary_message`). No watch mode, no incremental invalidation. pub fn validateProject( gpa: std.mem.Allocator, files: []const ProjectFile, diags_out: *std.ArrayListUnmanaged(Diagnostic), ) !void { - // Parse every file up front; the arenas stay alive for the whole pass so - // the byte-keyed indexes below can reference their interned strings. - var asts: std.ArrayListUnmanaged(Ast) = .empty; - defer { - for (asts.items) |*a| a.deinit(gpa); - asts.deinit(gpa); - } - try asts.ensureTotalCapacity(gpa, files.len); - for (files) |f| { - // `ProjectFile.name` is the ONLY place in the tree that holds both the - // path and the source, which is why the mode is threaded here and - // nowhere else (`parser.parse` takes no filename, and of its call sites - // only this one and `scene_cook.zig` know a path at all). - const pr = try parser.parseWithMode(gpa, f.source, parser.modeForPath(f.name)); - // Move each parse diagnostic into diags_out (its gpa-owned message - // transfers), then free only the now-vacated backing slice — never - // `pr.deinit`, which would double-free the arena we keep below. - for (pr.diagnostics) |d| try diags_out.append(gpa, d); - gpa.free(pr.diagnostics); - asts.appendAssumeCapacity(pr.ast); - } - - // Global byte-keyed prefab-name index (E1786 / E1791). Keys reference the - // arenas' string pools; the maps free before the arenas (LIFO defers). - var prefabs: std.StringHashMapUnmanaged(void) = .empty; - defer prefabs.deinit(gpa); - for (asts.items) |*a| { - const kinds = a.items.items(.kind); - const datas = a.items.items(.data); - var i: usize = 0; - while (i < a.items.len) : (i += 1) { - if (kinds[i] != .prefab_decl) continue; - const decl = a.prefab_decls.items[datas[i]]; - try prefabs.put(gpa, a.strings.slice(decl.name), {}); - } - } - - // Shared cross-scene UUID tracker (E1782): the first occurrence of a UUID - // is recorded, a later one is the duplicate. - var uuids: std.StringHashMapUnmanaged(void) = .empty; - defer uuids.deinit(gpa); - - // ── 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; the graph only needs a consistent node identity. - 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 import-resolution concern, 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(); - } - } - } - - // Per-module exports tables: 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; - // on a cycle, fall back to input order (the graph has no valid linearization). - 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]; - try TypeChecker.checkProject(gpa, &asts.items[idx], diags_out, &ctx); - } + var p = try project.Project.init(gpa, files, diags_out); + defer p.deinit(); + const ctx = p.context(); + for (p.order) |idx| try TypeChecker.checkProject(gpa, &p.arenas.items[idx], diags_out, &ctx); } test "public API builds + serializes a Level-B data descriptor" { diff --git a/src/etch/scene_cook.zig b/src/etch/scene_cook.zig index c7ee70f9..bd30c95f 100644 --- a/src/etch/scene_cook.zig +++ b/src/etch/scene_cook.zig @@ -9,13 +9,14 @@ //! job, and `World` appears nowhere below). //! //! Pipeline: -//! 1. Parse the source → AST. -//! 2. Register every `component`/`resource` declaration into a fresh -//! `Registry` via the shared `interp.compileTypeDecl` path, which takes a -//! bare `*Registry` so this cook needs no `World`. Unsupported field types -//! surface +//! 1. Parse the project's files → ASTs, and resolve the cooked file's +//! imports as `etch check` resolves them. +//! 2. Register every `component`/`resource` declaration, and every component +//! the file imports under its own name, into a fresh `Registry` via the +//! shared `interp.compileTypeDecl` path, which takes a bare `*Registry` so +//! this cook needs no `World`. Unsupported field types surface //! `error.InvalidProgram` as a clear cook diagnostic. -//! 3. Locate the single `scene`; reject `instance of`. +//! 3. Locate the single `scene` (or `prefab`). //! 4. For each entity component-instance field and each `resources` field: //! resolve against `Registry.findField`, const-eval the value, encode via //! `ecs_bridge.writeValueAsBytes`. Resource `string` values are interned @@ -40,6 +41,8 @@ const value_mod = @import("value.zig"); // `renderStmtRunAlloc` renders an extends prefab's on_attach/on_detach // statement-runs to canonical Etch text (stored in the .prefab.bin hooks section). const descriptor = @import("descriptor.zig"); +const lexer = @import("lexer.zig"); +const project_mod = @import("project.zig"); const weld_core = @import("weld_core"); // persistent heap moved to Tier 0 (`src/core/memory`); reach it via weld_core. @@ -56,6 +59,8 @@ const accessor = weld_core.scene.accessor; const validate = weld_core.scene.validate; const AstArena = ast_mod.AstArena; +const ProjectContext = types_mod.TypeChecker.ProjectContext; +const ExportEntry = types_mod.TypeChecker.ExportEntry; const StringId = ast_mod.StringId; const NodeId = ast_mod.NodeId; const Bridge = bridge_mod.Bridge; @@ -121,6 +126,9 @@ pub const CookError = error{ HookRenderFailed, /// An extension hook, or its `requires` clause, fails the type checker. HookRefused, + /// An `import` the type checker refuses: a module the project lacks, an + /// item its module does not export or keeps private, or an import cycle. + ImportRefused, /// `prefab "Y" of "X"` but the base `X.prefab.bin` could not be resolved /// (no resolver, or the resolver returned null for the base name). BasePrefabMissing, @@ -199,23 +207,63 @@ pub fn cookScene( base_resolver: ?BaseResolver, diag_out: ?*[]const u8, ) CookError!Cooked { - const parser = @import("parser.zig"); - var pr = parser.parse(gpa, source) catch return fail(diag_out, error.ParseFailed, "Etch parse failed (allocator error)"); - defer pr.deinit(gpa); - if (pr.diagnostics.len > 0) return fail(diag_out, error.ParseFailed, "Etch parse failed"); - const ast = &pr.ast; + const files = [_]ProjectFile{.{ .name = "cook.scene.etch", .source = source }}; + return cookSceneInProject(gpa, &files, 0, base_resolver, diag_out); +} + +/// One source file of a multi-file project. +pub const ProjectFile = project_mod.ProjectFile; + +/// Cook the `.scene.etch` at `index` in `files`, its imports resolved against +/// the other files as `etch check` resolves them. +pub fn cookSceneInProject(gpa: std.mem.Allocator, files: []const ProjectFile, index: usize, base_resolver: ?BaseResolver, diag_out: ?*[]const u8) CookError!Cooked { + return cookInProject(gpa, files, index, base_resolver, diag_out, .scene); +} + +/// Cook the `.prefab.etch` at `index` in `files`, its imports resolved against +/// the other files as `etch check` resolves them. +pub fn cookPrefabInProject(gpa: std.mem.Allocator, files: []const ProjectFile, index: usize, base_resolver: ?BaseResolver, diag_out: ?*[]const u8) CookError!Cooked { + return cookInProject(gpa, files, index, base_resolver, diag_out, .prefab); +} + +fn cookInProject( + gpa: std.mem.Allocator, + files: []const ProjectFile, + index: usize, + base_resolver: ?BaseResolver, + diag_out: ?*[]const u8, + comptime kind: enum { scene, prefab }, +) CookError!Cooked { + var parse_diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (parse_diags.items) |*d| d.deinit(gpa); + parse_diags.deinit(gpa); + } + var project = project_mod.Project.init(gpa, files, &parse_diags) catch |err| return switch (err) { + error.OutOfMemory => error.OutOfMemory, + error.ParseError => fail(diag_out, error.ParseFailed, "Etch parse failed"), + }; + defer project.deinit(); + if (std.mem.indexOfScalar(bool, project.parse_failed, true) != null) return fail(diag_out, error.ParseFailed, "Etch parse failed"); + if (project.has_cycle) return fail(diag_out, error.ImportRefused, "the project's imports form a cycle"); + const ctx = project.context(); + const ast = &project.arenas.items[index]; + try checkImports(gpa, ast, &ctx, diag_out); + if (kind == .prefab) try checkHooks(gpa, ast, &ctx, diag_out); var registry = Registry.init(); errdefer registry.deinit(gpa); - var b = Builder.init(gpa, ast, ®istry); + var b = Builder.init(gpa, ast, ®istry, &ctx, index); defer b.deinitScratch(); errdefer b.arena.deinit(); try b.registerDecls(diag_out); try b.finalizeDecls(diag_out); - const scene_decl = try b.findScene(diag_out); - const model = try b.build(scene_decl, base_resolver, diag_out); + const model = switch (kind) { + .scene => try b.build(try b.findScene(diag_out), base_resolver, diag_out), + .prefab => try b.buildPrefab(try b.findPrefab(diag_out), base_resolver, diag_out), + }; // `model` owns the cook arena BY VALUE — a copy of `b.arena` — so the two // share one arena. Adding `errdefer model.deinit()` double-frees it; the @@ -259,15 +307,28 @@ pub const BaseResolver = struct { } }; +/// Refuse an `import` the type checker refuses. +fn checkImports(gpa: std.mem.Allocator, ast: *AstArena, project: *const ProjectContext, diag_out: ?*[]const u8) CookError!void { + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + types_mod.TypeChecker.checkImports(gpa, ast, project, &diags) catch |err| return switch (err) { + error.OutOfMemory => error.OutOfMemory, + }; + if (diags.items.len > 0) return fail(diag_out, error.ImportRefused, "an import names a module the project lacks, or an item its module does not export or keeps private"); +} + /// Refuse an extension hook or `requires` clause the type checker refuses, so /// a cooked hook is one `etch check` accepts. -fn checkHooks(gpa: std.mem.Allocator, ast: *AstArena, diag_out: ?*[]const u8) CookError!void { +fn checkHooks(gpa: std.mem.Allocator, ast: *AstArena, project: *const ProjectContext, diag_out: ?*[]const u8) CookError!void { var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; defer { for (diags.items) |*d| d.deinit(gpa); diags.deinit(gpa); } - types_mod.TypeChecker.checkPrefabHooks(gpa, ast, &diags) catch |err| return switch (err) { + types_mod.TypeChecker.checkPrefabHooks(gpa, ast, project, &diags) catch |err| return switch (err) { error.OutOfMemory => error.OutOfMemory, }; if (diags.items.len > 0) return fail(diag_out, error.HookRefused, "an extension hook or its requires clause fails the type checker"); @@ -294,27 +355,8 @@ pub fn cookPrefab( base_resolver: ?BaseResolver, diag_out: ?*[]const u8, ) CookError!Cooked { - const parser = @import("parser.zig"); - var pr = parser.parse(gpa, source) catch return fail(diag_out, error.ParseFailed, "Etch parse failed (allocator error)"); - defer pr.deinit(gpa); - if (pr.diagnostics.len > 0) return fail(diag_out, error.ParseFailed, "Etch parse failed"); - const ast = &pr.ast; - try checkHooks(gpa, ast, diag_out); - - var registry = Registry.init(); - errdefer registry.deinit(gpa); - - var b = Builder.init(gpa, ast, ®istry); - defer b.deinitScratch(); - errdefer b.arena.deinit(); - - try b.registerDecls(diag_out); - try b.finalizeDecls(diag_out); - const prefab_decl = try b.findPrefab(diag_out); - const model = try b.buildPrefab(prefab_decl, base_resolver, diag_out); - - // Same shared-arena rule as `cookScene`: no `errdefer model.deinit()`. - return .{ .model = model, .registry = registry }; + const files = [_]ProjectFile{.{ .name = "cook.prefab.etch", .source = source }}; + return cookPrefabInProject(gpa, &files, 0, base_resolver, diag_out); } /// One in-progress entity, accumulated before archetype grouping. `comp_ids` is @@ -346,6 +388,14 @@ const Builder = struct { ast: *const AstArena, registry: *Registry, arena: std.heap.ArenaAllocator, + project: *const ProjectContext, + /// `ast`'s file in `project`. + file_index: usize, + /// Each alias of an imported component → the component's own name. Keys + /// and values point into the project's string pools. + aliases: std.StringHashMapUnmanaged([]const u8) = .empty, + /// Each imported component registered, by its own name → its declaration. + imported: std.StringHashMapUnmanaged(ImportedDecl) = .empty, // Scratch (gpa-owned, freed by `deinitScratch`). bridge: Bridge, @@ -372,12 +422,16 @@ const Builder = struct { prefab_id_table: std.ArrayListUnmanaged(u32) = .empty, prefab_id_map: std.AutoHashMapUnmanaged(u32, u32) = .empty, - fn init(gpa: std.mem.Allocator, ast: *const AstArena, registry: *Registry) Builder { + const ImportedDecl = struct { arena_index: usize, item_index: u32 }; + + fn init(gpa: std.mem.Allocator, ast: *const AstArena, registry: *Registry, project: *const ProjectContext, file_index: usize) Builder { return .{ .gpa = gpa, .ast = ast, .registry = registry, .arena = std.heap.ArenaAllocator.init(gpa), + .project = project, + .file_index = file_index, .bridge = Bridge.init(), }; } @@ -396,6 +450,15 @@ const Builder = struct { self.ext_entries.deinit(self.gpa); self.prefab_id_table.deinit(self.gpa); self.prefab_id_map.deinit(self.gpa); + self.aliases.deinit(self.gpa); + self.imported.deinit(self.gpa); + } + + /// The component a type name of this file names, by the component's own + /// name: an import alias spells the name it aliases. + fn nameOf(self: *const Builder, id: StringId) []const u8 { + const local = self.ast.strings.slice(id); + return self.aliases.get(local) orelse local; } fn a(self: *Builder) std.mem.Allocator { @@ -423,6 +486,7 @@ const Builder = struct { } fn registerDecls(self: *Builder, diag_out: ?*[]const u8) CookError!void { + try self.registerImports(diag_out); const kinds = self.ast.items.items(.kind); const datas = self.ast.items.items(.data); var i: usize = 0; @@ -430,6 +494,8 @@ const Builder = struct { switch (kinds[i]) { .component_decl => { const decl = self.ast.component_decls.items[datas[i]]; + const name = self.ast.strings.slice(decl.name); + if (self.imported.contains(name)) return fail(diag_out, error.DuplicateType, "a component the file declares shares its name with an imported one"); // The cook resolves the mode through the SAME resolver the // interpreter uses (`types.storageModeOf`). Its registry is // a throwaway used for layout and size, and the on-disk @@ -440,21 +506,80 @@ const Builder = struct { const req = types_mod.requiresNamesOf(self.gpa, self.ast, decl) catch return CookError.OutOfMemory; defer self.gpa.free(req); - _ = self.registerOne(self.ast.strings.slice(decl.name), decl.fields_start, decl.fields_len, .component, req, types_mod.storageModeOf(self.ast, decl), diag_out) catch |e| return e; + // `@requires` names a component its own file declares. + for (req) |r| if (self.imported.contains(r)) + return fail(diag_out, error.UndeclaredType, "`@requires` names a component this file imports rather than declares"); + _ = self.registerOne(self.ast, name, decl.fields_start, decl.fields_len, .component, req, types_mod.storageModeOf(self.ast, decl), diag_out) catch |e| return e; }, .resource_decl => { const decl = self.ast.resource_decls.items[datas[i]]; // `.table`: `@storage` is component-only, so a resource has // no mode to read. - _ = self.registerOne(self.ast.strings.slice(decl.name), decl.fields_start, decl.fields_len, .resource, &.{}, .table, diag_out) catch |e| return e; + _ = self.registerOne(self.ast, self.ast.strings.slice(decl.name), decl.fields_start, decl.fields_len, .resource, &.{}, .table, diag_out) catch |e| return e; }, else => {}, } } } + /// Register each component this file imports by name, under the + /// component's own name. + fn registerImports(self: *Builder, diag_out: ?*[]const u8) CookError!void { + const kinds = self.ast.items.items(.kind); + const datas = self.ast.items.items(.data); + var i: usize = 0; + while (i < self.ast.items.len) : (i += 1) { + if (kinds[i] != .import_decl) continue; + const decl = self.ast.import_decls.items[datas[i]]; + const path = types_mod.TypeChecker.importPath(self.gpa, self.ast, decl) catch return error.OutOfMemory; + defer self.gpa.free(path); + // `checkImports` refused an import that resolves to no module. + const target = self.project.module_index.get(path) orelse unreachable; + var j: u32 = 0; + while (j < decl.items_len) : (j += 1) { + const item = self.ast.import_items.items[decl.items_start + j]; + const local = self.ast.strings.slice(types_mod.TypeChecker.importLocalName(item)); + // A declaration of the file shadows an import of its name. + if (self.project.exports[self.file_index].contains(local)) continue; + const entry = types_mod.TypeChecker.importedExport(self.project, self.ast, target, item) orelse unreachable; + if (entry.kind != .component) continue; + const name = try self.registerImported(entry, diag_out); + if (item.alias != 0) try self.aliases.put(self.gpa, local, name); + } + } + } + + /// Register the component `entry` names, after the requisites its own + /// module declares, returning the component's name. + fn registerImported(self: *Builder, entry: ExportEntry, diag_out: ?*[]const u8) CookError![]const u8 { + const arena = &self.project.arenas[entry.arena_index]; + const decl = arena.component_decls.items[arena.itemData(entry.item_id)]; + const name = arena.strings.slice(decl.name); + const key: ImportedDecl = .{ .arena_index = entry.arena_index, .item_index = entry.item_id.index }; + if (self.imported.get(name)) |known| { + if (std.meta.eql(known, key)) return name; + return fail(diag_out, error.DuplicateType, "two imported components share one name"); + } + try self.imported.put(self.gpa, name, key); + const req = types_mod.requiresNamesOf(self.gpa, arena, decl) catch return error.OutOfMemory; + defer self.gpa.free(req); + for (req) |r| { + // `@requires` names a component its own module declares. + const requisite = self.project.exports[entry.arena_index].get(r) orelse + return fail(diag_out, error.UndeclaredType, "`@requires` names a component its module does not declare"); + switch (requisite.kind) { + .component => _ = try self.registerImported(requisite, diag_out), + .resource => return fail(diag_out, error.RequisiteIsResource, "`@requires` names a resource, which no entity carries"), + else => return fail(diag_out, error.UndeclaredType, "`@requires` names a component its module does not declare"), + } + } + _ = try self.registerOne(arena, name, decl.fields_start, decl.fields_len, .component, req, types_mod.storageModeOf(arena, decl), diag_out); + return name; + } + fn registerOne( self: *Builder, + arena: *const AstArena, name: []const u8, fields_start: u32, fields_len: u32, @@ -463,7 +588,7 @@ const Builder = struct { storage: weld_core.ecs.registry.StorageKind, diag_out: ?*[]const u8, ) CookError!ComponentId { - return interp.compileTypeDecl(self.gpa, self.ast, self.registry, &self.bridge, name, fields_start, fields_len, reg_kind, requires, storage) catch |e| switch (e) { + return interp.compileTypeDecl(self.gpa, arena, self.registry, &self.bridge, name, fields_start, fields_len, reg_kind, requires, storage) catch |e| switch (e) { error.InvalidProgram => fail(diag_out, error.UnsupportedFieldKind, "component/resource field has an unsupported type (only scalars, plus resource string/enum, are cookable)"), error.LayoutTooLarge => fail(diag_out, error.UnsupportedFieldKind, "component/resource declaration exceeds the registry's 64 KiB"), error.DuplicateComponent, error.SchemaChanged => fail(diag_out, error.DuplicateType, "component/resource type declared more than once"), @@ -788,7 +913,7 @@ const Builder = struct { const content_version = try self.versionFromNode(pd.version, diag_out); const hooks = if (pd.relation == .extends) try self.buildExtendsHooks(pd, base_resolver, diag_out) else &[_]format.HookSet{}; const requires = try self.a().alloc(u32, pd.requires_len); - for (requires, 0..) |*r, ri| r.* = try self.internString(self.ast.strings.slice(self.ast.prefab_requires.items[pd.requires_start + ri])); + for (requires, 0..) |*r, ri| r.* = try self.internString(self.nameOf(self.ast.prefab_requires.items[pd.requires_start + ri])); return .{ .strings = try self.a().dupe([]const u8, self.strings.items), @@ -825,7 +950,31 @@ const Builder = struct { else => return fail(diag_out, error.HookRenderFailed, "extension hook body could not be rendered to text"), }; defer self.gpa.free(text); - return self.internString(text); + const spelled = try self.withOwnNames(text); + defer self.gpa.free(spelled); + return self.internString(spelled); + } + + /// `text` with each import alias spelled by the name it aliases: the loader + /// checks and runs a hook against a program that declares the component + /// under that name. + fn withOwnNames(self: *Builder, text: []const u8) error{OutOfMemory}![]u8 { + var out: std.ArrayListUnmanaged(u8) = .empty; + errdefer out.deinit(self.gpa); + var lx = lexer.Lexer.init(text); + defer lx.deinit(self.gpa); + var copied: usize = 0; + while (true) { + const tok = try lx.next(self.gpa); + if (tok.kind == .eof) break; + if (tok.kind != .type_ident) continue; + const own = self.aliases.get(text[tok.span.byte_start..tok.span.byte_end]) orelse continue; + try out.appendSlice(self.gpa, text[copied..tok.span.byte_start]); + try out.appendSlice(self.gpa, own); + copied = tok.span.byte_end; + } + try out.appendSlice(self.gpa, text[copied..]); + return out.toOwnedSlice(self.gpa); } /// Validate an `extends` prefab's `requires C1, C2, …`: each required component @@ -839,7 +988,7 @@ const Builder = struct { const acc = try openResolvedPrefab(base_bytes, diag_out); var ri: u32 = 0; while (ri < pd.requires_len) : (ri += 1) { - const req = self.ast.strings.slice(self.ast.prefab_requires.items[pd.requires_start + ri]); + const req = self.nameOf(self.ast.prefab_requires.items[pd.requires_start + ri]); if (!baseHasComponent(acc, req)) return fail(diag_out, error.RequiresNotSatisfied, "`extends … requires` a component the base prefab does not declare"); } } @@ -942,7 +1091,7 @@ const Builder = struct { try blobs.appendSlice(self.gpa, eb.comp_blobs); for (instances) |ci| { - const type_name = self.ast.strings.slice(ci.type_name); + const type_name = self.nameOf(ci.type_name); const id = try self.entityComponentId(type_name, "variant entity references an undeclared component type", diag_out); if (indexOfId(ids.items, id)) |ci_idx| { // Prefab cook (`collect_crossrefs` false) → `source_uuid_idx` is @@ -1023,7 +1172,7 @@ const Builder = struct { var ids = try self.a().alloc(ComponentId, instances.len); var blobs = try self.a().alloc([]u8, instances.len); for (instances, 0..) |ci, k| { - const type_name = self.ast.strings.slice(ci.type_name); + const type_name = self.nameOf(ci.type_name); const id = try self.entityComponentId(type_name, "entity references an undeclared component type", diag_out); ids[k] = id; blobs[k] = try self.buildComponentBlob(id, ci, uuid_idx, diag_out); @@ -1076,7 +1225,7 @@ const Builder = struct { for (members) |m| switch (m.kind) { .component => { const ci = self.ast.component_instances.items[m.index]; - const id = try self.entityComponentId(self.ast.strings.slice(ci.type_name), "instance component references an undeclared component type", diag_out); + const id = try self.entityComponentId(self.nameOf(ci.type_name), "instance component references an undeclared component type", diag_out); if (indexOfId(ids.items, id)) |idx| { blobs.items[idx] = try self.mergeComponentBlob(blobs.items[idx], id, ci, uuid_idx, diag_out); } else { @@ -1086,7 +1235,7 @@ const Builder = struct { }, .field_override => { const fo = self.ast.field_overrides.items[m.index]; - const id = self.registry.idOf(self.ast.strings.slice(fo.type_name)) orelse return fail(diag_out, error.UndeclaredType, "instance field override references an undeclared component type"); + const id = self.registry.idOf(self.nameOf(fo.type_name)) orelse return fail(diag_out, error.UndeclaredType, "instance field override references an undeclared component type"); const idx = indexOfId(ids.items, id) orelse return fail(diag_out, error.OverrideTargetMissing, "per-field override targets a component the instance does not carry"); const fname = self.ast.strings.slice(fo.field); const fd = self.registry.findField(id, fname) orelse return fail(diag_out, error.UnknownField, "per-field override sets a field the component does not declare"); diff --git a/src/etch/types.zig b/src/etch/types.zig index 648f565d..9aa363eb 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -827,11 +827,25 @@ pub const TypeChecker = struct { try self.validateSceneDecls(); } + /// The import half of `check`, for the cook: every `import` of `arena` is + /// resolved against `project`, and what `check` refuses is reported into + /// `diagnostics`. + pub fn checkImports(gpa: std.mem.Allocator, arena: *AstArena, project: *const ProjectContext, diagnostics: *std.ArrayListUnmanaged(Diagnostic)) !void { + var tc: TypeChecker = .{ + .gpa = gpa, + .arena = arena, + .diagnostics = diagnostics, + .project = project, + }; + defer tc.deinit(); + try tc.bindImports(); + } + /// The prefab half of `check`, for the cook: the declarations are collected /// by `check`'s own passes, and only `requires` names and hook bodies are /// reported into `diagnostics`. A cook source reaches its base prefab through /// a resolver, so the rest of `check` (E1791 first) does not apply to it. - pub fn checkPrefabHooks(gpa: std.mem.Allocator, arena: *AstArena, diagnostics: *std.ArrayListUnmanaged(Diagnostic)) !void { + pub fn checkPrefabHooks(gpa: std.mem.Allocator, arena: *AstArena, project: ?*const ProjectContext, diagnostics: *std.ArrayListUnmanaged(Diagnostic)) !void { try arena.ensureErrorBuiltins(gpa); var scratch: std.ArrayListUnmanaged(Diagnostic) = .empty; defer { @@ -842,7 +856,7 @@ pub const TypeChecker = struct { .gpa = gpa, .arena = arena, .diagnostics = &scratch, - .project = null, + .project = project, }; defer tc.deinit(); try tc.runDeclarationPasses(); @@ -2104,40 +2118,44 @@ 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. - /// - /// 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`. + /// if the type is not a declared component, then per-field checks. An + /// imported component's fields are read from the arena that declares it. fn checkComponentInstance(self: *TypeChecker, ci: ast_mod.ComponentInstance, code_type: DiagnosticCode, code_field: DiagnosticCode, code_field_type: DiagnosticCode) !void { const owner = self.arena.strings.slice(ci.type_name); - // 1. Local component (the single-file 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). - 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; - } + const component = self.componentNamed(ci.type_name) orelse { + try self.emit(code_type, .error_, ci.span, "'{s}' is not a declared component", .{owner}); + return; + }; + var f: u32 = 0; + while (f < ci.fields_len) : (f += 1) { + try self.checkComponentField(component, owner, self.arena.struct_lit_fields.items[ci.fields_start + f], code_field, code_field_type); + } + } + + /// A component declaration and the arena it lives in. + const ComponentRef = struct { arena: *const AstArena, decl: ast_mod.ComponentDecl }; + + /// The component `name` names: a local symbol shadows an import, and a + /// symbol that is no component names none. + fn componentNamed(self: *TypeChecker, name: StringId) ?ComponentRef { + if (self.symbols.get(name)) |sym| { + if (sym.kind != .component) return null; + return .{ .arena = self.arena, .decl = self.arena.component_decls.items[self.arena.itemData(sym.item_id)] }; + } + const entry = self.imported_symbols.get(name) orelse return null; + if (entry.kind != .component) return null; + const decl_arena = &self.project.?.arenas[entry.arena_index]; + return .{ .arena = decl_arena, .decl = decl_arena.component_decls.items[decl_arena.itemData(entry.item_id)] }; + } + + /// One instance field against `component`'s declared fields, the imported + /// ones matched by name across arenas. + fn checkComponentField(self: *TypeChecker, component: ComponentRef, owner: []const u8, field: ast_mod.StructLitField, code_field: DiagnosticCode, code_field_type: DiagnosticCode) !void { + if (component.arena == self.arena) { + try self.checkInstanceField(owner, component.decl.fields_start, component.decl.fields_len, field, code_field, code_field_type); + } else { + try self.checkInstanceFieldForeign(component.arena, owner, component.decl.fields_start, component.decl.fields_len, field, code_field, code_field_type); } - try self.emit(code_type, .error_, ci.span, "'{s}' is not a declared component", .{owner}); } /// Cross-arena field check for an imported component instance. The @@ -2198,13 +2216,12 @@ pub const TypeChecker = struct { /// component type is unknown, E1784 if the field is absent, E1785 on a type /// mismatch. fn checkFieldOverride(self: *TypeChecker, fo: ast_mod.FieldOverride) !void { - const sym = self.symbols.get(fo.type_name); - if (sym == null or sym.?.kind != .component) { - try self.emit(.scene_component_type_unknown, .error_, fo.span, "'{s}' is not a declared component", .{self.arena.strings.slice(fo.type_name)}); + const owner = self.arena.strings.slice(fo.type_name); + const component = self.componentNamed(fo.type_name) orelse { + try self.emit(.scene_component_type_unknown, .error_, fo.span, "'{s}' is not a declared component", .{owner}); return; - } - const decl = self.arena.component_decls.items[self.arena.itemData(sym.?.item_id)]; - try self.checkInstanceField(self.arena.strings.slice(fo.type_name), decl.fields_start, decl.fields_len, .{ .name = fo.field, .value = fo.value }, .scene_component_field_unknown, .scene_component_field_type_invalid); + }; + try self.checkComponentField(component, owner, .{ .name = fo.field, .value = fo.value }, .scene_component_field_unknown, .scene_component_field_type_invalid); } /// E1782 helper — record `uuid_id` as seen, returning whether it was @@ -2469,12 +2486,8 @@ pub const TypeChecker = struct { try self.checkPrefabRequiresAndHooks(decl); } - /// Whether `name` is a declared component, looked up as - /// `checkComponentInstance` does: a local symbol shadows an import. fn isComponentName(self: *TypeChecker, name: StringId) bool { - if (self.symbols.get(name)) |sym| return sym.kind == .component; - if (self.imported_symbols.get(name)) |entry| return entry.kind == .component; - return false; + return self.componentNamed(name) != null; } /// E1793 on a `requires` name that is no component, then each hook body. @@ -3775,17 +3788,16 @@ pub const TypeChecker = struct { } } - /// Bind this file's `import` directives against the project exports index. 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`; 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 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 runs in pass 2. + /// Bind this file's `import` directives against the project exports index. + /// For each `ImportDecl`: + /// - a module path that names no project file is `E0103 NotAModule`; + /// - selective `{ X }`: X absent from the target's exports is + /// `E0104 UnknownExport`, private is `E0107`, else it is registered in + /// `imported_symbols` under its local name; + /// - module alias `as m` / bare `import a.b`: the alias → target binding + /// goes to `imported_aliases`. + /// No-op in single-file mode (`project == null`). This pass only records the + /// bindings and emits the import diagnostics; pass 2 applies them. fn bindImports(self: *TypeChecker) !void { const project = self.project orelse return; const kinds = self.arena.items.items(.kind); @@ -3797,16 +3809,10 @@ pub const TypeChecker = struct { 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}); + const path = try importPath(self.gpa, self.arena, decl); + defer self.gpa.free(path); + const target_idx = project.module_index.get(path) orelse { + try self.emit(.not_a_module, .error_, span, "import path '{s}' does not name a module in the project", .{path}); continue; }; @@ -3822,26 +3828,47 @@ pub const TypeChecker = struct { } // 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 }); + const entry = importedExport(project, self.arena, target_idx, item) orelse { + try self.emit(.unknown_export, .error_, span, "'{s}' is not exported by module '{s}'", .{ item_name, path }); continue; }; if (entry.visibility == .private) { - // Dormant until `private` graduates. - try self.emit(.import_private_item, .error_, span, "'{s}' is private to module '{s}'", .{ item_name, path_buf.items }); + try self.emit(.import_private_item, .error_, span, "'{s}' is private to module '{s}'", .{ item_name, path }); continue; } - const local = if (item.alias != 0) item.alias else item.name; - try self.imported_symbols.put(self.gpa, local, entry); + try self.imported_symbols.put(self.gpa, importLocalName(item), entry); } } } + /// The dotted module path `decl` names (`import a.b.c` → `"a.b.c"`), + /// `gpa`-owned. + pub fn importPath(gpa: std.mem.Allocator, arena: *const AstArena, decl: ast_mod.ImportDecl) ![]u8 { + var buf: std.ArrayListUnmanaged(u8) = .empty; + errdefer buf.deinit(gpa); + var s: u32 = 0; + while (s < decl.path_len) : (s += 1) { + if (s != 0) try buf.append(gpa, '.'); + try buf.appendSlice(gpa, arena.strings.slice(arena.import_path_segs.items[decl.path_start + s])); + } + return try buf.toOwnedSlice(gpa); + } + + /// The name `item` binds in the importing module: its alias, else its own. + pub fn importLocalName(item: ast_mod.ImportItem) StringId { + return if (item.alias != 0) item.alias else item.name; + } + + /// What `item`, imported by `arena` from the module at `target`, names in + /// that module's exports, whatever its visibility. + pub fn importedExport(project: *const ProjectContext, arena: *const AstArena, target: usize, item: ast_mod.ImportItem) ?ExportEntry { + return project.exports[target].get(arena.strings.slice(item.name)); + } + 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) { @@ -5227,14 +5254,9 @@ pub const TypeChecker = struct { } } - /// The component declaration named by `sid`, or null when `sid` names no - /// local component. - /// - /// Through `symbols` + `component_decls`, which is the mechanism - /// `checkComponentInstance` already uses — a second lookup by byte - /// comparison would be a second answer to one question. Keyed by - /// `StringId` because the arena's pool INTERNS, so one name is one id and a - /// byte comparison would only re-derive that. + /// The component declaration named by `sid` among this file's own + /// declarations, or null: `@requires` names a component its module + /// declares, never one it imports. fn requisiteDecl(self: *TypeChecker, sid: ast_mod.StringId) ?ast_mod.ComponentDecl { const sym = self.symbols.get(sid) orelse return null; if (sym.kind != .component) return null; diff --git a/tests/scene/import_cook_test.zig b/tests/scene/import_cook_test.zig new file mode 100644 index 00000000..adf3770c --- /dev/null +++ b/tests/scene/import_cook_test.zig @@ -0,0 +1,405 @@ +//! A scene or prefab that imports its components cooks as `etch check` resolves +//! it: to the bytes of the same source declaring them, under their own names, +//! and a refused import refuses the cook. + +const std = @import("std"); +const weld_core = @import("weld_core"); +const weld_etch = @import("weld_etch"); + +const scene = weld_core.scene; +const scene_cook = weld_etch.scene_cook; +const World = weld_core.ecs.World; +const EntityId = weld_core.ecs.EntityId; +const ComponentId = weld_core.ecs.registry.ComponentId; +const Interpreter = weld_etch.Interpreter; +const ProjectFile = weld_etch.ProjectFile; + +const OneResolver = struct { + name: []const u8, + bytes: []const u8, + fn resolve(ctx: *anyopaque, name: []const u8) ?[]const u8 { + const self: *OneResolver = @ptrCast(@alignCast(ctx)); + return if (std.mem.eql(u8, name, self.name)) self.bytes else null; + } + fn base(self: *OneResolver) scene_cook.BaseResolver { + return .{ .ctx = self, .resolveFn = OneResolver.resolve }; + } + fn ext(self: *OneResolver) scene.loader.ExtensionResolver { + return .{ .ctx = self, .resolveFn = OneResolver.resolve }; + } +}; + +const combat = + \\component Health { current: i32 = 100, max: i32 = 100 } + \\component Weapon { damage: i32 = 0 } +; + +fn expectChecked(files: []const ProjectFile) !void { + const gpa = std.testing.allocator; + var diags: std.ArrayListUnmanaged(weld_etch.Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try weld_etch.validateProject(gpa, files, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); +} + +fn written(cooked: *scene_cook.Cooked) ![]u8 { + return scene.writer.write(std.testing.allocator, cooked.model, &cooked.registry); +} + +fn prefabBytes(files: []const ProjectFile, index: usize, base: ?scene_cook.BaseResolver) ![]u8 { + var cooked = try scene_cook.cookPrefabInProject(std.testing.allocator, files, index, base, null); + defer cooked.deinit(std.testing.allocator); + return written(&cooked); +} + +fn inlinePrefabBytes(source: []const u8, base: ?scene_cook.BaseResolver) ![]u8 { + var cooked = try scene_cook.cookPrefab(std.testing.allocator, source, base, null); + defer cooked.deinit(std.testing.allocator); + return written(&cooked); +} + +fn expectPrefabRefused(expected: scene_cook.CookError, files: []const ProjectFile, index: usize) !void { + try std.testing.expectError(expected, scene_cook.cookPrefabInProject(std.testing.allocator, files, index, null, null)); +} + +test "a prefab importing its components cooks as one declaring them" { + const gpa = std.testing.allocator; + const files = [_]ProjectFile{ + .{ .name = "src/combat.etch", .source = combat }, + .{ .name = "src/goblin.prefab.etch", .source = + \\import combat { Health, Weapon } + \\prefab "Goblin" { + \\ entity "root" { uuid: "00000000-0000-0000-0000-000000000001" Health { current: 5 } Weapon { damage: 3 } } + \\} + }, + }; + try expectChecked(&files); + const imported = try prefabBytes(&files, 1, null); + defer gpa.free(imported); + const declared = try inlinePrefabBytes(combat ++ + \\ + \\prefab "Goblin" { + \\ entity "root" { uuid: "00000000-0000-0000-0000-000000000001" Health { current: 5 } Weapon { damage: 3 } } + \\} + , null); + defer gpa.free(declared); + try std.testing.expectEqualSlices(u8, declared, imported); +} + +test "a scene importing its components cooks as one declaring them" { + const gpa = std.testing.allocator; + const files = [_]ProjectFile{ + .{ .name = "src/combat.etch", .source = combat }, + .{ .name = "src/level.scene.etch", .source = + \\import combat { Health } + \\scene "Level" { + \\ entity "npc" { uuid: "00000000-0000-0000-0000-000000000002" Health { max: 40 } } + \\} + }, + }; + try expectChecked(&files); + var imported = try scene_cook.cookSceneInProject(gpa, &files, 1, null, null); + defer imported.deinit(gpa); + const imported_bytes = try written(&imported); + defer gpa.free(imported_bytes); + var declared = try scene_cook.cook(gpa, combat ++ + \\ + \\scene "Level" { + \\ entity "npc" { uuid: "00000000-0000-0000-0000-000000000002" Health { max: 40 } } + \\} + , null); + defer declared.deinit(gpa); + const declared_bytes = try written(&declared); + defer gpa.free(declared_bytes); + try std.testing.expectEqualSlices(u8, declared_bytes, imported_bytes); +} + +test "an aliased import cooks under the component's own name" { + const gpa = std.testing.allocator; + const files = [_]ProjectFile{ + .{ .name = "src/combat.etch", .source = combat }, + .{ .name = "src/goblin.prefab.etch", .source = + \\import combat { Health as HP } + \\prefab "Goblin" { + \\ entity "root" { uuid: "00000000-0000-0000-0000-000000000001" HP { current: 5 } } + \\} + }, + }; + try expectChecked(&files); + const imported = try prefabBytes(&files, 1, null); + defer gpa.free(imported); + const declared = try inlinePrefabBytes(combat ++ + \\ + \\prefab "Goblin" { + \\ entity "root" { uuid: "00000000-0000-0000-0000-000000000001" Health { current: 5 } } + \\} + , null); + defer gpa.free(declared); + try std.testing.expectEqualSlices(u8, declared, imported); +} + +test "a component imported with its requisites cooks as one declaring them" { + const gpa = std.testing.allocator; + const chain = + \\component Anchor { a: i32 = 0 } + \\@requires(Anchor) + \\component Transform { x: i32 = 0 } + \\@requires(Transform) + \\component Health { current: i32 = 100, max: i32 = 100 } + ; + const body = + \\prefab "Goblin" { + \\ entity "root" { uuid: "00000000-0000-0000-0000-000000000001" Health { current: 5 } } + \\} + ; + const files = [_]ProjectFile{ + .{ .name = "src/combat.etch", .source = chain }, + .{ .name = "src/goblin.prefab.etch", .source = "import combat { Health }\n" ++ body }, + }; + try expectChecked(&files); + const imported = try prefabBytes(&files, 1, null); + defer gpa.free(imported); + const declared = try inlinePrefabBytes(chain ++ "\n" ++ body, null); + defer gpa.free(declared); + try std.testing.expectEqualSlices(u8, declared, imported); +} + +test "a requisite its module imports refuses the cook, as it fails etch check" { + const gpa = std.testing.allocator; + const files = [_]ProjectFile{ + .{ .name = "src/core.etch", .source = "component Transform { x: i32 = 0 }" }, + .{ .name = "src/combat.etch", .source = + \\import core { Transform } + \\@requires(Transform) + \\component Health { current: i32 = 100, max: i32 = 100 } + }, + .{ .name = "src/goblin.prefab.etch", .source = + \\import combat { Health } + \\import core { Transform } + \\prefab "Goblin" { + \\ entity "root" { uuid: "00000000-0000-0000-0000-000000000001" Health { current: 5 } } + \\} + }, + }; + var diags: std.ArrayListUnmanaged(weld_etch.Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try weld_etch.validateProject(gpa, &files, &diags); + try std.testing.expectEqual(@as(usize, 1), diags.items.len); + try expectPrefabRefused(error.UndeclaredType, &files, 2); +} + +test "an aliased extension hook runs at load under the component's own name" { + const gpa = std.testing.allocator; + const base_bytes = try inlinePrefabBytes(combat ++ + \\ + \\prefab "BaseCharacter" { + \\ entity "root" { uuid: "00000000-0000-0000-0000-000000000003" Health { current: 100, max: 100 } } + \\} + , null); + defer gpa.free(base_bytes); + var base_res = OneResolver{ .name = "BaseCharacter", .bytes = base_bytes }; + const files = [_]ProjectFile{ + .{ .name = "src/combat.etch", .source = combat }, + .{ .name = "src/combat_module.prefab.etch", .source = + \\import combat { Health as HP, Weapon } + \\prefab "CombatModule" extends "BaseCharacter" requires HP { + \\ entity "mod" { uuid: "00000000-0000-0000-0000-000000000004" Weapon { damage: 25 } } + \\ on_attach { entity.get_mut(HP).max += 50 } + \\} + }, + }; + const module_bytes = try prefabBytes(&files, 1, base_res.base()); + defer gpa.free(module_bytes); + + var world = World.init(); + defer world.deinit(gpa); + var pr = try weld_etch.parser.parse(gpa, combat); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + try interp.bindToWorld(&world); + const health = world.componentId("Health").?; + var hv = [_]i32{ 100, 100 }; + const npc = try world.spawnDynamicWithValues(gpa, &[_]ComponentId{health}, &[_][]const u8{std.mem.asBytes(&hv)}); + var ext_res = OneResolver{ .name = "CombatModule", .bytes = module_bytes }; + try scene.loader.runtimeActivate(&world, gpa, npc, "CombatModule", ext_res.ext()); + const hb = world.componentBytes(npc, health).?; + try std.testing.expectEqual(@as(i32, 150), std.mem.readInt(i32, hb[4..8], .little)); +} + +test "an instance override of an imported component passes etch check and cooks" { + const gpa = std.testing.allocator; + const files = [_]ProjectFile{ + .{ .name = "src/combat.etch", .source = combat }, + .{ .name = "src/goblin.prefab.etch", .source = + \\import combat { Health } + \\prefab "Goblin" { + \\ entity "root" { uuid: "00000000-0000-0000-0000-000000000001" Health { current: 5 } } + \\} + }, + .{ .name = "src/level.scene.etch", .source = + \\import combat { Health } + \\scene "Level" { + \\ instance of "Goblin" "g1" { uuid: "00000000-0000-0000-0000-000000000005" Health.current = 3 } + \\} + }, + }; + try expectChecked(&files); + const goblin = try prefabBytes(&files, 1, null); + defer gpa.free(goblin); + var res = OneResolver{ .name = "Goblin", .bytes = goblin }; + var cooked = try scene_cook.cookSceneInProject(gpa, &files, 2, res.base(), null); + defer cooked.deinit(gpa); + const bytes = try written(&cooked); + defer gpa.free(bytes); + const acc = try scene.accessor.Accessor.open(bytes); + const arch = acc.archetype(0); + try std.testing.expectEqual(@as(i32, 3), std.mem.readInt(i32, arch.componentSlot(0, 0)[0..4], .little)); +} + +test "an import of a module the project lacks refuses the cook" { + const files = [_]ProjectFile{ + .{ .name = "src/goblin.prefab.etch", .source = + \\import ghost { Health } + \\component Weapon { damage: i32 = 0 } + \\prefab "Goblin" { + \\ entity "root" { uuid: "00000000-0000-0000-0000-000000000001" Weapon { damage: 3 } } + \\} + }, + }; + try expectPrefabRefused(error.ImportRefused, &files, 0); +} + +test "an import of an item its module does not export refuses the cook" { + const files = [_]ProjectFile{ + .{ .name = "src/combat.etch", .source = combat }, + .{ .name = "src/goblin.prefab.etch", .source = + \\import combat { Armor } + \\component Weapon { damage: i32 = 0 } + \\prefab "Goblin" { + \\ entity "root" { uuid: "00000000-0000-0000-0000-000000000001" Weapon { damage: 3 } } + \\} + }, + }; + try expectPrefabRefused(error.ImportRefused, &files, 1); +} + +test "an import of a private item refuses the cook" { + const files = [_]ProjectFile{ + .{ .name = "src/combat.etch", .source = "private component Secret { v: i32 = 0 }" }, + .{ .name = "src/goblin.prefab.etch", .source = + \\import combat { Secret } + \\component Weapon { damage: i32 = 0 } + \\prefab "Goblin" { + \\ entity "root" { uuid: "00000000-0000-0000-0000-000000000001" Weapon { damage: 3 } } + \\} + }, + }; + try expectPrefabRefused(error.ImportRefused, &files, 1); +} + +test "a local declaration shadows an import of its name, as in etch check" { + const gpa = std.testing.allocator; + const local = + \\component Health { hp: i32 = 7 } + \\prefab "Goblin" { + \\ entity "root" { uuid: "00000000-0000-0000-0000-000000000001" Health { hp: 3 } } + \\} + ; + const files = [_]ProjectFile{ + .{ .name = "src/combat.etch", .source = combat }, + .{ .name = "src/goblin.prefab.etch", .source = "import combat { Health }\n" ++ local }, + }; + try expectChecked(&files); + const imported = try prefabBytes(&files, 1, null); + defer gpa.free(imported); + const declared = try inlinePrefabBytes(local, null); + defer gpa.free(declared); + try std.testing.expectEqualSlices(u8, declared, imported); +} + +test "an alias naming a component the file also declares refuses the cook" { + const files = [_]ProjectFile{ + .{ .name = "src/combat.etch", .source = combat }, + .{ .name = "src/goblin.prefab.etch", .source = + \\import combat { Health as HP } + \\component Health { hp: i32 = 7 } + \\prefab "Goblin" { + \\ entity "root" { uuid: "00000000-0000-0000-0000-000000000001" HP { current: 5 } } + \\} + }, + }; + try expectPrefabRefused(error.DuplicateType, &files, 1); +} + +test "a requisite the file imports rather than declares refuses the cook, as it fails etch check" { + const gpa = std.testing.allocator; + const files = [_]ProjectFile{ + .{ .name = "src/combat.etch", .source = combat }, + .{ .name = "src/goblin.prefab.etch", .source = + \\import combat { Health } + \\@requires(Health) + \\component Tag { t: i32 = 0 } + \\prefab "Goblin" { + \\ entity "root" { uuid: "00000000-0000-0000-0000-000000000001" Tag { t: 1 } } + \\} + }, + }; + var diags: std.ArrayListUnmanaged(weld_etch.Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try weld_etch.validateProject(gpa, &files, &diags); + try std.testing.expectEqual(@as(usize, 1), diags.items.len); + try expectPrefabRefused(error.UndeclaredType, &files, 1); +} + +test "an import cycle in the project refuses the cook, as it fails etch check" { + const files = [_]ProjectFile{ + .{ .name = "src/a.etch", .source = "import b { Weapon }\ncomponent Health { current: i32 = 100 }" }, + .{ .name = "src/b.etch", .source = "import a { Health }\ncomponent Weapon { damage: i32 = 0 }" }, + .{ .name = "src/goblin.prefab.etch", .source = + \\import a { Health } + \\prefab "Goblin" { + \\ entity "root" { uuid: "00000000-0000-0000-0000-000000000001" Health { current: 5 } } + \\} + }, + }; + try expectPrefabRefused(error.ImportRefused, &files, 2); +} + +test "a project file that does not parse refuses the cook, as it fails etch check" { + const files = [_]ProjectFile{ + .{ .name = "src/combat.etch", .source = "component Health { current: i32 = 100 " }, + .{ .name = "src/goblin.prefab.etch", .source = + \\component Weapon { damage: i32 = 0 } + \\prefab "Goblin" { + \\ entity "root" { uuid: "00000000-0000-0000-0000-000000000001" Weapon { damage: 3 } } + \\} + }, + }; + try expectPrefabRefused(error.ParseFailed, &files, 1); +} + +test "two imported components under one name refuse the cook" { + const files = [_]ProjectFile{ + .{ .name = "src/a.etch", .source = "component Health { current: i32 = 100 }" }, + .{ .name = "src/b.etch", .source = "component Health { hp: i32 = 7, max: i32 = 7 }" }, + .{ .name = "src/goblin.prefab.etch", .source = + \\import a { Health } + \\import b { Health as Life } + \\prefab "Goblin" { + \\ entity "root" { uuid: "00000000-0000-0000-0000-000000000001" Life { hp: 5 } } + \\} + }, + }; + try expectChecked(&files); + try expectPrefabRefused(error.DuplicateType, &files, 2); +} diff --git a/tools/scene_cook/main.zig b/tools/scene_cook/main.zig index d61f2620..40e59cb2 100644 --- a/tools/scene_cook/main.zig +++ b/tools/scene_cook/main.zig @@ -1,10 +1,16 @@ //! `scene_cook` — thin CLI shim around the scene cook and the prefab cook. //! Parses args + does file I/O; all real work is -//! `weld_etch.scene_cook.{cook,cookPrefab}` + `weld_core.scene.writer.write` -//! in-process. Mirrors `tools/etch_cook` / `tools/asset_cook`. +//! `weld_etch.scene_cook.{cookSceneInProject,cookPrefabInProject}` + +//! `weld_core.scene.writer.write` in-process. Mirrors `tools/etch_cook` / +//! `tools/asset_cook`. //! -//! scene_cook --output -//! scene_cook --output [--prefab-dir ] +//! scene_cook --output [--prefab-dir ] [--module ]... +//! scene_cook --output [--prefab-dir ] [--module ]... +//! +//! Each `--module` file joins the input in one project, against which the +//! input's `import`s resolve as `etch check` resolves them. A module path comes +//! from the file path as given, a leading `src/` stripped, so paths are written +//! from the project root. //! //! The input kind is taken from its extension: `*.prefab.etch` → prefab cook, //! `*.scene.etch` → scene cook. A `prefab "Y" of "X"` variant resolves its base @@ -50,6 +56,7 @@ pub fn main(init: std.process.Init) !void { var output: ?[]const u8 = null; var input: ?[]const u8 = null; var prefab_dir: ?[]const u8 = null; + var modules: std.ArrayListUnmanaged([]const u8) = .empty; var i: usize = 1; while (i < args.len) : (i += 1) { const a = args[i]; @@ -61,6 +68,10 @@ pub fn main(init: std.process.Init) !void { i += 1; if (i >= args.len) return die(io, "missing path after --prefab-dir"); prefab_dir = args[i]; + } else if (std.mem.eql(u8, a, "--module")) { + i += 1; + if (i >= args.len) return die(io, "missing path after --module"); + try modules.append(init.arena.allocator(), args[i]); } else if (std.mem.startsWith(u8, a, "--")) { return die(io, "unknown flag"); } else { @@ -72,11 +83,15 @@ pub fn main(init: std.process.Init) !void { const in_path = input orelse return die(io, "missing "); const dir = std.Io.Dir.cwd(); - const source = readWholeFile(gpa, io, dir, in_path) catch |err| { - try printErr(io, "cannot read input: ", @errorName(err)); - return err; - }; - defer gpa.free(source); + const files = try init.arena.allocator().alloc(scene_cook.ProjectFile, 1 + modules.items.len); + for (files, 0..) |*f, k| { + const path = if (k == 0) in_path else modules.items[k - 1]; + const source = readWholeFile(init.arena.allocator(), io, dir, path) catch |err| { + try printErr(io, "cannot read input: ", @errorName(err)); + return err; + }; + f.* = .{ .name = path, .source = source }; + } const is_prefab = std.mem.endsWith(u8, in_path, ".prefab.etch"); @@ -94,12 +109,12 @@ pub fn main(init: std.process.Init) !void { var diag: []const u8 = ""; var cooked = blk: { if (is_prefab) { - break :blk scene_cook.cookPrefab(gpa, source, resolver.base(), &diag) catch |err| { + break :blk scene_cook.cookPrefabInProject(gpa, files, 0, resolver.base(), &diag) catch |err| { try printErr(io, "prefab cook failed: ", if (diag.len > 0) diag else @errorName(err)); return err; }; } else { - break :blk scene_cook.cookScene(gpa, source, resolver.base(), &diag) catch |err| { + break :blk scene_cook.cookSceneInProject(gpa, files, 0, resolver.base(), &diag) catch |err| { try printErr(io, "cook failed: ", if (diag.len > 0) diag else @errorName(err)); return err; }; diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 7fc312b4..7fd34d87 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2710, - else => 2712, + .windows => 2726, + else => 2728, }; } From 9410c18274acc7e52128479b7fb829f5a1556da7 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 27 Sep 2026 08:45:10 +0200 Subject: [PATCH 087/141] fix(etch): take the cook's names from etch check's own resolution An adversarial pass over the four fixes found the cook re-deriving what etch check resolves, and differing from it: - two imports binding one name: check keeps the last, the cook kept a stale alias, cooking the wrong component without an error, and skipped a rebinding to a non-component; - shadowing was read from the exports table, which lacks the builtin Error and several declaration kinds check's symbols carry; - a component registered only as another's requisite could be named by the file without being imported; - only component aliases were respelled in hook text. The checker now hands the cook a scope: for each name the file uses, the component it names and the export its import binds, a declaration of the file shadowing the import of its name, through the one lookup check itself uses. Registration, every lookup, and the respelled hook text read that scope. An alias spelled like a builtin type or resource refuses a hook's cook, the respelled text no longer telling the two apart. A refused op a cooked hook queued reported an offset into the hook text as if it were program source; its span is now empty. Three comments lost decision reasoning or a false justification. Red first, each at its predicted value, one premise corrected: the builtin-alias witness failed etch check on my own source (a ';', then the base prefab missing from the project) before it measured the cook. Floor 2728 to 2735. Co-Authored-By: Claude Opus 5.5 --- src/etch/interp.zig | 15 ++-- src/etch/parser.zig | 5 -- src/etch/project.zig | 3 +- src/etch/scene_cook.zig | 84 ++++++++++-------- src/etch/types.zig | 82 ++++++++++++++---- src/etch/value.zig | 2 +- tests/scene/extensions_test.zig | 42 +++++++++ tests/scene/import_cook_test.zig | 141 ++++++++++++++++++++++++++++++- tools/weld_lint/dead_tests.zig | 4 +- 9 files changed, 308 insertions(+), 70 deletions(-) diff --git a/src/etch/interp.zig b/src/etch/interp.zig index 8028f1fb..017e6780 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -213,7 +213,9 @@ const PendingExtension = struct { /// resolver's backing outlives the tick. bytes: []const u8, op: ExtOp, - /// The extension name at the call, where a refusal at the flush is reported. + /// The extension name at the call, where a refusal at the flush is + /// reported; empty for a call a cooked hook made, its text being no program + /// source. span: SourceSpan, }; @@ -1356,6 +1358,8 @@ pub const Interpreter = struct { /// The address `bindToWorld` registered into the world, which holds it as /// the hooks' and observers' context. Non-null once bound. bound_at: ?*const Interpreter = null, + /// Set while a cooked hook text runs: its spans index that text. + in_hook_text: bool = false, /// Non-null while an observer body runs: the registry's deferred /// command buffer. Structural mutations issued by the body (tag mutations) /// route here instead of `pending_tags`, so they apply at the NEXT flush — @@ -1713,10 +1717,6 @@ pub const Interpreter = struct { } pub fn runFor(self: *Interpreter, world: *World, ticks: u32) !RuntimeReport { - // Bound here and not in `compile`, which returns by value: the world - // keeps this address, so the interpreter must not move once it runs. A - // test that drives a Tier-0 flush directly calls `bindToWorld` itself - // before flushing. try self.bindToWorld(world); var report: RuntimeReport = .{}; var t: u32 = 0; @@ -2044,6 +2044,9 @@ pub const Interpreter = struct { const prev_deferred = self.observer_deferred; self.observer_deferred = &world.observer_registry.deferred.?; defer self.observer_deferred = prev_deferred; + const prev_in_hook = self.in_hook_text; + self.in_hook_text = true; + defer self.in_hook_text = prev_in_hook; self.control = .none; self.thrown = false; @@ -5230,7 +5233,7 @@ pub const Interpreter = struct { /// dup'd (the AST / run-string source may not outlive the flush). fn enqueueExtension(self: *Interpreter, world: *World, locals: *Locals, entity: CoreEntityId, mc: ast_mod.MethodCall, op: ExtOp) StmtError!void { const name = try self.extensionNameArg(world, locals, mc); - const span = self.ast.exprSpan(@bitCast(self.ast.extra.items[mc.args_start])); + const span: SourceSpan = if (self.in_hook_text) .{ .byte_start = 0, .byte_end = 0 } else self.ast.exprSpan(@bitCast(self.ast.extra.items[mc.args_start])); const resolver = self.bridge.ext_resolver orelse return error.RuntimeFailure; const bytes = resolver.resolve(name) orelse return error.RuntimeFailure; const name_dup = try self.gpa.dupe(u8, name); diff --git a/src/etch/parser.zig b/src/etch/parser.zig index cff9814b..dfc91179 100644 --- a/src/etch/parser.zig +++ b/src/etch/parser.zig @@ -110,11 +110,6 @@ pub fn parse(gpa: std.mem.Allocator, source: []const u8) !ParseResult { /// Callers that know the file's path pass `modeForPath(path)`; callers /// that hold only a buffer keep using `parse`, which is this function at /// `.standard`. -/// -/// The mode is a parameter here rather than on `parse` for a measured reason: -/// `parse` has about twenty call sites across `src/`, `tools/` and `tests/`, -/// and only `project.zig`'s `Project.init` holds a filename at all. Threading a -/// parameter through the others would buy nothing. pub fn parseWithMode(gpa: std.mem.Allocator, source: []const u8, mode: ParseMode) !ParseResult { var lexer = Lexer.init(source); // Without this `errdefer`, an OOM coming from `lexer.next` or diff --git a/src/etch/project.zig b/src/etch/project.zig index ee1ee1c6..36acae35 100644 --- a/src/etch/project.zig +++ b/src/etch/project.zig @@ -81,8 +81,7 @@ pub const Project = struct { for (files, 0..) |f, idx| { const mp = try deriveModulePath(gpa, f.name); self.module_paths.appendAssumeCapacity(mp); - // A duplicate module path maps to the last file; the graph only - // needs a consistent node identity. + // A duplicate module path maps to the last file. try self.module_index.put(gpa, mp, idx); } diff --git a/src/etch/scene_cook.zig b/src/etch/scene_cook.zig index bd30c95f..0a248cf1 100644 --- a/src/etch/scene_cook.zig +++ b/src/etch/scene_cook.zig @@ -61,6 +61,7 @@ const validate = weld_core.scene.validate; const AstArena = ast_mod.AstArena; const ProjectContext = types_mod.TypeChecker.ProjectContext; const ExportEntry = types_mod.TypeChecker.ExportEntry; +const CookScope = types_mod.TypeChecker.CookScope; const StringId = ast_mod.StringId; const NodeId = ast_mod.NodeId; const Bridge = bridge_mod.Bridge; @@ -248,13 +249,14 @@ fn cookInProject( if (project.has_cycle) return fail(diag_out, error.ImportRefused, "the project's imports form a cycle"); const ctx = project.context(); const ast = &project.arenas.items[index]; - try checkImports(gpa, ast, &ctx, diag_out); + var scope = try resolveScope(gpa, ast, &ctx, diag_out); + defer scope.deinit(gpa); if (kind == .prefab) try checkHooks(gpa, ast, &ctx, diag_out); var registry = Registry.init(); errdefer registry.deinit(gpa); - var b = Builder.init(gpa, ast, ®istry, &ctx, index); + var b = Builder.init(gpa, ast, ®istry, &ctx, &scope); defer b.deinitScratch(); errdefer b.arena.deinit(); @@ -307,17 +309,20 @@ pub const BaseResolver = struct { } }; -/// Refuse an `import` the type checker refuses. -fn checkImports(gpa: std.mem.Allocator, ast: *AstArena, project: *const ProjectContext, diag_out: ?*[]const u8) CookError!void { +/// The file's names as the type checker resolves them, refusing an `import` it +/// refuses. +fn resolveScope(gpa: std.mem.Allocator, ast: *AstArena, project: *const ProjectContext, diag_out: ?*[]const u8) CookError!CookScope { var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; defer { for (diags.items) |*d| d.deinit(gpa); diags.deinit(gpa); } - types_mod.TypeChecker.checkImports(gpa, ast, project, &diags) catch |err| return switch (err) { + var scope = types_mod.TypeChecker.cookScope(gpa, ast, project, &diags) catch |err| return switch (err) { error.OutOfMemory => error.OutOfMemory, }; + errdefer scope.deinit(gpa); if (diags.items.len > 0) return fail(diag_out, error.ImportRefused, "an import names a module the project lacks, or an item its module does not export or keeps private"); + return scope; } /// Refuse an extension hook or `requires` clause the type checker refuses, so @@ -389,10 +394,9 @@ const Builder = struct { registry: *Registry, arena: std.heap.ArenaAllocator, project: *const ProjectContext, - /// `ast`'s file in `project`. - file_index: usize, - /// Each alias of an imported component → the component's own name. Keys - /// and values point into the project's string pools. + scope: *const CookScope, + /// Each import alias → the name it aliases. Keys and values point into the + /// file's string pool. aliases: std.StringHashMapUnmanaged([]const u8) = .empty, /// Each imported component registered, by its own name → its declaration. imported: std.StringHashMapUnmanaged(ImportedDecl) = .empty, @@ -424,14 +428,14 @@ const Builder = struct { const ImportedDecl = struct { arena_index: usize, item_index: u32 }; - fn init(gpa: std.mem.Allocator, ast: *const AstArena, registry: *Registry, project: *const ProjectContext, file_index: usize) Builder { + fn init(gpa: std.mem.Allocator, ast: *const AstArena, registry: *Registry, project: *const ProjectContext, scope: *const CookScope) Builder { return .{ .gpa = gpa, .ast = ast, .registry = registry, .arena = std.heap.ArenaAllocator.init(gpa), .project = project, - .file_index = file_index, + .scope = scope, .bridge = Bridge.init(), }; } @@ -454,11 +458,14 @@ const Builder = struct { self.imported.deinit(self.gpa); } - /// The component a type name of this file names, by the component's own - /// name: an import alias spells the name it aliases. - fn nameOf(self: *const Builder, id: StringId) []const u8 { - const local = self.ast.strings.slice(id); - return self.aliases.get(local) orelse local; + /// The registry's name for what a type name of this file names: an imported + /// component's own name, else the name as written. Null for a component + /// registered only as another one's requisite, which the file cannot name. + fn nameOf(self: *const Builder, id: StringId) ?[]const u8 { + if (self.scope.components.get(id)) |c| return c.arena.strings.slice(c.decl.name); + const written = self.ast.strings.slice(id); + if (self.imported.contains(written)) return null; + return written; } fn a(self: *Builder) std.mem.Allocator { @@ -522,8 +529,8 @@ const Builder = struct { } } - /// Register each component this file imports by name, under the - /// component's own name. + /// Register, under its own name and in import order, each component the + /// file's imports bind, and record each alias the scope keeps. fn registerImports(self: *Builder, diag_out: ?*[]const u8) CookError!void { const kinds = self.ast.items.items(.kind); const datas = self.ast.items.items(.data); @@ -533,18 +540,16 @@ const Builder = struct { const decl = self.ast.import_decls.items[datas[i]]; const path = types_mod.TypeChecker.importPath(self.gpa, self.ast, decl) catch return error.OutOfMemory; defer self.gpa.free(path); - // `checkImports` refused an import that resolves to no module. - const target = self.project.module_index.get(path) orelse unreachable; + const target = self.project.module_index.get(path) orelse continue; var j: u32 = 0; while (j < decl.items_len) : (j += 1) { const item = self.ast.import_items.items[decl.items_start + j]; - const local = self.ast.strings.slice(types_mod.TypeChecker.importLocalName(item)); - // A declaration of the file shadows an import of its name. - if (self.project.exports[self.file_index].contains(local)) continue; - const entry = types_mod.TypeChecker.importedExport(self.project, self.ast, target, item) orelse unreachable; - if (entry.kind != .component) continue; - const name = try self.registerImported(entry, diag_out); - if (item.alias != 0) try self.aliases.put(self.gpa, local, name); + const bound = self.scope.imports.get(types_mod.TypeChecker.importLocalName(item)) orelse continue; + if (bound.kind == .component) _ = try self.registerImported(bound, diag_out); + if (item.alias == 0) continue; + // The item that binds its alias last is the one the scope kept. + const own = types_mod.TypeChecker.importedExport(self.project, self.ast, target, item) orelse continue; + if (std.meta.eql(own, bound)) try self.aliases.put(self.gpa, self.ast.strings.slice(item.alias), self.ast.strings.slice(item.name)); } } } @@ -913,7 +918,7 @@ const Builder = struct { const content_version = try self.versionFromNode(pd.version, diag_out); const hooks = if (pd.relation == .extends) try self.buildExtendsHooks(pd, base_resolver, diag_out) else &[_]format.HookSet{}; const requires = try self.a().alloc(u32, pd.requires_len); - for (requires, 0..) |*r, ri| r.* = try self.internString(self.nameOf(self.ast.prefab_requires.items[pd.requires_start + ri])); + for (requires, 0..) |*r, ri| r.* = try self.internString(self.nameOf(self.ast.prefab_requires.items[pd.requires_start + ri]) orelse return fail(diag_out, error.UndeclaredType, "`requires` names a component the file neither declares nor imports")); return .{ .strings = try self.a().dupe([]const u8, self.strings.items), @@ -950,15 +955,16 @@ const Builder = struct { else => return fail(diag_out, error.HookRenderFailed, "extension hook body could not be rendered to text"), }; defer self.gpa.free(text); - const spelled = try self.withOwnNames(text); + const spelled = try self.withOwnNames(text, diag_out); defer self.gpa.free(spelled); return self.internString(spelled); } /// `text` with each import alias spelled by the name it aliases: the loader - /// checks and runs a hook against a program that declares the component - /// under that name. - fn withOwnNames(self: *Builder, text: []const u8) error{OutOfMemory}![]u8 { + /// checks and runs a hook against a program that declares it under that + /// name. An alias spelled like a builtin type or resource refuses the cook, + /// the text no longer telling the two apart. + fn withOwnNames(self: *Builder, text: []const u8, diag_out: ?*[]const u8) CookError![]u8 { var out: std.ArrayListUnmanaged(u8) = .empty; errdefer out.deinit(self.gpa); var lx = lexer.Lexer.init(text); @@ -968,7 +974,10 @@ const Builder = struct { const tok = try lx.next(self.gpa); if (tok.kind == .eof) break; if (tok.kind != .type_ident) continue; - const own = self.aliases.get(text[tok.span.byte_start..tok.span.byte_end]) orelse continue; + const alias = text[tok.span.byte_start..tok.span.byte_end]; + const own = self.aliases.get(alias) orelse continue; + if (types_mod.BuiltinType.fromName(alias) != null or types_mod.builtinResourceByName(alias) != null) + return fail(diag_out, error.HookRenderFailed, "an import alias spelled like a builtin type or resource names the extension hook's component ambiguously"); try out.appendSlice(self.gpa, text[copied..tok.span.byte_start]); try out.appendSlice(self.gpa, own); copied = tok.span.byte_end; @@ -988,7 +997,7 @@ const Builder = struct { const acc = try openResolvedPrefab(base_bytes, diag_out); var ri: u32 = 0; while (ri < pd.requires_len) : (ri += 1) { - const req = self.nameOf(self.ast.prefab_requires.items[pd.requires_start + ri]); + const req = self.nameOf(self.ast.prefab_requires.items[pd.requires_start + ri]) orelse return fail(diag_out, error.UndeclaredType, "`requires` names a component the file neither declares nor imports"); if (!baseHasComponent(acc, req)) return fail(diag_out, error.RequiresNotSatisfied, "`extends … requires` a component the base prefab does not declare"); } } @@ -1235,7 +1244,8 @@ const Builder = struct { }, .field_override => { const fo = self.ast.field_overrides.items[m.index]; - const id = self.registry.idOf(self.nameOf(fo.type_name)) orelse return fail(diag_out, error.UndeclaredType, "instance field override references an undeclared component type"); + const name = self.nameOf(fo.type_name) orelse return fail(diag_out, error.UndeclaredType, "instance field override references an undeclared component type"); + const id = self.registry.idOf(name) orelse return fail(diag_out, error.UndeclaredType, "instance field override references an undeclared component type"); const idx = indexOfId(ids.items, id) orelse return fail(diag_out, error.OverrideTargetMissing, "per-field override targets a component the instance does not carry"); const fname = self.ast.strings.slice(fo.field); const fd = self.registry.findField(id, fname) orelse return fail(diag_out, error.UnknownField, "per-field override sets a field the component does not declare"); @@ -1289,8 +1299,8 @@ const Builder = struct { /// Resolve an instance's type name to an entity component id; a resource is /// refused, since the loader refuses it as a column. - fn entityComponentId(self: *Builder, name: []const u8, undeclared_msg: []const u8, diag_out: ?*[]const u8) CookError!ComponentId { - const id = self.registry.idOf(name) orelse return fail(diag_out, error.UndeclaredType, undeclared_msg); + fn entityComponentId(self: *Builder, name: ?[]const u8, undeclared_msg: []const u8, diag_out: ?*[]const u8) CookError!ComponentId { + const id = self.registry.idOf(name orelse return fail(diag_out, error.UndeclaredType, undeclared_msg)) orelse return fail(diag_out, error.UndeclaredType, undeclared_msg); if (self.registry.componentKind(id) == .resource) return fail(diag_out, error.ResourceAsComponent, "entity instance names a resource, which is no entity component"); return id; } diff --git a/src/etch/types.zig b/src/etch/types.zig index 9aa363eb..ee769c79 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -794,6 +794,13 @@ pub const TypeChecker = struct { /// imports, impls, tags, services and events a statement check reads, and /// the construct validators. Shared with `checkPrefabHooks`. fn runDeclarationPasses(self: *TypeChecker) !void { + try self.collectDeclarations(); + try self.bindImports(); + try self.validateDeclarations(); + } + + /// The passes before the imports bind: the file's own symbols. + fn collectDeclarations(self: *TypeChecker) !void { // E1901 runs FIRST: it decides whether the file is even allowed to // contain what it contains, and a `.d.etch` carrying a `rule` would // otherwise produce a cascade of resolution errors on a body that had @@ -804,7 +811,9 @@ pub const TypeChecker = struct { try self.collectServices(); try self.collectDeclaredEvents(); try self.pass1Collect(); - try self.bindImports(); + } + + fn validateDeclarations(self: *TypeChecker) !void { try self.validateTypeAliases(); try self.validateImpls(); try self.validateDataDecls(); @@ -827,18 +836,53 @@ pub const TypeChecker = struct { try self.validateSceneDecls(); } - /// The import half of `check`, for the cook: every `import` of `arena` is - /// resolved against `project`, and what `check` refuses is reported into - /// `diagnostics`. - pub fn checkImports(gpa: std.mem.Allocator, arena: *AstArena, project: *const ProjectContext, diagnostics: *std.ArrayListUnmanaged(Diagnostic)) !void { + /// How a file names its components and what its imports bind, as `check` + /// resolves them, for the cook. + pub const CookScope = struct { + /// Each name the file uses for a component → that component. + components: std.AutoHashMapUnmanaged(StringId, ComponentRef) = .empty, + /// Each name an import binds → the export it binds, a declaration of the + /// file shadowing the import of its name. + imports: std.AutoHashMapUnmanaged(StringId, ExportEntry) = .empty, + + pub fn deinit(self: *CookScope, gpa: std.mem.Allocator) void { + self.components.deinit(gpa); + self.imports.deinit(gpa); + } + }; + + /// `arena`'s scope resolved against `project`; what `check` refuses on an + /// `import` is reported into `diagnostics`. + pub fn cookScope(gpa: std.mem.Allocator, arena: *AstArena, project: *const ProjectContext, diagnostics: *std.ArrayListUnmanaged(Diagnostic)) !CookScope { + try arena.ensureErrorBuiltins(gpa); + var scratch: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (scratch.items) |*d| d.deinit(gpa); + scratch.deinit(gpa); + } var tc: TypeChecker = .{ .gpa = gpa, .arena = arena, - .diagnostics = diagnostics, + .diagnostics = &scratch, .project = project, }; defer tc.deinit(); + try tc.collectDeclarations(); + tc.diagnostics = diagnostics; try tc.bindImports(); + var scope: CookScope = .{}; + errdefer scope.deinit(gpa); + var locals = tc.symbols.keyIterator(); + while (locals.next()) |name| { + if (tc.componentNamed(name.*)) |c| try scope.components.put(gpa, name.*, c); + } + var bound = tc.imported_symbols.keyIterator(); + while (bound.next()) |name| { + const entry = tc.importedBinding(name.*) orelse continue; + try scope.imports.put(gpa, name.*, entry); + if (tc.componentNamed(name.*)) |c| try scope.components.put(gpa, name.*, c); + } + return scope; } /// The prefab half of `check`, for the cook: the declarations are collected @@ -2133,19 +2177,25 @@ pub const TypeChecker = struct { } /// A component declaration and the arena it lives in. - const ComponentRef = struct { arena: *const AstArena, decl: ast_mod.ComponentDecl }; + pub const ComponentRef = struct { arena: *const AstArena, decl: ast_mod.ComponentDecl }; + + /// The export an import binds `name` to, unless a declaration of the file + /// shadows it. + fn importedBinding(self: *TypeChecker, name: StringId) ?ExportEntry { + if (self.symbols.contains(name)) return null; + return self.imported_symbols.get(name); + } - /// The component `name` names: a local symbol shadows an import, and a - /// symbol that is no component names none. + /// The component `name` names; a symbol that is no component names none. fn componentNamed(self: *TypeChecker, name: StringId) ?ComponentRef { - if (self.symbols.get(name)) |sym| { - if (sym.kind != .component) return null; - return .{ .arena = self.arena, .decl = self.arena.component_decls.items[self.arena.itemData(sym.item_id)] }; + if (self.importedBinding(name)) |entry| { + if (entry.kind != .component) return null; + const decl_arena = &self.project.?.arenas[entry.arena_index]; + return .{ .arena = decl_arena, .decl = decl_arena.component_decls.items[decl_arena.itemData(entry.item_id)] }; } - const entry = self.imported_symbols.get(name) orelse return null; - if (entry.kind != .component) return null; - const decl_arena = &self.project.?.arenas[entry.arena_index]; - return .{ .arena = decl_arena, .decl = decl_arena.component_decls.items[decl_arena.itemData(entry.item_id)] }; + const sym = self.symbols.get(name) orelse return null; + if (sym.kind != .component) return null; + return .{ .arena = self.arena, .decl = self.arena.component_decls.items[self.arena.itemData(sym.item_id)] }; } /// One instance field against `component`'s declared fields, the imported diff --git a/src/etch/value.zig b/src/etch/value.zig index 3964abe6..ab26862b 100644 --- a/src/etch/value.zig +++ b/src/etch/value.zig @@ -293,7 +293,7 @@ pub const RuntimeErrorKind = enum { StaleComponentRef, /// A deferred `activate_extension` / `deactivate_extension` the tick /// boundary refused, or whose hook failed. The span covers the extension name - /// at the call. + /// at the call, and is empty when a cooked hook made the call. ExtensionOpFailed, }; diff --git a/tests/scene/extensions_test.zig b/tests/scene/extensions_test.zig index d17db50d..0466b761 100644 --- a/tests/scene/extensions_test.zig +++ b/tests/scene/extensions_test.zig @@ -973,6 +973,48 @@ test "a refused deferred activation leaves the rest of its tick applied" { try std.testing.expectEqual(@as(u32, @intCast(std.mem.indexOf(u8, prog, "\"CombatModule\"").?)), failure.span.byte_start); } +test "a refused op a cooked hook queued reports no program position" { + const gpa = std.testing.allocator; + var base = try scene_cook.cookPrefab(gpa, base_character, null, null); + defer base.deinit(gpa); + const base_bytes = try scene.writer.write(gpa, base.model, &base.registry); + defer gpa.free(base_bytes); + var base_res = OneResolver{ .name = "BaseCharacter", .bytes = base_bytes }; + var looped = try scene_cook.cookPrefab(gpa, + \\component Health { current: i32 = 100, max: i32 = 100 } + \\component Weapon { damage: i32 = 10 } + \\prefab "Loop" extends "BaseCharacter" requires Health { + \\ entity "mod" { uuid: "9c4f3a2b-1e7d-4a5c-b8e9-f4d2c3a1b5e6" Weapon { damage: 25 } } + \\ on_attach { entity.activate_extension("Loop") } + \\} + , base_res.base(), null); + defer looped.deinit(gpa); + const loop_bytes = try scene.writer.write(gpa, looped.model, &looped.registry); + defer gpa.free(loop_bytes); + + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser.parse(gpa, + \\component Health { current: i32 = 100, max: i32 = 100 } + \\component Weapon { damage: i32 = 0 } + ); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + try interp.bindToWorld(&world); + var res = OneResolver{ .name = "Loop", .bytes = loop_bytes }; + interp.setExtensionResolver(res.ext()); + const npc = try spawnHealth(&world, gpa, 100, 100); + try scene.loader.runtimeActivate(&world, gpa, npc, "Loop", res.ext()); + + const report = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 1), report.runtime_errors); + const failure = report.last_error orelse return error.TestExpectedTypedError; + try std.testing.expectEqualStrings("ExtensionOpFailed", @tagName(failure.kind)); + try std.testing.expectEqual(@as(u32, 0), failure.span.byte_start); + try std.testing.expectEqual(@as(u32, 0), failure.span.byte_end); +} + test "an allocation failure in a deferred activation ends the tick" { const gpa = std.testing.allocator; const combat_bytes = try cookCombatModule(gpa); diff --git a/tests/scene/import_cook_test.zig b/tests/scene/import_cook_test.zig index adf3770c..5c3d8eea 100644 --- a/tests/scene/import_cook_test.zig +++ b/tests/scene/import_cook_test.zig @@ -206,6 +206,12 @@ test "an aliased extension hook runs at load under the component's own name" { var base_res = OneResolver{ .name = "BaseCharacter", .bytes = base_bytes }; const files = [_]ProjectFile{ .{ .name = "src/combat.etch", .source = combat }, + .{ .name = "src/base.prefab.etch", .source = + \\import combat { Health } + \\prefab "BaseCharacter" { + \\ entity "root" { uuid: "00000000-0000-0000-0000-000000000003" Health { current: 100, max: 100 } } + \\} + }, .{ .name = "src/combat_module.prefab.etch", .source = \\import combat { Health as HP, Weapon } \\prefab "CombatModule" extends "BaseCharacter" requires HP { @@ -214,7 +220,8 @@ test "an aliased extension hook runs at load under the component's own name" { \\} }, }; - const module_bytes = try prefabBytes(&files, 1, base_res.base()); + try expectChecked(&files); + const module_bytes = try prefabBytes(&files, 2, base_res.base()); defer gpa.free(module_bytes); var world = World.init(); @@ -403,3 +410,135 @@ test "two imported components under one name refuse the cook" { try expectChecked(&files); try expectPrefabRefused(error.DuplicateType, &files, 2); } + +fn expectCheckRefused(files: []const ProjectFile) !void { + const gpa = std.testing.allocator; + var diags: std.ArrayListUnmanaged(weld_etch.Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try weld_etch.validateProject(gpa, files, &diags); + try std.testing.expect(diags.items.len > 0); +} + +test "the last import of a name binds it, as in etch check" { + const gpa = std.testing.allocator; + const body = + \\prefab "Goblin" { + \\ entity "root" { uuid: "00000000-0000-0000-0000-000000000001" Pos { x: 1 } } + \\} + ; + const files = [_]ProjectFile{ + .{ .name = "src/a.etch", .source = "component Pos { x: i32 = 0 }" }, + .{ .name = "src/b.etch", .source = "component Other { x: i32 = 0, y: i32 = 0 }" }, + .{ .name = "src/goblin.prefab.etch", .source = "import b { Other as Pos }\nimport a { Pos }\n" ++ body }, + }; + try expectChecked(&files); + const imported = try prefabBytes(&files, 2, null); + defer gpa.free(imported); + const declared = try inlinePrefabBytes("component Pos { x: i32 = 0 }\n" ++ body, null); + defer gpa.free(declared); + try std.testing.expectEqualSlices(u8, declared, imported); +} + +test "a name the last import binds to no component names none, as in etch check" { + const files = [_]ProjectFile{ + .{ .name = "src/a.etch", .source = "component Health { current: i32 = 100 }" }, + .{ .name = "src/b.etch", .source = "struct Stats { v: i32 = 0 }" }, + .{ .name = "src/goblin.prefab.etch", .source = + \\import a { Health } + \\import b { Stats as Health } + \\prefab "Goblin" { + \\ entity "root" { uuid: "00000000-0000-0000-0000-000000000001" Health { current: 5 } } + \\} + }, + }; + try expectCheckRefused(&files); + try expectPrefabRefused(error.UndeclaredType, &files, 2); +} + +test "two imports of one name bind the later, as in etch check" { + const gpa = std.testing.allocator; + const body = + \\prefab "Goblin" { + \\ entity "root" { uuid: "00000000-0000-0000-0000-000000000001" Health { hp: 5 } } + \\} + ; + const files = [_]ProjectFile{ + .{ .name = "src/a.etch", .source = "component Health { current: i32 = 100 }" }, + .{ .name = "src/b.etch", .source = "component Health { hp: i32 = 7, max: i32 = 7 }" }, + .{ .name = "src/goblin.prefab.etch", .source = "import a { Health }\nimport b { Health }\n" ++ body }, + }; + try expectChecked(&files); + const imported = try prefabBytes(&files, 2, null); + defer gpa.free(imported); + const declared = try inlinePrefabBytes("component Health { hp: i32 = 7, max: i32 = 7 }\n" ++ body, null); + defer gpa.free(declared); + try std.testing.expectEqualSlices(u8, declared, imported); +} + +test "a requisite reached only through an import is not the file's to name, as in etch check" { + const files = [_]ProjectFile{ + .{ .name = "src/combat.etch", .source = + \\component Transform { x: i32 = 0 } + \\@requires(Transform) + \\component Health { current: i32 = 100, max: i32 = 100 } + }, + .{ .name = "src/goblin.prefab.etch", .source = + \\import combat { Health } + \\prefab "Goblin" { + \\ entity "root" { uuid: "00000000-0000-0000-0000-000000000001" Health { current: 5 } Transform { x: 3 } } + \\} + }, + }; + try expectCheckRefused(&files); + try expectPrefabRefused(error.UndeclaredType, &files, 1); +} + +test "a builtin declaration shadows an import of its name, as in etch check" { + const files = [_]ProjectFile{ + .{ .name = "src/combat.etch", .source = combat }, + .{ .name = "src/goblin.prefab.etch", .source = + \\import combat { Health as Error } + \\prefab "Goblin" { + \\ entity "root" { uuid: "00000000-0000-0000-0000-000000000001" Error { current: 5 } } + \\} + }, + }; + try expectCheckRefused(&files); + try expectPrefabRefused(error.UndeclaredType, &files, 1); +} + +test "an alias spelling a builtin type name refuses a hook's cook" { + const gpa = std.testing.allocator; + const base_bytes = try inlinePrefabBytes(combat ++ + \\ + \\prefab "BaseCharacter" { + \\ entity "root" { uuid: "00000000-0000-0000-0000-000000000003" Health { current: 100, max: 100 } } + \\} + , null); + defer gpa.free(base_bytes); + var base_res = OneResolver{ .name = "BaseCharacter", .bytes = base_bytes }; + const files = [_]ProjectFile{ + .{ .name = "src/combat.etch", .source = combat }, + .{ .name = "src/base.prefab.etch", .source = + \\import combat { Health } + \\prefab "BaseCharacter" { + \\ entity "root" { uuid: "00000000-0000-0000-0000-000000000003" Health { current: 100, max: 100 } } + \\} + }, + .{ .name = "src/combat_module.prefab.etch", .source = + \\import combat { Health as Entity, Weapon } + \\prefab "CombatModule" extends "BaseCharacter" requires Entity { + \\ entity "mod" { uuid: "00000000-0000-0000-0000-000000000004" Weapon { damage: 25 } } + \\ on_attach { + \\ let e: Entity = entity + \\ e.get_mut(Entity).max += 50 + \\ } + \\} + }, + }; + try expectChecked(&files); + try std.testing.expectError(error.HookRenderFailed, scene_cook.cookPrefabInProject(gpa, &files, 2, base_res.base(), null)); +} diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 7fd34d87..44f4e708 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2726, - else => 2728, + .windows => 2733, + else => 2735, }; } From c1ac1790dc2f3742510807d0bae56031d03e1e1b Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 27 Sep 2026 08:58:45 +0200 Subject: [PATCH 088/141] test(scene): pin the alias a hook's text keeps to the last import The check that an alias is respelled only when its item is the one the scope kept had no witness: a counter-factual removing it left the suite green, since lookups read the scope and a stale alias reaches only the cooked hook text, which no test's hook exercised with a rebound name. The witness imports `Other as Pos`, then `Pos` itself, and a hook naming `Pos`: activated at load, the hook adds to `Pos`. Floor 2735 to 2736. Co-Authored-By: Claude Opus 5.5 --- tests/scene/import_cook_test.zig | 47 ++++++++++++++++++++++++++++++++ tools/weld_lint/dead_tests.zig | 4 +-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/tests/scene/import_cook_test.zig b/tests/scene/import_cook_test.zig index 5c3d8eea..d05a82a5 100644 --- a/tests/scene/import_cook_test.zig +++ b/tests/scene/import_cook_test.zig @@ -542,3 +542,50 @@ test "an alias spelling a builtin type name refuses a hook's cook" { try expectChecked(&files); try std.testing.expectError(error.HookRenderFailed, scene_cook.cookPrefabInProject(gpa, &files, 2, base_res.base(), null)); } + +test "a hook names what the last import of a name binds, as in etch check" { + const gpa = std.testing.allocator; + const a = "component Pos { x: i32 = 0 }\ncomponent Mark { m: i32 = 0 }"; + const base_bytes = try inlinePrefabBytes(a ++ + \\ + \\prefab "Base" { + \\ entity "root" { uuid: "00000000-0000-0000-0000-000000000003" Pos { x: 0 } } + \\} + , null); + defer gpa.free(base_bytes); + var base_res = OneResolver{ .name = "Base", .bytes = base_bytes }; + const files = [_]ProjectFile{ + .{ .name = "src/a.etch", .source = a }, + .{ .name = "src/b.etch", .source = "component Other { x: i32 = 0 }" }, + .{ .name = "src/base.prefab.etch", .source = + \\import a { Pos } + \\prefab "Base" { + \\ entity "root" { uuid: "00000000-0000-0000-0000-000000000003" Pos { x: 0 } } + \\} + }, + .{ .name = "src/mod.prefab.etch", .source = + \\import b { Other as Pos } + \\import a { Pos, Mark } + \\prefab "Mod" extends "Base" requires Pos { + \\ entity "m" { uuid: "00000000-0000-0000-0000-000000000004" Mark { m: 1 } } + \\ on_attach { entity.get_mut(Pos).x += 7 } + \\} + }, + }; + try expectChecked(&files); + const mod_bytes = try prefabBytes(&files, 3, base_res.base()); + defer gpa.free(mod_bytes); + + var world = World.init(); + defer world.deinit(gpa); + var pr = try weld_etch.parser.parse(gpa, a); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + try interp.bindToWorld(&world); + const pos = world.componentId("Pos").?; + const e = try world.spawnDynamic(gpa, &[_]ComponentId{pos}); + var ext_res = OneResolver{ .name = "Mod", .bytes = mod_bytes }; + try scene.loader.runtimeActivate(&world, gpa, e, "Mod", ext_res.ext()); + try std.testing.expectEqual(@as(i32, 7), std.mem.readInt(i32, world.componentBytes(e, pos).?[0..4], .little)); +} diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 44f4e708..7d31003d 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2733, - else => 2735, + .windows => 2734, + else => 2736, }; } From 2f736b62e8de741c55f0a9cfd9b058b139e0752e Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 27 Sep 2026 09:01:52 +0200 Subject: [PATCH 089/141] docs(brief): record the four points found during the milestone The annotation map, the moved interpreter, the deferred flush and the cook's imports, with their red runs and counter-factuals, the adversarial pass that refused the cook's first parity, and six neighbours found and left for arbitration. Co-Authored-By: Claude Opus 5.5 --- briefs/m1.d-phase-1-debt.md | 144 ++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index b2821d5a..25fc2310 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -6779,6 +6779,150 @@ carried the third. Floor 2672 → 2680 → 2694 → 2707 on macOS and 2670 → 2678 → 2692 → 2705 on windows, read from the suite at each step. +### S5/G9 duodecies — the four points found during the milestone + +Ruled at the load end's GO: the four defects this session had reported are closed +here, before the seven open points. Every witness was written after its prediction and +run red on the unfixed tree, unless stated; every counter-factual was predicted before +its run and run alone in a worktree. + +**Point 4, `AstArena.annotations`** (`ef673b5f`). Its readers measured first, as ruled: +none, and no writer either — the only access was its own `deinit`, the generic clone +walk copied it empty, and `git log -S` finds no reader or writer ever added since it +was declared in S3. Annotations live in `annot_pool`, reached through each +declaration's own range. The field goes, with `AnnotationSpan`, whose only use was the +map's value type; the compiler is the counter-proof, the full suite building without +it. **The corpus still describes the map**: `etch-resolver-types.md` §13.3 step 5 +stores the resolved annotation in `AnnotationMap[NodeId]`, §13.3.1 is built around "le +consommateur de `AnnotationMap`", and `etch-ast-ir.md:236` declares the field — the +spec's text is Guy's. + +**Point 1, the moved interpreter** (`9a492c6f`). `bindToWorld` hands the world the +interpreter's own address, the context of the three extension seams and of every +observer rule, and its idempotence flag travelled with a copy, so a bind on the copy +was a silent no-op. Swept at the code: `tickOnAcc` is the only site that moves a bound +interpreter. The flag became `bound_at`, the address bound, and `stepOnce` and a second +`bindToWorld` refuse from any other one with `InterpreterMovedAfterBind`; `tickOnAcc` +compiles into a caller-owned slot and binds there. Three runs in sequence, each +predicted: without the guard, the two moved-copy witnesses red, `found void`, and the +re-tick witness green — vacuous without a guard, as predicted; with the guard and the +old helper, the re-tick witness alone red, `InterpreterMovedAfterBind`; with both, green. +Counter-factuals: the `stepOnce` check removed reddens the tick witness alone; the bind's +silent return restored reddens the bind witness alone. + +**Point 2, the deferred flush** (`75b4f39b`). `flushPendingExtensions` took the batch +and applied each op with `try`: the first refusal dropped the rest of the batch and +ended `stepOnce` before `flushStructural`. Each op now applies on its own; a refusal, or +a hook failure, is a runtime error of the tick, counted as `ExtensionOpFailed` at the +extension name of the call, and the batch and the structural flush go on; only an +allocation failure ends the tick, as for a rule body. A deferred refusal inside a +`test` body's `tick(n)` is therefore counted into the throwaway report instead of +failing the test, which is `evalWorldTick`'s written rule for every rule-tick error; the +test's own assertions see its effect. The witness puts the refused op first in the +batch and a structural add in the same tick: red first, `ExtensionAlreadyActive` out +of `runFor`. **The allocation sweep's first version crashed, a prediction refuted**: +it failed an allocation inside `DynamicQuery.maybeRescan`, which panics on allocation +failure by design (`query.zig:588`); a warm-up tick without a resolver lets the +selection take in the archetype first, and the sweep measures the call and the flush +alone — green on the old code, as predicted, since every flush error propagated. +Counter-factuals: stopping the batch at the first refusal reddens `expected 150, found +100`; no payload, `TestExpectedTypedError`; an allocation failure counted as a refusal, +the sweep, `expected 0, found 1`; a zero span, `expected 235, found 0`. Two stale +documents went with it: the resolver's absence was said to fail with +`MissingExtensionResolver`, which only the loader names, and a component conflict was +said to surface at the call. + +**Point 3, the cook's imports** (`0dfd5d04`, `9410c182`). The cook ignored every +`import`: a scene or prefab importing its components — the form `etch-grammar.md` §21.2 +prescribes, a typed file importing its types and never declaring them — was refused +`UndeclaredType` where `etch check` accepted it, and an import check refuses cooked +without a word. `validateProject`'s parse and indexes moved to `project.zig`, shared by +a project cook; the single-file cooks are one-file projects, and `scene_cook` takes +`--module ` for the other files, smoke-run: with the module the `.prefab.bin` +carries `Health` once and the alias `HP` never, without it `ImportRefused`. What +`check` refuses on an import is `ImportRefused`, as are an import cycle and a project +file that does not parse. Each component the file's imports bind is registered from the +file declaring it, under its own name, with the requisites its own module declares — +**measured, not assumed: `requisiteDecl` admits only a component the module declares, +never one it imports**, so the cook does the same, in the cooked file as in an imported +one. An alias is spelled by the name it aliases at every lookup and in the cooked hook +text, so the loader checks and runs the hook against the program's declaration. Found +while wiring it: `checkFieldOverride` resolved local components only, so an instance +override of an imported component failed `etch check` with E1783 while its instance +literal passed; both go through one lookup now. + +Red first against a cook that ignored the project: ten of twelve witnesses red, each at +its predicted error, the two vacuous ones green, every pre-existing test green — the +move of `validateProject` changed no verdict. Three witnesses were added after that run +for branches it did not reach — the cycle, a project file that does not parse, two +imported components under one name — and their red is taken by counter-factual. Twelve +counter-factuals, each alone and each reddening exactly its predicted tests; one +**refuted in form**: with the import refusal disabled, the two tests reaching the +registration's `unreachable` crash and are counted, the runner isolating a crash per +test, where I had predicted the rest of that binary lost. + +**An adversarial pass then refused my own parity**, ten findings confirmed of +thirty-eight, and the cause was one: the cook re-derived what `check` resolves instead +of reading it. Two imports binding one name — `check` keeps the last, the cook kept a +stale alias and cooked the wrong component without an error; shadowing read from the +exports table, which lacks the builtin `Error` and several declaration kinds `check`'s +symbols carry; a component registered only as another's requisite nameable without an +import; only component aliases respelled. `check` now hands the cook a scope — for each +name the file uses, the component it names and the export its import binds, a +declaration of the file shadowing the import of its name — through the one lookup it +uses itself, and registration, every lookup and the respelled text read that scope. An +alias spelled like a builtin type or resource refuses a hook's cook, the respelled text +no longer telling the two apart. On point 2's side, a refused op a cooked hook queued +reported an offset into the hook text as program source; its span is now empty. Seven +witnesses red first, each at its predicted value, one after a correction of **my own +premise**: the builtin-alias witness first failed `etch check` on my source — a `;` +between statements, which only the cooked text uses, then E1791, the base prefab +missing from the project — before it measured the cook. Three comments lost decision +reasoning or a false justification. Counter-factuals, each alone: the requisite-only +name let through, the shadow test dropped from the lookup `check` shares — which +reddens both shadow witnesses at their `check` halves —, the builtin-alias refusal +removed, the hook-text flag never set, and each item registering its own export each +redden exactly their witnesses. **One was predicted red and stayed green**: an alias +recorded without asking whether its item is the one the scope kept. Lookups read the +scope, so a stale alias reaches only the cooked hook text, and no test's hook named a +rebound alias; the witness added for it (`c1ac1790`) reddens alone under +that mutation, `ExtensionHookRefused` at load. + +**Found beside the four points and not closed here — each a STOP for arbitration, never +a number.** Measured unless said: + +1. *An interpreter deinit'd or replaced while its world lives* keeps the world calling + into it: `Interpreter.run` binds then frees; a hot reload compiles a second + interpreter on the same world, whose bind overwrites the three seams but only + APPENDS its observers, so the old program's observer rules keep firing and dangle + once it is freed. Read, not triggered by any test. Closing it touches Tier 0 — the + observer registry has no unregister — and the written contract that `deinit` touches + nothing of the world. +2. *`flushStructural` loses its batch on one stale command*, the point-2 defect on the + structural side: two rules despawning one entity in a tick end `stepOnce` with + `StaleEntityHandle` and the tick's later `add` never applies — red, against a control + with one despawn. Whether a stale structural command is a silent no-op, as a stale + tag already is, or a counted runtime error is a semantics decision. +3. *An annotation on a `const` or a type alias is parsed and discarded*: `@phase(.update)` + on either gives zero diagnostics, against a control on a component that refuses it; + by reading, `import` and an inherent `impl` discard theirs too. +4. *A base prefab's columns must be declared or imported by the source that cooks an + `of` variant or an `instance of`*, which `etch check` does not require: a scene with no + import instancing an importing prefab passes `check` and fails `BaseSchemaMismatch`. + Pre-existing and independent of imports; read by the adversarial pass and on the + code, not run. +5. *Two cook-only refusals kept*, `check` accepting both: two components under one name + (an alias and a local declaration, or two modules), the runtime's identity being the + name; and an alias spelled like a builtin in a hook. +6. *E0858* (§21.2) is implemented nowhere: a typed file declaring types passes both + tools. + +The corpus still describes `AnnotationMap` (point 4). Floor 2707 → 2710 → 2712 → 2728 → +2735 → 2736 on macOS and 2705 → 2708 → 2710 → 2726 → 2733 → 2734 on windows, read from +the suite at each step. CI on `0dfd5d04`: `windows-2025 / Debug / f64` failed on +`win32_thread_safety_test`'s `Win32ThreadSafetyTimeout`, the class recorded twice before, +no test lost (2726 collected, the windows floor); the next push cancelled that run. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree From 7ca9ea518d4d51a238a2cc26cdd044ffbe73b5a1 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 27 Sep 2026 15:09:12 +0200 Subject: [PATCH 090/141] test(platform): drop the win32 stress test's wall-clock bound M1.D.29. The test bounded its workers by 30 s of real time and failed with Win32ThreadSafetyTimeout past it: twice on windows-2025 Debug f64 under compiler load, with no test lost and nothing wrong with the code. The bound only detected a hang. Measured on Zig 0.16: with --test-timeout, which every CI matrix cell passes, the runner names a hung test and runs the next one; without it a running test has no deadline at all. The test now joins its workers and asserts what it asserted beyond the bound: the class atom stable, the open count back to 0, under 5% of creates failed. Its three plain expects failed silently in a release build, which is how two ReleaseSafe failures could not be attributed; each now names itself on stderr. Cross-compiled for x86_64-windows in Debug and ReleaseSafe; a type error planted in the body fails that compile, so it analyses the test. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 5 +- tests/platform/win32_thread_safety_test.zig | 52 ++++++--------------- 2 files changed, 17 insertions(+), 40 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 484a1bd8..02a4d3c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,8 +125,9 @@ jobs: run: | set -euo pipefail s=$SECONDS - # --test-timeout raises Zig's 60 s response floor: on windows a compiler spawned beside a - # test can inherit its pipe and hold the end of stream until that compiler exits. + # --test-timeout gives each test a deadline, the only one: without it a hung test hangs + # the build. It also raises Zig's 60 s response floor: on windows a compiler spawned + # beside a test can inherit its pipe and hold the end of stream until that compiler exits. zig build test --summary all --test-timeout 180s -Doptimize=${{ matrix.mode }} -Dphysics_f64=${{ matrix.precision }} -Dcpu=${{ env.ZIG_CPU }} 2>&1 | tee test-out.txt rc=${PIPESTATUS[0]} if [ "$rc" -ne 0 ]; then exit "$rc"; fi diff --git a/tests/platform/win32_thread_safety_test.zig b/tests/platform/win32_thread_safety_test.zig index 7c662fcd..fa718a87 100644 --- a/tests/platform/win32_thread_safety_test.zig +++ b/tests/platform/win32_thread_safety_test.zig @@ -13,48 +13,39 @@ const weld = @import("weld_core"); const window_api = weld.platform.window; const NUM_THREADS: u32 = 8; -// The target is 1000 iterations per thread, 8000 windows in all. CI -// windows-2025 runners cannot create and destroy windows fast enough to reach -// that within a 5 s budget — the observed failure was exit code 3, the -// bail-on-timeout leaving worker threads running and tripping -// `std.testing.allocator`'s leak detection at exit — so it is 100 per thread, -// 800 windows, matching `wayland_thread_safety_test`'s cadence. The timeout is -// 30 s to absorb CI variance: the assertions still mean what they meant, and a -// real deadlock would never finish inside it. +// A deadlock is caught by the runner's per-test deadline (`--test-timeout` in +// CI), which this count must stay well inside on a loaded windows runner. const ITERATIONS_PER_THREAD: u32 = 100; -const TIMEOUT_MS: u64 = 30000; const Ctx = struct { iterations: u32, - done: std.atomic.Value(u32) = std.atomic.Value(u32).init(0), err_count: std.atomic.Value(u32) = std.atomic.Value(u32).init(0), gpa: std.mem.Allocator, }; +/// `ok`, or the failure named on stderr, which a release build otherwise +/// leaves empty. +fn check(ok: bool, comptime what: []const u8, args: anytype) !void { + if (ok) return; + std.debug.print(what ++ "\n", args); + return error.TestUnexpectedResult; +} + fn workerStress(ctx: *Ctx) void { var i: u32 = 0; while (i < ctx.iterations) : (i += 1) { var w = window_api.Window.create(ctx.gpa, .{}) catch { _ = ctx.err_count.fetchAdd(1, .release); - ctx.done.store(1, .release); return; }; w.destroy(); } - ctx.done.store(1, .release); } test "concurrent createWindow + destroyWindow" { if (builtin.os.tag != .windows) return error.SkipZigTest; - // Use page_allocator instead of std.testing.allocator for this - // stress test: the timeout-bail path (error.Win32ThreadSafetyTimeout) - // returns from the test while worker threads are still running, and - // testing.allocator would then false-positive a leak on the worker- - // thread allocations that haven't completed their destroy cycle yet. - // The gate is "no deadlock + class_atom stable + - // class_open_count returns to 0" — heap accounting is not part of - // the contract here. + // Heap accounting is not what this test checks. const gpa = std.heap.page_allocator; var ctxs: [NUM_THREADS]Ctx = undefined; @@ -71,7 +62,7 @@ test "concurrent createWindow + destroyWindow" { warmup.destroy(); } const atom_before = window_api.classAtom(); - try std.testing.expect(atom_before != 0); + try check(atom_before != 0, "the class atom is 0 after the warm-up", .{}); var i: u32 = 0; while (i < NUM_THREADS) : (i += 1) { @@ -82,25 +73,10 @@ test "concurrent createWindow + destroyWindow" { threads[i] = try std.Thread.spawn(.{}, workerStress, .{&ctxs[i]}); } - const start_ns = weld.platform.time.nowNanos(); - while (true) { - var all_done = true; - for (&ctxs) |*c| { - if (c.done.load(.acquire) == 0) { - all_done = false; - break; - } - } - if (all_done) break; - const elapsed_ms = (weld.platform.time.nowNanos() - start_ns) / 1_000_000; - if (elapsed_ms >= TIMEOUT_MS) return error.Win32ThreadSafetyTimeout; - std.Thread.yield() catch {}; - } - for (&threads) |*t| t.join(); const atom_after = window_api.classAtom(); - try std.testing.expect(atom_after != 0); + try check(atom_after != 0, "the class atom is 0 after the stress", .{}); try std.testing.expectEqual(atom_before, atom_after); try std.testing.expectEqual(@as(u32, 0), window_api.classOpenCount()); @@ -119,5 +95,5 @@ test "concurrent createWindow + destroyWindow" { var total_errs: u32 = 0; for (&ctxs) |*c| total_errs += c.err_count.load(.acquire); const total_attempts: u32 = NUM_THREADS * ITERATIONS_PER_THREAD; - try std.testing.expect(total_errs * 20 < total_attempts); // < 5% + try check(total_errs * 20 < total_attempts, "{d} of {d} window creates failed", .{ total_errs, total_attempts }); } From fbafdcd7e102ee45c659e8caef62bd79c0d46add Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 27 Sep 2026 15:10:12 +0200 Subject: [PATCH 091/141] docs(brief): record M1.D.29 closed and its class left for arbitration Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 +--- briefs/m1.d-phase-1-debt.md | 40 +++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fd8434dc..1f93c5bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,9 +122,7 @@ commit, so a milestone still in review has no row here. - **M1.D.18 — a table component under sustained churn grows its chunk count until the DISPATCH FAILS (opened at M1.B/G11, measured, mechanism named)**. `Archetype.removeSwap` compacts INSIDE one chunk only — `chunk_idx` is fixed and the last slot OF THAT CHUNK fills the hole — and `archetype.zig` has NO release path: no `chunks.pop`, no `swapRemove`, no `entity_count == 0` test, no shrink. So the count follows the CUMULATIVE number of adds and never the live population. Measured by `bench/ecs_hybrid_crossover.zig` at `payload=64B`, fraction 1.0, churn 60/carrier/s: **128 chunks at the first tick, 8200 within the window**, for 20 000 entities over 8 archetypes — 2.4 entities per chunk where the payload allows ~156 — after which `jobs.Scheduler.dispatchBatch` returns `error.TooManyChunks` at its `workers × 8192` capacity. **The consequence is a HARD dispatch failure, not slowness**, and the population that reaches it is any durable churning load — precisely the load `@storage(.sparse)` exists to serve, whose range count is CONSTANT in the same report row. **Invisible to C0.1, which never churns.** NOT fix-as-you-go and the reason is structural, not convenience: the remedy is inter-chunk compaction or a partial-chunk free list, and **moving an entity between chunks invalidates the `chunk_ptr` of every live `ComponentRef`** — the type M1.B/G5 built, whose table arm deliberately holds a chunk pointer — so the fix touches a contract this milestone just froze, and it interacts with `chunkAt(i)`'s stability during a dispatch. That is a milestone, not a commit. Owner: the chunk-lifecycle owner; Guy carries the corpus side. **THE BLOCKER THIS ENTRY RECORDS NO LONGER EXISTS, measured at M1.D/S5/G6**: `M1.D.21` removed `chunk_ptr` in S2/G1 of that same milestone, one session BEFORE this sentence was written, so `ComponentRef` is `{entity, component_id, mutable}` and re-resolves at every access. Second deferral condition satisfied by work from elsewhere, after `M1.D.12`. And the half of the remedy that is REUSE — not compaction — landed at S5/G7 as `Archetype.first_partial`: it moves NO entity, because `removeSwap` leaves a dense prefix and free space at the tail, so its invalidation set is empty. Its benefit is deliberately UNMEASURED and no figure is offered, the instrument being absent (`DynamicQuery` exposes neither `chunkAt` nor `chunkCount`). - **`M1.D.14` gets its SEVENTH and EIGHTH measurements, and its first treatment (M1.B/G11)**. That plan row already carries this debt by name — *"le job `bench-ecs-smoke (windows-2025)` a une queue de durée qui franchit son budget `timeout-minutes: 10`"* — with six measurements at near-constant code (5m08, 6m52, 8m19, 9m21, 9m28, 9m38), factor 1.9, two cancellations and two green re-runs at the SAME SHA, and it names raising the budget as one of two options while taking neither. **M1.B adds 9m37 (passed, 23 seconds of headroom) and 11m28 (cancelled) and TAKES that option**: 10 → 20 minutes. M1.B also made the job heavier, measured rather than assumed — the step runs `zig build bench-ecs`, whose run step depends on the install step, so it compiles EVERY installed artifact, verified by deleting `zig-out/bin/` and watching `ecs-hybrid-crossover-bench` reappear beside `ecs-benchmark`. What the raise does NOT remove is the runner's intrinsic variance, which `M1.D.14`'s own analysis already establishes as the cause, nor the standing question of whether the Windows bench belongs in the PR matrix. *An earlier draft of this entry opened a parallel record under a new number; a debt treated under any name but its own stays open in the document that carries it.* Raised again to 30 minutes at M1.D/S5; detail in the brief. - **A CELL OF THE CI MATRIX HANGS, TEN TIMES MEASURED, AND THE CLASS HAS NO HOME IN THE CORPUS (opened at M1.B/G11, needs a number)**. Distinct from `M1.D.14`, which carries the DURATION of the bench job: this is the PENDING of a matrix cell that gates merges. **Cell:** `build-and-test (windows-2025, ReleaseSafe)`, both precisions. **Signature:** `error: test runner failed to respond for ~1m`, with **zero** occurrences of the sibling class `failed without output` — the two have never been co-present. **Count: TEN**, all on that cell, every one exonerated by a green re-run at the SAME SHA (three recorded before M1.B, two at M1.B/G10-G11, three at M1.B/P3-P4, one at M1.E/G12 on `6a2bb8e`, one at M1.E on `387ab97`). **The ninth, at M1.E/G12 on `6a2bb8e`, is the most informative the class has produced and it is the LARGEST BY AN ORDER**: `2164/2196 tests passed (32 skipped)` against a declared windows floor of 2250 gives **54 TESTS LOST**, where the previous counts were 1, 5, 6, 8 and 14. It ran 38.1 min against a 55-minute budget, so it is NOT the sibling timeout recorded below it — that one has every step green and no lost test — and the log carries ZERO `error: '…' failed:` lines, so no assertion fired. Fifty-four tests is a whole step of substance rather than a straggler, which narrows what can be hanging: the class is not a slow tail on a small step. **THE THIRTEENTH, at M1.A on `565012c`, hangs TWICE IN ONE RUN and the new discriminant reads BOTH**: signature present twice, sibling absent, zero assertions, `2253/2285 tests passed (32 skipped)` against a declared floor of 2288 — **THREE tests lost across two hung steps** — 52.6 minutes against 55 with conclusion `failure`. The two `failed command:` lines name `fecde19354ea9ccbd9ecb5d20cdb00ac` and `2fe9c3b586059afa4881744abc5715ef`: **two different executables in ONE build**, which is the within-run twin of the within-SHA comparison the twelfth produced. Four distinct identities are now recorded across three attempts and no two agree. **CLOSED at M1.D/S5 as `M1.D.19`**: a Zig 0.16 defect — the windows spawn leaves a test's pipe ends inheritable, so a concurrent compiler holds its end of stream — worked around by `--test-timeout 180s` on the matrix `zig build test`. Detail in the brief; the upstream report, not filed, is in `briefs/artifacts/`. The series of losses is 1, 5, 6, 8, 14, 54, 2, 1, 3. **THE TWELFTH, at M1.A on `e054b26`, ties the smallest loss and adds a collateral the class did not carry**: `2255/2287 tests passed (32 skipped)` against a declared floor of 2288 gives **ONE test lost**, signature present once, sibling class absent, zero `error: '…' failed:` lines, and **53.0 minutes against a 55-minute budget with conclusion `failure` and not `cancelled`** — which excludes `M1.D.27` on both of its discriminants at once rather than on duration alone. The series of losses is now 1, 5, 6, 8, 14, 54, 2, 1. **The collateral is that two debts met on one job**: the run carries `Zig cache is 11222302720 bytes, over the 10737418240 cap — SKIPPING the final save`, which is `M1.D.10`'s mechanism, and a skipped final save leaves the NEXT run on that cell colder, which is `M1.D.27`'s trigger. Neither debt is new; their interaction is recorded nowhere else. **IT FAILED ITS FIRST SAME-SHA RE-RUN, and that re-run produced a THIRD DISCRIMINANT this entry records as impossible.** Attempt 2: signature once, sibling absent, zero assertions, `2256/2287 (31 skipped)` against 2288 — **one test lost again**, where every previous pair of failures at one SHA had lost DIFFERENT counts, which is the shape an implicated test would produce. Refuted by measurement: this entry states that *"naming the step from the log does not work"*, which is true of the step's SOURCE name and FALSE of its identity — **the `failed command:` line immediately following the `failed to respond` message carries the hung step's build-cache hash**, and the two attempts differ (`01a58047…` against `c810df3f…`). Two DIFFERENT executables hung at one SHA, so no test is implicated, and the equal loss explains itself: both hung steps sit in the contiguous family whose neighbours all report `0 pass, 1 skip (1 total)`, where a hang costs exactly one test whichever member it hits. *The count was never the discriminant; the identity is, and it is one grep away in every log this class has already produced.* **THE ELEVENTH, at M1.A on `3f22cf6`, is the smallest loss the class has produced and the cleanest measurement of it**: `2245/2277 tests passed (32 skipped)` against a declared floor of 2279 gives **2 TESTS LOST**, with the signature present once, the sibling class absent, zero `error: '…' failed:` lines, and 52.7 minutes against a 55-minute budget — so `M1.D.27` is excluded by duration and by conclusion (`fail`, not `cancelled`). The series of losses is now 1, 5, 6, 8, 14, 54 and 2, which continues to refute any single implicated test. Cleared by a re-run at the SAME SHA. **THE TENTH, at M1.E on `387ab97`, is the first to hang TWICE IN ONE RUN**: two `failed to respond` twelve minutes apart (19:49:14 and 20:01:15 UTC) on two DIFFERENT test executables, and `301/304 steps succeeded (2 failed)` accounts for exactly those two. **And the loss stopped following the step count**: `2217/2249 tests passed (32 skipped)` against the same 2250 floor gives **ONE test lost for TWO hung steps**, so at least one of the two cost no test at all — where the counts so far had been 1, 5, 6, 8, 14 and 54, always for a single step. That asymmetry is NOT explained here: the natural reading, a runner that blocks after its last result is already reported, is plausible and unmeasured, and this class has already paid for two hypotheses issued on a plausible reading. What it does strengthen is the `0664f28` discriminant — two different steps, in one run, on a commit whose diff is comments and two Markdown files. It ran 40.2 min, SHORTER than the sibling timeout because it aborted on the hang rather than finishing, carries ZERO `error: '…' failed:`, and exonerated on the FIRST re-run. **AT ONE SHA (`0664f28`) THE CELL FAILED THREE TIMES AND LOST THREE DIFFERENT COUNTS — 14, 1 and 5 tests — hence three different step sets.** That is a discriminant the class did not previously have, and it is the strongest evidence yet that no single test is implicated: a test that hangs deterministically blocks the same step every time and loses the same number. **And the frequency moved**: the five occurrences preceding that SHA each exonerated on the FIRST same-SHA re-run, where this one took two — f64 green on re-run 1, f32 failing again with a third lost count and green only on re-run 2. Two collateral facts from the same investigation: `pre-push` runs `zig build test -Doptimize=ReleaseSafe` (`lefthook.yml:31`), so the failing cell's MODE is green on the dev machine at every push; and pushing over an in-flight run marks that run `failure` with no evidence of its own (`aa7416c`), which is the recorded status-predicate hazard seen from the other side. **Two discriminants, and only one of them works today.** (1) `declared − collected` from the `Build Summary` line `--summary all` already produces: at the M1.B occurrence, `302/304 steps succeeded (1 failed); 2103/2135 tests passed` against a declared windows floor of 2143 gives **8 tests lost**, so the hung step holds eight — a SIZE, computed and not inferred. (2) Naming the step from the log **does not work**: a hung step emits no output at all, so the per-step summary that would name it is exactly what is missing, and `--log-failed` returns the aggregate. Naming it needs either a per-step timeout that identifies its target or a step-by-step run. What DOES survive is a negative discriminant used at G11: a step that printed its own report AND its `failed command:` artifact has COMPLETED, which is how the M1.B/G10 scheduler-dispatch tests were exonerated without a re-run. **The corpus carries no foyer for this**: the signature returns zero occurrences across the corpus (measured), so ten occurrences on a merge-gating cell live only in a succession of briefs. -- **`win32_thread_safety_test` TIMES OUT on `windows-2025 / Debug`, and it is a DISTINCT class from the hang above (second occurrence at M1.A, needs a number)**. Not `M1.D.19`: the hang signature is ABSENT, no test is lost (collected equals the floor exactly), and ONE assertion genuinely fires — `error.Win32ThreadSafetyTimeout` at `tests/platform/win32_thread_safety_test.zig:99`, a wall-clock bound on three threads doing 100 `createWindow`/`destroyWindow` pairs each against a 30 s budget. The job takes ~10 minutes, not the ~50 of a hang. - **The decisive discriminant is stronger than the sibling-cell one and is general to every flake class: A DIFF WITH NO ZIG IN IT.** The second occurrence landed on a commit whose diff is two Markdown files and seven lines, on top of a commit where that same cell had passed — so no code changed between the green and the red, and the commit cannot be the cause. The sibling `Debug / f64` cell passing at the same SHA corroborates it and is weaker: Win32 windowing has nothing to do with the physics scalar, so that axis could not discriminate a real fault either. - **It is NOT the class M1.1.9 retired from `crash_recovery.zig`**, and the difference is worth keeping: those assertions ran AFTER the blocking receive returned and therefore guarded nothing, while this loop BOUNDS a wait and abandons it — a correctly shaped hang guard. What fires is its BUDGET on a runner the suite itself saturates. Removing it would remove a real guard; raising it weakens the bound it exists to give. Owner: unassigned. +- **`M1.D.29` — CLOSED at M1.D/S5: `win32_thread_safety_test`'s 30 s wall-clock bound is gone**; it only detected a hang, and `--test-timeout`, passed by every CI matrix cell, names a hung test. Without that flag a running test has no deadline at all (Zig 0.16, measured). **Open, for arbitration: the rest of the class** — duration gates in correctness tests, home-made hang detectors, the 5 s `test_watchdog`, the Wayland twin — against `engine-zig-conventions.md` §13's required internal timeout, and the pre-push hook and `test-runtime-env` passing no `--test-timeout`. Detail in the brief, S5/G9 terdecies. - **The sparse-driven disjunctive path's first-use allocation is BOUNDED but not ISOLATED (opened at M1.1.15-era, measured at M1.B/G10)**. The entity-keyed disjunctive path allocates an `AutoHashMapUnmanaged` the FIRST time a sparse-driven term appears and reuses it after, so steady state is allocation-free like `merge_cursors` — the same shape as `contact_constraint.zig`'s `deferred` residual. `bench/ecs_hybrid_crossover.zig` now bounds it: the sparse arm's first-tick allocation count is **constant at 3** across all 28 cells of every configuration, where the table arm's grows **2 → 183** with churn. **What the bench does NOT do is isolate the map from the other two allocations on that tick** — it establishes that the quantity is a small constant and does not grow with fraction or churn, which is what the debt needed, and not which of the three is the map. Stated rather than implied. - **`D-M0.2.1-c01-baseline-investigation` is CLOSED, and it was tracking a phantom (closed at M1.B/G11)**. It followed the divergence between C0.1's 3.74 ms and a "14.2 ms M0.1 baseline" that **no M0.1 artifact carries**: the squash commit body, the annotated tag and `briefs/M0.1-ecs-full.md:316` all read **3.84 ms in ReleaseFast**, and `git log --all --grep=14.2` returns two commits of which the earlier is `df67e1c` (M0.2.1 itself). The real delta is **2.6 %**. Closed by naming it here and in the M1.B brief plus a head note on the dated report — `engine-audit-checklist.md` carries zero occurrences of the identifier (measured), so the debt lived only in a brief, and a brief is a document of its commit and is not edited. - **`build-and-test (windows-2025, ReleaseSafe)` HAD NO HEADROOM AT ITS 55-MINUTE BUDGET, and a comment-only commit was enough to spend it (opened at M1.E/G11, measured, mechanism named; the Release budget is 75 minutes since `c3e6d316`, Debug 35)**. **NOT the hang class**, whose count stays at eight: there, tests are LOST and the signature is `test runner failed to respond`. Here the job's conclusion is `cancelled` and **EVERY step succeeded**, `zig build test` and `Complete job` included — the job ran to its end and the wall arrived as it finished. Duration **55.0 min against `timeout-minutes: 55`** (`ci.yml:216`), and `fail-fast: false` means the other fourteen jobs ran green; `ci-gate` failed only because a required cell was not `success`. **Located step by step**: `zig build` 11.0 min, **`zig build test` 38.0 min**, the three cache steps 4.8 min together — so `M1.D.10`'s cache purging is NOT the cause here. The same cell on the two preceding commits of the same branch: `fb3d912` **12.5 min**, `7a7fc1b` **37.6 min**. A factor of 4.4 on a diff that is comments only. **The mechanism is the commit's own shape**, and it is `M1.D.8`'s transposed to another cell: the changed files are the tree's most-depended-upon Tier 0 headers — `world.zig`, `interp.zig`, `query.zig`, `observers.zig`, `archetype.zig`, `hybrid_query.zig`, `registry.zig`, `sparse_storage.zig`, both schedulers — and a comment edit changes a file's hash, so every compile step whose closure reaches them recompiles. A documentation pass over Tier 0 therefore costs the same cache as a refactor of it. **THE AMPLITUDE IS ATTRIBUTED, by a re-run at the SAME SHA whose only difference is that the first attempt's cache save had run**: attempt 2 took **10.3 min** where attempt 1 took 55.0, with `zig build test` at **3.2 min against 38.0** — a factor of **11.9** — and `zig build` at 4.0 against 11.0. So 34.8 of those 38 minutes were COLD-CACHE COMPILATION and not test execution, measured on one commit with the apparatus held fixed, which is `M1.D.8`'s finding with a clean before/after instead of a comparison across runs. The runner's intrinsic variance is NOT needed to explain it. The first of the two options was taken at `c3e6d316`: the Release budget went from 55 to 75 minutes. **AND A WARM CACHE IS NOT GUARANTEED BY A RE-RUN**: at M1.E on `387ab97`, another comment-only Tier 0 commit, the f64 leg of the same cell hung (the hang class above), and its re-run — which therefore started from whatever the FAILED attempt had left — took **53.1 min**, passing with **1.9 minutes of headroom**. The two classes appeared on one cell in one run, the hang on attempt 1 and the cold-cache cost on attempt 2, which does not merge them — one loses tests and prints `failed to respond`, the other has every step green and pays in minutes — but it is the second commit in two milestones where a comment-only edit to Tier 0 headers put this cell within two minutes of its wall. Owner: unassigned. diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index 25fc2310..bf33fe91 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -6923,6 +6923,46 @@ the suite at each step. CI on `0dfd5d04`: `windows-2025 / Debug / f64` failed on `win32_thread_safety_test`'s `Win32ThreadSafetyTimeout`, the class recorded twice before, no test lost (2726 collected, the windows floor); the next push cancelled that run. +### S5/G9 terdecies — `M1.D.29`, the win32 stress test's wall-clock bound + +Ruled: measure what the test checks beyond its bound; a bound that only detects a hang +leaves the test, the runner's deadline doing that work. `win32_thread_safety_test` +bounded its eight workers by 30 s of real time and failed `Win32ThreadSafetyTimeout` +past it — twice in a row on `windows-2025 / Debug / f64` (`0dfd5d04`, `9410c182`), +no test lost, collected equal to the windows floor. Beyond the bound it asserts three +things: the class atom stable, the open count back to 0, under 5 % of creates failed. +The bound feeds none of them. **Measured on Zig 0.16's own source and on a throwaway +project**: with `--test-timeout` a running test has a deadline and the runner reports +`'…' timed out` by name, then runs the next test; without it a running test has NO +deadline — the 60 s floor covers only the wait between tests — and the build never +ends, killed by an alarm at 25 s with no output. Every CI matrix cell passes +`--test-timeout 180s` to `zig build test`, and `ci.yml` now says that flag is the only +per-test deadline. The test joins its workers (`7ca9ea51`). + +On `2f736b62` both `windows-2025 / ReleaseSafe` cells failed on the same test *without +output*, and that failure **cannot be attributed**: in a release build `expect(false)` +prints nothing — measured, the exact `failed without output` of CI — and three of the +four checks were plain `expect`s. Each now names itself on stderr, measured to print in +ReleaseSafe; the next failure will say which. Cross-compiled for `x86_64-windows` in +Debug and ReleaseSafe, and a type error planted in the body fails that compile, so it +analyses the test on macOS, where the test itself is skipped. + +**The class is wider, and its sweep is an arbitration, not a fix.** A read-only sweep +with a critic found thirty-three uses in twenty-four files: duration gates inside correctness +tests — `reference_500`'s median parse, the hot reload under 500 ms, `sleepPrecise` +under 50 ms (twice), the IPC handshake under 100 ms, the shader reload under 1500 ms — +which is exactly what M1.1.9 removed from `crash_recovery.zig`; home-made hang +detectors on external resources — the async asset load, the IPC receives and process +polls; the 5 s `test_watchdog` armed in ten files of the suite, one test of which +waits for a worker to park after 1024 yield rounds, a count the scheduler's own comment +measures past 5 s on a loaded runner; and the Wayland twin of this test, bound at 30 s, +run by `test-runtime-env` and by the pre-push TSan rerun. Two facts block a sweep +here: `engine-zig-conventions.md` §13 REQUIRES an internal timeout of at most 5 s for +every test awaiting an external resource, on the premise that a hang otherwise hangs +the suite with no culprit named — a premise measured false where `--test-timeout` is +passed and true where it is not; and neither the pre-push hook nor `test-runtime-env` +passes it. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree From 2a146711873fce7be16d8c059e73c9e50ebbba33 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 27 Sep 2026 15:22:20 +0200 Subject: [PATCH 092/141] fix(etch): unbind an interpreter from its world when freed or replaced A world kept calling into an interpreter that was gone. bindToWorld registered the interpreter's address as the context of three extension seams and of every observer rule, and nothing ever took it back: Interpreter.run bound then freed; a hot reload bound a second interpreter on the same world, whose seams replaced the first's while its observers were only appended. Red first, the reload itself: after the old interpreter was freed, the next structural change crashed at 0xaaaaaaaaaaaaab62 in runObserverBody, called through its freed context. The observer registry gains unregister, removing one context's listeners and keeping the rest in firing order, and the world clears the seams a context registered. An interpreter now unbinds when freed, and binding a world unbinds the interpreter found in any of its seams. A bound interpreter refuses a second world with InterpreterBoundToAnotherWorld, where it silently kept the first. Floor 2736 to 2745. Co-Authored-By: Claude Opus 5.5 --- src/core/ecs/observers.zig | 41 +++++++ src/core/ecs/world.zig | 14 +++ src/etch/interp.zig | 192 +++++++++++++++++++++++++++++++-- tools/weld_lint/dead_tests.zig | 4 +- 4 files changed, 240 insertions(+), 11 deletions(-) diff --git a/src/core/ecs/observers.zig b/src/core/ecs/observers.zig index 43a7eb2e..0ee7f363 100644 --- a/src/core/ecs/observers.zig +++ b/src/core/ecs/observers.zig @@ -143,6 +143,16 @@ pub const ComponentUnionIter = struct { } }; +fn removeListeners(list: *Listeners, ctx: *anyopaque) void { + var kept: usize = 0; + for (list.items) |l| { + if (l.ctx == @as(?*anyopaque, ctx)) continue; + list.items[kept] = l; + kept += 1; + } + list.shrinkRetainingCapacity(kept); +} + /// Registry holding the five kinds of observer lists. Lives next to /// the `World` (typically as a field) and is consulted during every /// command buffer flush. @@ -245,6 +255,17 @@ pub const ObserverRegistry = struct { try self.registerInMap(gpa, &self.on_replaced, cid, ctx, callback); } + /// Remove every listener registered with `ctx`, keeping the others in + /// firing order. Allocates nothing. + pub fn unregister(self: *ObserverRegistry, ctx: *anyopaque) void { + removeListeners(&self.on_spawned, ctx); + removeListeners(&self.on_despawned, ctx); + inline for (.{ &self.on_add, &self.on_remove, &self.on_replaced }) |map| { + var lists = map.valueIterator(); + while (lists.next()) |list| removeListeners(list, ctx); + } + } + fn registerInMap( self: *ObserverRegistry, gpa: std.mem.Allocator, @@ -497,6 +518,26 @@ fn applyRawCommand(world: *World, gpa: std.mem.Allocator, c_in: Command) !void { const testing = std.testing; +test "unregister removes one context's listeners and keeps the others in order" { + const gpa = testing.allocator; + var reg = ObserverRegistry.init(); + defer reg.deinit(gpa); + var gone: u8 = 0; + var kept_a: u8 = 0; + var kept_b: u8 = 0; + const cid: ComponentId = 3; + for ([_]*u8{ &gone, &kept_a, &gone, &kept_b }) |ctx| { + try reg.registerOnAdd(gpa, cid, ctx, &e3CaptureObserver); + try reg.registerOnSpawned(gpa, ctx, &e3CaptureObserver); + } + reg.unregister(&gone); + for ([_][]const Listener{ reg.on_add.get(cid).?.items, reg.on_spawned.items }) |left| { + try testing.expectEqual(@as(usize, 2), left.len); + try testing.expectEqual(@as(?*anyopaque, &kept_a), left[0].ctx); + try testing.expectEqual(@as(?*anyopaque, &kept_b), left[1].ctx); + } +} + test "ObserverRegistry init/deinit round-trip is leak-free" { const gpa = testing.allocator; var reg = ObserverRegistry.init(); diff --git a/src/core/ecs/world.zig b/src/core/ecs/world.zig index 2a7b6395..aed69cdf 100644 --- a/src/core/ecs/world.zig +++ b/src/core/ecs/world.zig @@ -433,6 +433,20 @@ pub const World = struct { if (self.detach_hook) |h| try h.func(h.ctx, self, entity, extension_name, on_detach_text); } + /// Clear each extension seam registered with `ctx`. + pub fn unregisterExtensionHooks(self: *World, ctx: *anyopaque) void { + const own = @as(?*anyopaque, ctx); + if (self.attach_hook) |h| { + if (h.ctx == own) self.attach_hook = null; + } + if (self.detach_hook) |h| { + if (h.ctx == own) self.detach_hook = null; + } + if (self.check_hook) |h| { + if (h.ctx == own) self.check_hook = null; + } + } + /// Register the extension hook check (one per world, last registration wins). pub fn registerExtensionCheck(self: *World, ctx: ?*anyopaque, callback: ExtensionCheckFn) void { self.check_hook = .{ .ctx = ctx, .func = callback }; diff --git a/src/etch/interp.zig b/src/etch/interp.zig index 017e6780..8d2f1892 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -1079,9 +1079,7 @@ const StepAction = enum { /// Per-observer-rule context handed to the Tier-0 `ObserverRegistry` as the /// opaque `ctx` pointer. Points back at the interpreter + the -/// descriptor index so the trampoline can run the right rule body. Allocated -/// once per binding in `bindToWorld`, freed at `deinit` — the interpreter must -/// outlive any flush that fires its observers. +/// descriptor index so the trampoline can run the right rule body. const ObserverCtx = struct { interp: *Interpreter, rule_desc_idx: usize, @@ -1352,12 +1350,15 @@ pub const Interpreter = struct { merge_seen: std.AutoHashMapUnmanaged(CoreEntityId, void) = .empty, /// Per-observer-rule contexts registered into the world's `ObserverRegistry` /// at `bindToWorld`. One entry per rule with `observer_kind` - /// set; the registry holds `&observer_ctxs[k]` as its opaque `ctx`. Freed at - /// `deinit` (the interpreter must outlive any observer-firing flush). + /// set; the registry holds `&observer_ctxs[k]` as its opaque `ctx`. + /// Unregistered and freed when the interpreter unbinds. observer_ctxs: []ObserverCtx = &.{}, /// The address `bindToWorld` registered into the world, which holds it as /// the hooks' and observers' context. Non-null once bound. bound_at: ?*const Interpreter = null, + /// The world `bindToWorld` registered into, which must outlive the + /// interpreter. + bound_world: ?*World = null, /// Set while a cooked hook text runs: its spans index that text. in_hook_text: bool = false, /// Non-null while an observer body runs: the registry's deferred @@ -1365,10 +1366,11 @@ pub const Interpreter = struct { /// route here instead of `pending_tags`, so they apply at the NEXT flush — /// never re-entrantly during the current one (the no-recursion contract). observer_deferred: ?*CommandBuffer = null, - /// Touches nothing of the world: its registrations, resource payloads and + /// Removes what it registered in the world it bound. Resource payloads and /// the blocks they point into belong to the world and outlive this /// interpreter. pub fn deinit(self: *Interpreter) void { + self.unbind(); self.drainDeferredDecrefs(); self.deferred_decrefs.deinit(self.gpa); self.event_sources.deinit(self.gpa); @@ -1415,7 +1417,6 @@ pub const Interpreter = struct { self.descriptors.deinit(self.gpa); self.merge_cursors.deinit(self.gpa); self.merge_seen.deinit(self.gpa); - self.gpa.free(self.observer_ctxs); self.test_msg_buf.deinit(self.gpa); var hook_keys = self.hook_runs.keyIterator(); while (hook_keys.next()) |k| self.gpa.free(k.*); @@ -1834,12 +1835,19 @@ pub const Interpreter = struct { /// Register this program's observer rules into `world`'s `ObserverRegistry`. /// Idempotent: the first call allocates one `ObserverCtx` per observer rule and /// registers a trampoline keyed on the rule's lifecycle kind + target component; - /// later calls no-op, and refuse from a copy of the bound interpreter. Called + /// later calls no-op, and refuse from a copy of the bound interpreter or for + /// another world. An interpreter bound to `world` before is unbound. Called /// lazily by `runFor`, or explicitly by a test that drives a Tier-0 flush /// before any tick. pub fn bindToWorld(self: *Interpreter, world: *World) !void { - if (self.bound_at != null) return self.checkNotMoved(); + if (self.bound_at != null) { + try self.checkNotMoved(); + if (self.bound_world.? != world) return error.InterpreterBoundToAnotherWorld; + return; + } + if (boundTo(world)) |replaced| replaced.unbind(); self.bound_at = self; + self.bound_world = world; // register the extension hook seams so the loader's // `dispatchOnAttach` / runtime `deactivate_extension` reach `execHookText`. @@ -1874,6 +1882,32 @@ pub const Interpreter = struct { } } + /// The interpreter bound to `world`, found through any seam still holding + /// one of its trampolines. + fn boundTo(world: *const World) ?*Interpreter { + if (world.attach_hook) |h| { + if (h.func == &extensionAttachTrampoline) return @ptrCast(@alignCast(h.ctx.?)); + } + if (world.detach_hook) |h| { + if (h.func == &extensionDetachTrampoline) return @ptrCast(@alignCast(h.ctx.?)); + } + if (world.check_hook) |h| { + if (h.func == &extensionCheckTrampoline) return @ptrCast(@alignCast(h.ctx.?)); + } + return null; + } + + /// Remove from the world it bound what `bindToWorld` registered there. + fn unbind(self: *Interpreter) void { + const world = self.bound_world orelse return; + for (self.observer_ctxs) |*c| world.observer_registry.unregister(c); + world.unregisterExtensionHooks(@constCast(self.bound_at.?)); + self.gpa.free(self.observer_ctxs); + self.observer_ctxs = &.{}; + self.bound_world = null; + self.bound_at = null; + } + /// Refuse to run from an address other than the one the world holds: the /// world would call back into the interpreter this one was copied from. fn checkNotMoved(self: *const Interpreter) error{InterpreterMovedAfterBind}!void { @@ -17282,6 +17316,146 @@ test "an interpreter moved after it bound its world refuses to bind again" { try std.testing.expectError(error.InterpreterMovedAfterBind, moved.bindToWorld(&world)); } +const observed_source = + \\component Health { current: int = 0 } + \\@on_added(Health) + \\rule seen(entity: Entity, value: Health) {} +; + +/// Listeners on `cid`'s `on_add` list. +fn onAddListeners(world: *World, cid: ComponentId) usize { + return if (world.observer_registry.on_add.get(cid)) |list| list.items.len else 0; +} + +test "a freed interpreter leaves no observer in the world it bound" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, observed_source); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + try interp.bindToWorld(&world); + const health = world.registry.idOf("Health").?; + interp.deinit(); + try std.testing.expectEqual(@as(usize, 0), onAddListeners(&world, health)); +} + +test "a freed interpreter leaves no extension seam in the world it bound" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, observed_source); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + try interp.bindToWorld(&world); + interp.deinit(); + try std.testing.expect(world.attach_hook == null); + try std.testing.expect(world.detach_hook == null); + try std.testing.expect(world.check_hook == null); +} + +test "an interpreter bound to a world replaces the one bound before it" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, observed_source); + defer pr.deinit(gpa); + var first = try Interpreter.compile(gpa, &pr.ast, &world); + defer first.deinit(); + try first.bindToWorld(&world); + var second = try Interpreter.compile(gpa, &pr.ast, &world); + defer second.deinit(); + try second.bindToWorld(&world); + try std.testing.expectEqual(@as(usize, 1), onAddListeners(&world, world.registry.idOf("Health").?)); +} + +test "a replaced interpreter freed leaves its successor bound" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, observed_source); + defer pr.deinit(gpa); + var first = try Interpreter.compile(gpa, &pr.ast, &world); + try first.bindToWorld(&world); + var second = try Interpreter.compile(gpa, &pr.ast, &world); + defer second.deinit(); + try second.bindToWorld(&world); + first.deinit(); + try std.testing.expectEqual(@as(usize, 1), onAddListeners(&world, world.registry.idOf("Health").?)); + try std.testing.expect(world.check_hook.?.ctx == @as(?*anyopaque, &second)); +} + +fn standInAttach(_: ?*anyopaque, _: *World, _: CoreEntityId, _: []const u8, _: ?[]const u8) anyerror!void {} + +test "a freed interpreter leaves in place a seam another registered" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, observed_source); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + try interp.bindToWorld(&world); + var other: u8 = 0; + world.registerOnAttach(&other, &standInAttach); + interp.deinit(); + try std.testing.expect(world.attach_hook.?.ctx == @as(?*anyopaque, &other)); +} + +test "an interpreter bound to a world replaces the one bound before it through any of its seams" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, observed_source); + defer pr.deinit(gpa); + var first = try Interpreter.compile(gpa, &pr.ast, &world); + defer first.deinit(); + try first.bindToWorld(&world); + var other: u8 = 0; + world.registerOnAttach(&other, &standInAttach); + var second = try Interpreter.compile(gpa, &pr.ast, &world); + defer second.deinit(); + try second.bindToWorld(&world); + try std.testing.expectEqual(@as(usize, 1), onAddListeners(&world, world.registry.idOf("Health").?)); +} + +test "an interpreter bound to one world refuses another" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var other = World.init(); + defer other.deinit(gpa); + var pr = try parser_mod.parse(gpa, observed_source); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + try interp.bindToWorld(&world); + try std.testing.expectError(error.InterpreterBoundToAnotherWorld, interp.bindToWorld(&other)); +} + +test "after a reload frees the old interpreter, a structural change calls only the new one" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, + \\component Health { current: int = 0 } + \\@on_added(Health) + \\rule seen(entity: Entity, value: Health) { + \\ entity.get_mut(Health).current = 7 + \\} + ); + defer pr.deinit(gpa); + var old = try Interpreter.compile(gpa, &pr.ast, &world); + try old.bindToWorld(&world); + var new = try Interpreter.compile(gpa, &pr.ast, &world); + defer new.deinit(); + try new.bindToWorld(&world); + old.deinit(); + const health = world.registry.idOf("Health").?; + var zero: i64 = 0; + const e = try world.observer_registry.spawnWithObservers(gpa, &world, &[_]ComponentId{health}, &[_][]const u8{std.mem.asBytes(&zero)}); + try std.testing.expectEqual(@as(i64, 7), std.mem.readInt(i64, world.componentBytes(e, health).?[0..8], .little)); +} + test "an interpreter tickOnAcc ran ticks again" { const gpa = std.testing.allocator; var world = World.init(); diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 7d31003d..a1d4d53b 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2734, - else => 2736, + .windows => 2743, + else => 2745, }; } From f5de61ba6027aa1bfffdb6b1e7dd805ce4baa834 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 27 Sep 2026 15:30:22 +0200 Subject: [PATCH 093/141] test(etch): fail the seam witnesses on a missing seam, not a panic Under the counter-factual that clears every seam, the witness unwrapped the emptied slot with .? and panicked instead of failing. Co-Authored-By: Claude Opus 5.5 --- src/etch/interp.zig | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/etch/interp.zig b/src/etch/interp.zig index 8d2f1892..7f1c0523 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -17382,7 +17382,8 @@ test "a replaced interpreter freed leaves its successor bound" { try second.bindToWorld(&world); first.deinit(); try std.testing.expectEqual(@as(usize, 1), onAddListeners(&world, world.registry.idOf("Health").?)); - try std.testing.expect(world.check_hook.?.ctx == @as(?*anyopaque, &second)); + const hook = world.check_hook orelse return error.TestExpectedSeam; + try std.testing.expect(hook.ctx == @as(?*anyopaque, &second)); } fn standInAttach(_: ?*anyopaque, _: *World, _: CoreEntityId, _: []const u8, _: ?[]const u8) anyerror!void {} @@ -17398,7 +17399,8 @@ test "a freed interpreter leaves in place a seam another registered" { var other: u8 = 0; world.registerOnAttach(&other, &standInAttach); interp.deinit(); - try std.testing.expect(world.attach_hook.?.ctx == @as(?*anyopaque, &other)); + const hook = world.attach_hook orelse return error.TestExpectedSeam; + try std.testing.expect(hook.ctx == @as(?*anyopaque, &other)); } test "an interpreter bound to a world replaces the one bound before it through any of its seams" { From f6d5a2b30c751fc6a5d59eb86afe37aaeab025eb Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 27 Sep 2026 15:32:07 +0200 Subject: [PATCH 094/141] docs(brief): record arbitration 1, the interpreter freed while bound Co-Authored-By: Claude Opus 5.5 --- briefs/m1.d-phase-1-debt.md | 44 +++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index bf33fe91..e8294604 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -6963,6 +6963,50 @@ the suite with no culprit named — a premise measured false where `--test-timeo passed and true where it is not; and neither the pre-push hook nor `test-runtime-env` passes it. +### S5/G9 quattuordecies — arbitration 1, an interpreter freed while its world lives + +Ruled: the observer registry gains an unregister, and the interpreter unregisters when +it is freed or replaced — a use-after-free on the hot-reload path closes even though it +touches Tier 0; read and not run, so a red witness first. `bindToWorld` gave the world +the interpreter's address as the context of three extension seams and of every observer +rule, and nothing took it back: `Interpreter.run` bound then freed, and a reload bound a +second interpreter on the same world, whose seams replaced the first's while its +observers were only APPENDED. + +Five witnesses red first, each at its predicted value — the freed interpreter's +listener left in the world (`expected 0, found 1`), its seams left set, two listeners +where the replacing interpreter should leave one, the same after the replaced one is +freed, and a second world bound in silence (`found void`). Then **the use-after-free +itself, in the reload's exact shape**: an old and a new interpreter bound on one world, +the old freed, a structural change — `Segmentation fault at address 0xaaaaaaaaaaaaab62` +in `runObserverBody`, called by `observerTrampoline` through the freed context, as +predicted. `ObserverRegistry.unregister` removes one context's listeners and keeps the +rest in firing order; `World.unregisterExtensionHooks` clears the seams a context +registered; the interpreter keeps the world it bound, unbinds when freed, and binding a +world unbinds the interpreter it replaces; a bound interpreter refuses a second world +with `InterpreterBoundToAnotherWorld`, where it had kept the first without a word. No +test freed a world before its interpreter — the suite would have crashed in `unbind`, +`World.deinit` leaving `0xaa` behind in Debug. + +**One gap was found by writing the counter-factuals, before they ran.** With the +replacement at bind, nothing witnessed that an unbind clears only its own seams: that +case is reachable only when something else registers a seam after the bind, a native +stand-in for instance. Its witness passes on the fix and reddens only under its +mutation. And the same scenario showed the replacement looked for the old interpreter +in the attach seam alone, so a stand-in there hid it: red first, `expected 1, found 2`; +the old interpreter is now found through any of its three seams (`2a146711`). + +Counter-factuals, each alone and each predicted: an unbind skipped at the free reddens +the two freed-interpreter witnesses; no replacement at bind, the two replacement +witnesses; the seams cleared whatever registered them, the stand-in's seam witness; +removal by swap, the registry's order witness; a second world accepted, its witness. +The seam witness first reddened as a CRASH — it unwrapped the emptied slot with `.?` — +and now fails on a missing seam, rerun alone. **An instrument error of mine, caught +before it was believed**: this batch's done marker and one log reused the file names of +the earlier point-1 batch, so the first summary read the old results — their +timestamps, 07:44 against 15:23, said so; the fresh run is the one recorded here. Floor +2736 → 2745 on macOS and 2734 → 2743 on windows, read from the suite. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree From 06dc4a98f382bf90bdab3c49301cf19e9f682d87 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 27 Sep 2026 16:24:55 +0200 Subject: [PATCH 095/141] chore(build): give every test run a deadline, the TSan rerun included MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit engine-zig-conventions.md §13 makes --test-timeout the hang detector wherever the suite runs. Measured on Zig 0.16, a running test has no deadline without it, and the build runner is the only place it can be set: build.zig cannot give it a default. The pre-push hook's two test runs, and CI's test-runtime-env, test-etch and test-ppm-psnr, now pass --test-timeout 180s, as the matrix test step already did. The pre-push ThreadSanitizer rerun of the Wayland stress test called the zig test CLI, which has no such flag, and could not compile at all: its command named the root module twice and provided neither test_env nor foundation. It is now a build step, test-tsan-wayland, instrumenting weld_core as well, where the backend under stress lives. It builds for x86_64-linux-gnu; macOS cannot build libtsan, and the hook runs it on Linux only. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 6 +++--- build.zig | 30 ++++++++++++++++++++++++++++++ lefthook.yml | 19 +++++++------------ 3 files changed, 40 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02a4d3c2..b24adcc9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,7 +142,7 @@ jobs: echo "TEST_SECONDS=$((SECONDS - s))" >> "$GITHUB_ENV" - name: zig build test-etch - run: zig build test-etch -Doptimize=${{ matrix.mode }} -Dphysics_f64=${{ matrix.precision }} -Dcpu=${{ env.ZIG_CPU }} + run: zig build test-etch --test-timeout 180s -Doptimize=${{ matrix.mode }} -Dphysics_f64=${{ matrix.precision }} -Dcpu=${{ env.ZIG_CPU }} - name: zig build bindgen-verify run: zig build bindgen-verify -Dcpu=${{ env.ZIG_CPU }} @@ -329,14 +329,14 @@ jobs: - name: Verify PSNR vs golden run: | set -euo pipefail - zig build test-ppm-psnr -Doptimize=ReleaseSafe -Dcpu=${{ env.ZIG_CPU }} --summary all 2>&1 | tee test-output.txt + zig build test-ppm-psnr --test-timeout 180s -Doptimize=ReleaseSafe -Dcpu=${{ env.ZIG_CPU }} --summary all 2>&1 | tee test-output.txt - name: zig build test-runtime-env env: VK_ICD_FILENAMES: /usr/share/vulkan/icd.d/lvp_icd.json run: | set -euo pipefail - zig build test-runtime-env -Dcpu=${{ env.ZIG_CPU }} --summary all + zig build test-runtime-env --test-timeout 180s -Dcpu=${{ env.ZIG_CPU }} --summary all - name: Measure the Zig cache before the save if: always() diff --git a/build.zig b/build.zig index 31ab3b8b..89177140 100644 --- a/build.zig +++ b/build.zig @@ -1246,6 +1246,36 @@ pub fn build(b: *std.Build) void { psnr_step.dependOn(&psnr_run.step); } + // The pre-push ThreadSanitizer rerun of the Wayland stress test, invoked on + // Linux by `lefthook.yml`. `weld_core` is instrumented too: the backend + // under stress lives there. + { + const tsan_foundation = b.createModule(.{ + .root_source_file = b.path("src/foundation/root.zig"), + .target = target, + .optimize = optimize, + .sanitize_thread = true, + }); + const tsan_core = b.createModule(.{ + .root_source_file = b.path("src/core/root.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + .sanitize_thread = true, + }); + tsan_core.addImport("foundation", tsan_foundation); + const tsan_mod = b.createModule(.{ + .root_source_file = b.path("tests/platform/wayland_thread_safety_test.zig"), + .target = target, + .optimize = optimize, + .sanitize_thread = true, + }); + tsan_mod.addImport("weld_core", tsan_core); + tsan_mod.addImport("test_env", test_env_modules[0]); + const tsan_step = b.step("test-tsan-wayland", "Run the Wayland stress test under ThreadSanitizer"); + tsan_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = tsan_mod })).step); + } + // `zig build test-stress` builds and runs ONLY the // scheduler-livelock stress test. It is deliberately OUT of `test_step` and // must STAY out: the 100× stress-signal loop is a local validation tool, not diff --git a/lefthook.yml b/lefthook.yml index ec280114..2a527580 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -26,23 +26,18 @@ pre-push: commands: build: run: zig build + # --test-timeout is the only per-test deadline: without it a hung test + # hangs the push. test: - run: zig build test + run: zig build test --test-timeout 180s test-release: - run: zig build test -Doptimize=ReleaseSafe - # M0.3 — local-only thread-safety rerun on the Wayland stress test - # with ThreadSanitizer (TSan). The Linux CI matrix does not ship - # TSan toolchains for the runner image we target, so this hook acts - # as the M0.3 garde-fou. Wrapped in a Linux-only guard because the - # `-fsanitize=thread` CLI flag isn't recognized as a standalone - # `zig test` argument on macOS / Windows hosts (Zig's flag set is - # frontend-target-dependent). On macOS / Windows dev boxes this - # entire command short-circuits to `true`, keeping the pre-push - # green; on Linux it runs the TSan-instrumented test. + run: zig build test -Doptimize=ReleaseSafe --test-timeout 180s + # The Wayland stress test under ThreadSanitizer, which the CI matrix does + # not run; Linux only, the test itself being skipped elsewhere. test-tsan-wayland: run: | if [ "$(uname -s)" = "Linux" ]; then - zig test tests/platform/wayland_thread_safety_test.zig -fsanitize=thread --dep weld_core -Mroot=tests/platform/wayland_thread_safety_test.zig -Mweld_core=src/core/root.zig -lc + zig build test-tsan-wayland --test-timeout 180s else true fi From 7831e0d3a02d0af8d4e29146a398d183883a523d Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 27 Sep 2026 16:58:20 +0200 Subject: [PATCH 096/141] test: drop hang detectors that the runner's deadline replaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit engine-zig-conventions.md §13, rewritten: the hang detector is --test-timeout, now passed wherever the suite runs, and an internal delay survives only to free what the process's death would not. Removed, each asserting nothing past "finished in time": the 5 s watchdog of the two files that register no scheduler (deque_test, threading_test); the Wayland stress test's 30 s bound, its workers joined as in M1.D.29; the async loader's 5 s bound; and every receive timeout of the in-process IPC tests - handshake (server and client), transport x3, fd_passing and handoff_fd, the last two absent from the census and found by a regex over every clock and timeout token. The handshake client's timeout was what let a server-side failure be reported: the join waited on a thread reading an ack that never came. The tests now close the server and release the thread before joining it. Measured: a failure planted before the ack fails with its own error in seconds; under the old order, without timeouts, the same test times out after 20 s and the planted error is never reported. Co-Authored-By: Claude Opus 5.5 --- build.zig | 2 +- tests/assets/loader_async.zig | 12 +-- tests/ipc/fd_passing.zig | 25 ------- tests/ipc/handoff_fd.zig | 28 ------- tests/ipc/handshake.zig | 73 +++++-------------- tests/ipc/transport.zig | 48 +----------- tests/jobs/deque_test.zig | 6 -- tests/platform/threading_test.zig | 6 -- tests/platform/wayland_thread_safety_test.zig | 49 +++---------- 9 files changed, 37 insertions(+), 212 deletions(-) diff --git a/build.zig b/build.zig index 89177140..d481b625 100644 --- a/build.zig +++ b/build.zig @@ -1109,7 +1109,7 @@ pub fn build(b: *std.Build) void { .{ .path = "tests/vk_gen/raw_variants.zig" }, // asset registry stale-handle (generation) acceptance. .{ .path = "tests/assets/handle_generation.zig", .asset_pipeline = true }, - // async loader + lifecycle (internal 5 s watchdog). + // async loader + lifecycle. .{ .path = "tests/assets/loader_async.zig", .asset_pipeline = true }, // DEFLATE/zlib inflate known-vector acceptance. .{ .path = "tests/assets/deflate_vectors.zig", .asset_pipeline = true }, diff --git a/tests/assets/loader_async.zig b/tests/assets/loader_async.zig index 97200e80..a7ed0c3d 100644 --- a/tests/assets/loader_async.zig +++ b/tests/assets/loader_async.zig @@ -1,8 +1,6 @@ //! Async loader + lifecycle acceptance. //! -//! The main loop ticks while a load is in flight and the load completes, under -//! an internal 5 s watchdog with clean teardown (`engine-zig-conventions.md` -//! §13). +//! The main loop ticks while a load is in flight and the load completes. const std = @import("std"); const assets = @import("weld_asset_pipeline"); @@ -48,19 +46,11 @@ test "async load does not block main thread" { var loader = Loader.init(tmp.dir); defer loader.deinit(gpa); - // Begin the load and keep ticking the main loop until it is ready. A - // 5 s wall-clock watchdog guarantees the suite cannot hang on a stuck - // load, with clean teardown via `pending.cancel`. var pending = try loader.beginLoad(gpa, io, "x.texture.bin"); - const start = std.Io.Clock.Timestamp.now(io, .awake); var ticks: usize = 0; while (!pending.ready()) { ticks += 1; std.mem.doNotOptimizeAway(ticks); - if (start.untilNow(io).raw.nanoseconds > 5 * std.time.ns_per_s) { - pending.cancel(io); - return error.LoadTimedOut; - } } try std.testing.expect(ticks >= 1); // the main loop advanced; the read ran off-thread diff --git a/tests/ipc/fd_passing.zig b/tests/ipc/fd_passing.zig index eca0b7b9..db79fd5f 100644 --- a/tests/ipc/fd_passing.zig +++ b/tests/ipc/fd_passing.zig @@ -27,29 +27,6 @@ extern "c" fn write(fd: c_int, buf: [*]const u8, count: usize) isize; extern "c" fn read(fd: c_int, buf: [*]u8, count: usize) isize; extern "c" fn unlink(path: [*:0]const u8) c_int; -extern "c" fn setsockopt( - sockfd: c_int, - level: c_int, - optname: c_int, - optval: *const anyopaque, - optlen: u32, -) c_int; - -const timeval = extern struct { - tv_sec: i64, - tv_usec: i32, - _pad: i32 = 0, -}; - -const SOL_SOCKET: c_int = if (builtin.os.tag == .linux) 1 else 0xFFFF; -const SO_RCVTIMEO: c_int = if (builtin.os.tag == .linux) 20 else 0x1006; - -fn installRecvTimeout(sock: *transport.IpcSocket) void { - if (comptime !is_posix) return; - var tv = timeval{ .tv_sec = 5, .tv_usec = 0 }; - _ = setsockopt(sock.impl.fd, SOL_SOCKET, SO_RCVTIMEO, &tv, @sizeOf(timeval)); -} - test "transmits an open fd via sendWithHandles and writes through it" { if (!is_posix) return error.SkipZigTest; @@ -70,8 +47,6 @@ test "transmits an open fd via sendWithHandles and writes through it" { defer client.close(); var server = try listener.accept(); defer server.close(); - installRecvTimeout(&server); - installRecvTimeout(&client); // Editor sends the pipe write fd to the runtime via SCM_RIGHTS. // SCM_RIGHTS requires a non-empty regular payload to ride along. diff --git a/tests/ipc/handoff_fd.zig b/tests/ipc/handoff_fd.zig index 1443f557..1a180476 100644 --- a/tests/ipc/handoff_fd.zig +++ b/tests/ipc/handoff_fd.zig @@ -24,10 +24,6 @@ //! name (`open`), the fd-passing pivot is POSIX-only (§4.8). The //! `ShmRegion.fromFd` Windows path is asserted to return //! `error.Unimplemented` instead. -//! -//! External-resource discipline (engine-zig-conventions.md §13): a -//! 5 s `SO_RCVTIMEO` is installed on both endpoints so a lost cmsg -//! cannot hang the suite. const std = @import("std"); const builtin = @import("builtin"); @@ -53,28 +49,6 @@ const F_GETFD: c_int = 1; fn fdOpen(fd: transport.OsHandle) bool { return fcntl(fd, F_GETFD) != -1; } -extern "c" fn setsockopt( - sockfd: c_int, - level: c_int, - optname: c_int, - optval: *const anyopaque, - optlen: u32, -) c_int; - -const timeval = extern struct { - tv_sec: i64, - tv_usec: i32, - _pad: i32 = 0, -}; - -const SOL_SOCKET: c_int = if (builtin.os.tag == .linux) 1 else 0xFFFF; -const SO_RCVTIMEO: c_int = if (builtin.os.tag == .linux) 20 else 0x1006; - -fn installRecvTimeout(sock: *transport.IpcSocket) void { - if (comptime !is_posix) return; - var tv = timeval{ .tv_sec = 5, .tv_usec = 0 }; - _ = setsockopt(sock.impl.fd, SOL_SOCKET, SO_RCVTIMEO, &tv, @sizeOf(timeval)); -} test "shm attach via received fd" { if (!is_posix) return error.SkipZigTest; @@ -99,8 +73,6 @@ test "shm attach via received fd" { defer client.close(); var server = try listener.accept(); defer server.close(); - installRecvTimeout(&server); - installRecvTimeout(&client); // ---- Handoff: editor → runtime, fd in ancillary data. ---- // The 1-byte payload stands in for the ShmRegionsHandoff frame; diff --git a/tests/ipc/handshake.zig b/tests/ipc/handshake.zig index 41ff7e75..a5610f98 100644 --- a/tests/ipc/handshake.zig +++ b/tests/ipc/handshake.zig @@ -3,10 +3,10 @@ //! with a dedicated thread for the runtime side (the server's //! `acceptOne` is blocking). //! -//! Each test installs a 5 s socket recv timeout on the server side -//! so a misbehaving handshake fails the test instead of hanging the -//! runner. The Unix socket file is cleaned up on every scope exit -//! via `defer forceUnlink`. +//! Each test closes its server and releases the runtime thread before +//! joining it, so a failure before the ack ends the thread's wait and is +//! reported instead of hanging the join. The Unix socket file is cleaned up on every scope +//! exit via `defer forceUnlink`. const std = @import("std"); const builtin = @import("builtin"); @@ -20,34 +20,12 @@ const framing = ipc.framing; const is_posix = builtin.os.tag == .linux or builtin.os.tag == .macos; extern "c" fn unlink(path: [*:0]const u8) c_int; -extern "c" fn setsockopt( - sockfd: c_int, - level: c_int, - optname: c_int, - optval: *const anyopaque, - optlen: u32, -) c_int; - -const timeval = extern struct { - tv_sec: i64, - tv_usec: i32, - _pad: i32 = 0, -}; - -const SOL_SOCKET: c_int = if (builtin.os.tag == .linux) 1 else 0xFFFF; -const SO_RCVTIMEO: c_int = if (builtin.os.tag == .linux) 20 else 0x1006; fn forceUnlink(path: [:0]const u8) void { if (comptime !is_posix) return; _ = unlink(path.ptr); } -fn installRecvTimeout(socket: *ipc.transport.IpcSocket) void { - if (comptime !is_posix) return; - var tv = timeval{ .tv_sec = 5, .tv_usec = 0 }; - _ = setsockopt(socket.impl.fd, SOL_SOCKET, SO_RCVTIMEO, &tv, @sizeOf(timeval)); -} - const RuntimeArgs = struct { gpa: std.mem.Allocator, path: []const u8, @@ -62,15 +40,8 @@ const RuntimeArgs = struct { }; extern "c" fn nanosleep(req: *const timespec_t, rem: ?*timespec_t) c_int; -extern "c" fn clock_gettime(clk_id: i32, tp: *timespec_t) c_int; -const CLOCK_MONOTONIC: i32 = if (builtin.os.tag == .linux) 1 else 6; const timespec_t = extern struct { tv_sec: i64, tv_nsec: i64 }; -fn nowMs() i64 { - var ts = timespec_t{ .tv_sec = 0, .tv_nsec = 0 }; - _ = clock_gettime(CLOCK_MONOTONIC, &ts); - return ts.tv_sec * 1000 + @divFloor(ts.tv_nsec, std.time.ns_per_ms); -} fn spinSleepMs(ms: u64) void { var ts = timespec_t{ .tv_sec = @intCast(ms / 1_000), @@ -84,7 +55,6 @@ fn runtimeThread(args: *RuntimeArgs) void { var client = ipc.client.IpcClient.init(args.gpa); defer client.deinit(); client.connect(args.path) catch return; - installRecvTimeout(&client.socket.?); client.sendHello("0.0.7-S6", "deadbee", args.capabilities) catch return; var scratch: [framing.frameSizeOf(messages.ProtocolHelloAck)]u8 = undefined; @@ -92,7 +62,7 @@ fn runtimeThread(args: *RuntimeArgs) void { args.accepted_out.* = ack.accepted; } -test "full handshake completes within 100 ms" { +test "full handshake completes" { if (!is_posix) return error.SkipZigTest; const gpa = std.testing.allocator; @@ -100,10 +70,6 @@ test "full handshake completes within 100 ms" { forceUnlink(path); defer forceUnlink(path); - var server = ipc.server.IpcServer.init(gpa); - defer server.deinit(); - try server.listen(path); - var accepted_out: u8 = 0xFF; var ready_flag = std.atomic.Value(u8).init(0); var args = RuntimeArgs{ @@ -115,24 +81,25 @@ test "full handshake completes within 100 ms" { }; const runtime = try std.Thread.spawn(.{}, runtimeThread, .{&args}); defer runtime.join(); + defer ready_flag.store(1, .release); + + var server = ipc.server.IpcServer.init(gpa); + defer server.deinit(); + try server.listen(path); // Drop the starter pistol after the listener is armed. Without // this the client thread can hit `connect()` before the server // installs its socket — `ECONNREFUSED` on macOS. ready_flag.store(1, .release); - const t0 = nowMs(); try server.acceptOne(); - installRecvTimeout(&server.client.?); var hello_buf: [framing.frameSizeOf(messages.ProtocolHello)]u8 = undefined; const hello = try server.recvHello(&hello_buf); try server.sendHelloAck(true, ""); - const elapsed_ms = nowMs() - t0; try std.testing.expectEqual(@as(u16, protocol.WELD_IPC_PROTOCOL_VERSION), hello.protocol_version); try std.testing.expectEqualStrings("0.0.7-S6", messages.readFixedString(&hello.engine_version)); - try std.testing.expect(elapsed_ms < 100); } test "version mismatch produces explicit rejection" { @@ -143,10 +110,6 @@ test "version mismatch produces explicit rejection" { forceUnlink(path); defer forceUnlink(path); - var server = ipc.server.IpcServer.init(gpa); - defer server.deinit(); - try server.listen(path); - var accepted_out: u8 = 0xFF; var ready_flag = std.atomic.Value(u8).init(0); var args = RuntimeArgs{ @@ -158,11 +121,15 @@ test "version mismatch produces explicit rejection" { }; const runtime = try std.Thread.spawn(.{}, runtimeThread, .{&args}); defer runtime.join(); + defer ready_flag.store(1, .release); + + var server = ipc.server.IpcServer.init(gpa); + defer server.deinit(); + try server.listen(path); ready_flag.store(1, .release); try server.acceptOne(); - installRecvTimeout(&server.client.?); var hello_buf: [framing.frameSizeOf(messages.ProtocolHello)]u8 = undefined; var hello = try server.recvHello(&hello_buf); @@ -185,10 +152,6 @@ test "GPU_SHARED_FB capability defaults to 0" { forceUnlink(path); defer forceUnlink(path); - var server = ipc.server.IpcServer.init(gpa); - defer server.deinit(); - try server.listen(path); - var accepted_out: u8 = 0xFF; var ready_flag = std.atomic.Value(u8).init(0); var args = RuntimeArgs{ @@ -200,11 +163,15 @@ test "GPU_SHARED_FB capability defaults to 0" { }; const runtime = try std.Thread.spawn(.{}, runtimeThread, .{&args}); defer runtime.join(); + defer ready_flag.store(1, .release); + + var server = ipc.server.IpcServer.init(gpa); + defer server.deinit(); + try server.listen(path); ready_flag.store(1, .release); try server.acceptOne(); - installRecvTimeout(&server.client.?); var hello_buf: [framing.frameSizeOf(messages.ProtocolHello)]u8 = undefined; const hello = try server.recvHello(&hello_buf); diff --git a/tests/ipc/transport.zig b/tests/ipc/transport.zig index fada0a77..50c1488f 100644 --- a/tests/ipc/transport.zig +++ b/tests/ipc/transport.zig @@ -1,20 +1,11 @@ //! Transport — `IpcSocket.listen/connect/accept/send/recv` on a real OS socket. //! //! Writing 64 KB single-threaded on an AF_UNIX SOCK_STREAM deadlocks once the -//! kernel send buffer fills, no reader draining it — hence the first two rules -//! below, and the third is the cleanup every test owes: -//! - Large-payload tests spawn a reader thread that consumes bytes -//! in parallel. -//! - Every test installs a 5 s recv timeout on its server-side -//! socket via the platform `SO_RCVTIMEO` socket option (POSIX). -//! The timeout makes the test fail cleanly with -//! `error.BrokenPipe` instead of hanging if the protocol misfires. -//! - The listen socket and any unix socket file are unlinked on -//! test scope exit (`defer`). +//! kernel send buffer fills, no reader draining it, so large-payload tests spawn +//! a reader thread that consumes bytes in parallel. The listen socket and any +//! unix socket file are unlinked on test scope exit (`defer`). //! -//! Skipped on Windows: the named-pipe backend has different timeout semantics -//! (`PIPE_WAIT` against `PIPE_NOWAIT` + `WaitNamedPipe`), so the timeouts these -//! tests rest on do not transpose. +//! POSIX only: the tests address unix socket files. const std = @import("std"); const builtin = @import("builtin"); @@ -24,40 +15,12 @@ const transport = weld_core.ipc.transport; const is_posix = builtin.os.tag == .linux or builtin.os.tag == .macos; -extern "c" fn setsockopt( - sockfd: c_int, - level: c_int, - optname: c_int, - optval: *const anyopaque, - optlen: u32, -) c_int; - extern "c" fn unlink(path: [*:0]const u8) c_int; fn forceUnlink(path: [:0]const u8) void { _ = unlink(path.ptr); } -const timeval = extern struct { - tv_sec: i64, - tv_usec: i32, - _pad: i32 = 0, -}; - -const SOL_SOCKET: c_int = if (builtin.os.tag == .linux) 1 else 0xFFFF; -const SO_RCVTIMEO: c_int = if (builtin.os.tag == .linux) 20 else 0x1006; - -/// Install a 5-second recv timeout on the underlying fd of an -/// `IpcSocket` (POSIX only). Catches the test-runner deadlock the -/// previous session burned 46 minutes on: any `recv()` that would -/// normally hang now fails with `EAGAIN`/`error.BrokenPipe` after 5 s. -fn installRecvTimeout(sock: *transport.IpcSocket) void { - if (comptime !is_posix) return; - const fd = sock.impl.fd; - var tv = timeval{ .tv_sec = 5, .tv_usec = 0 }; - _ = setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, @sizeOf(timeval)); -} - fn socketPath(comptime suffix: []const u8) [:0]const u8 { return "/tmp/weld-test-" ++ suffix ++ ".sock"; } @@ -77,7 +40,6 @@ test "listen + connect + accept + small payload round-trip" { var server = try listener.accept(); defer server.close(); - installRecvTimeout(&server); const payload = "hello-weld-ipc"; try client.send(payload); @@ -131,7 +93,6 @@ test "send loops over partial writes (64 KB, drained by reader thread)" { var server = try listener.accept(); defer server.close(); - installRecvTimeout(&server); const big = [_]u8{42} ** 64_000; @@ -158,7 +119,6 @@ test "recv returns 0 on clean peer close (EOF)" { var client = try transport.IpcSocket.connect(path); var server = try listener.accept(); defer server.close(); - installRecvTimeout(&server); client.close(); diff --git a/tests/jobs/deque_test.zig b/tests/jobs/deque_test.zig index 4c80ef0c..ce819827 100644 --- a/tests/jobs/deque_test.zig +++ b/tests/jobs/deque_test.zig @@ -1,6 +1,5 @@ const std = @import("std"); const weld_core = @import("weld_core"); -const watchdog = @import("test_watchdog"); const Deque = weld_core.jobs.deque.Deque; @@ -42,11 +41,6 @@ fn stealerLoop(ctx: StealCtx) void { } test "concurrent steal: every element is consumed exactly once" { - const io = std.testing.io; - var wd: watchdog.Watchdog = .{}; - try wd.arm(io, watchdog.default_timeout_ns, "concurrent steal: every element is consumed exactly once"); - defer wd.disarm(); - const N: u32 = 4096; var deque = Deque(u32, 1024).init(); var consumed: std.atomic.Value(u32) = .init(0); diff --git a/tests/platform/threading_test.zig b/tests/platform/threading_test.zig index 804f3db1..fb796548 100644 --- a/tests/platform/threading_test.zig +++ b/tests/platform/threading_test.zig @@ -7,18 +7,12 @@ const std = @import("std"); const weld = @import("weld_core"); const threading = weld.platform.threading; const builtin = @import("builtin"); -const watchdog = @import("test_watchdog"); test "setAffinity + setPriority on spawned thread" { if (builtin.os.tag != .linux and builtin.os.tag != .macos and builtin.os.tag != .windows) { return error.SkipZigTest; } - const io = std.testing.io; - var wd: watchdog.Watchdog = .{}; - try wd.arm(io, watchdog.default_timeout_ns, "setAffinity + setPriority on spawned thread"); - defer wd.disarm(); - const Ctx = struct { done: std.atomic.Value(u32) = std.atomic.Value(u32).init(0), diff --git a/tests/platform/wayland_thread_safety_test.zig b/tests/platform/wayland_thread_safety_test.zig index 258ee9c7..defb97f5 100644 --- a/tests/platform/wayland_thread_safety_test.zig +++ b/tests/platform/wayland_thread_safety_test.zig @@ -1,8 +1,8 @@ //! Wayland concurrent createWindow + destroyWindow stress. //! -//! Concurrent `createWindow` and `destroyWindow` — 8 threads, timeout 5 s — -//! against the Wayland backend's module-level state: the libwayland loader's -//! once-init and `wayland.live_state`. +//! Concurrent `createWindow` and `destroyWindow` on 8 threads against the +//! Wayland backend's module-level state: the libwayland loader's once-init and +//! `wayland.live_state`. //! //! This is the FUNCTIONAL pass. The explicit data-race check is the lefthook //! pre-push `-fsanitize=thread` rerun. @@ -15,15 +15,13 @@ const builtin = @import("builtin"); const weld = @import("weld_core"); const NUM_THREADS: u32 = 8; -// The target is 1000 iterations, knocked down to 100 here because each one -// round-trips with the compositor: microseconds on real hardware, but a -// headless or nested compositor stretches that considerably. +// Each iteration round-trips with the compositor, which a headless or nested +// one stretches considerably; a deadlock is caught by the runner's per-test +// deadline, which this count must stay well inside. const ITERATIONS_PER_THREAD: u32 = 100; -const TIMEOUT_MS: u64 = 30000; const Ctx = struct { iterations: u32, - done: std.atomic.Value(u32) = std.atomic.Value(u32).init(0), err_count: std.atomic.Value(u32) = std.atomic.Value(u32).init(0), gpa: std.mem.Allocator, }; @@ -33,30 +31,20 @@ fn workerStress(ctx: *Ctx) void { while (i < ctx.iterations) : (i += 1) { var w = weld.platform.window.Window.create(ctx.gpa, .{}) catch { _ = ctx.err_count.fetchAdd(1, .release); - ctx.done.store(1, .release); return; }; w.destroy(); } - ctx.done.store(1, .release); } -// Stress-pattern test: 8 threads × N iter of create/destroy sequential -// Backend. page_allocator allocator (not testing.allocator) because a -// timeout bail without join produces a false-positive leak ~512 B/thread -// (State allocated per thread mid-iter). The steady-state create/destroy -// stays covered by the inline tests of wayland.zig + TSAN active via -// lefthook pre-push. -// -// INVARIANT NOTE — this test validates memory non-corruption under -// backend-create stress, NOT multi-backend coherence which stays outside -// the Phase 0 invariant ("1 Backend per process"). The global non-atomic -// live_state var is raced between threads here, with no consequence on the -// tested pattern. Phase 0+ multi-window cleanup (cf. wayland.zig live_state -// comment) will address this tension. +// Memory non-corruption under concurrent backend creation, not multi-backend +// coherence, which the "one Backend per process" invariant leaves out: the +// non-atomic `live_state` is raced between threads here, harmlessly for this +// pattern. test "concurrent createWindow + destroyWindow" { if (builtin.os.tag != .linux) return test_env.absent("a Linux host"); + // Heap accounting is not what this test checks. const gpa = std.heap.page_allocator; // Probe first, so a missing compositor is reported as one and not as @@ -78,21 +66,6 @@ test "concurrent createWindow + destroyWindow" { threads[i] = try std.Thread.spawn(.{}, workerStress, .{&ctxs[i]}); } - const start_ns = weld.platform.time.nowNanos(); - while (true) { - var all_done = true; - for (&ctxs) |*c| { - if (c.done.load(.acquire) == 0) { - all_done = false; - break; - } - } - if (all_done) break; - const elapsed_ms = (weld.platform.time.nowNanos() - start_ns) / 1_000_000; - if (elapsed_ms >= TIMEOUT_MS) return error.WaylandThreadSafetyTimeout; - std.Thread.yield() catch {}; - } - for (&threads) |*t| t.join(); var total_errs: u32 = 0; From 89516251764cf208384ccfcf09120f756119c3be Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 27 Sep 2026 16:58:38 +0200 Subject: [PATCH 097/141] test(ipc): kill a child process that outlives a wide bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §13 keeps an internal delay only to free what the process's death would not, and a child process is that: a test killed by the runner's deadline leaves it running. The child waits of process.zig (x3), crash_recovery.zig (the clean-exit wait and the reap) and catalogue.zig (the teardown, which never killed the runtime) now give it 6 000 polls of 10 ms, about 60 s and inside the 180 s deadline, then kill it and fail by a named error, with no duration asserted. catalogue.zig's receive timeout goes: it freed nothing. The kill test's child slept 30 s, inside the new bound, so a kill that did nothing would pass for the child's own exit. It sleeps 600 s. Measured, kill removed: with sleep 600 the test fails ChildNeverDied after the bound and no sleeper is left; with sleep 30 it passes. Co-Authored-By: Claude Opus 5.5 --- tests/ipc/catalogue.zig | 25 ++++++---------- tests/ipc/crash_recovery.zig | 34 +++++++++++----------- tests/ipc/process.zig | 55 ++++++++++++++++-------------------- 3 files changed, 50 insertions(+), 64 deletions(-) diff --git a/tests/ipc/catalogue.zig b/tests/ipc/catalogue.zig index 14a9f1d4..005e3244 100644 --- a/tests/ipc/catalogue.zig +++ b/tests/ipc/catalogue.zig @@ -9,10 +9,6 @@ //! `SaveProject` → `ProjectSaved` (same seq_id), `LoadScene` with //! an empty path → `RuntimeError` event, and `Play`/`Pause`/`Stop` //! accepted without desync (an `Echo` after them still round-trips). -//! -//! External-resource discipline (engine-zig-conventions.md §13): the -//! accepted socket gets a 5 s `SO_RCVTIMEO` so a missing reply fails the -//! test instead of hanging the suite. const std = @import("std"); const builtin = @import("builtin"); @@ -75,15 +71,10 @@ test "catalogue messages round-trip through encode/decode" { extern "c" fn unlink(path: [*:0]const u8) c_int; extern "c" fn shm_unlink(name: [*:0]const u8) i32; -extern "c" fn setsockopt(sockfd: c_int, level: c_int, optname: c_int, optval: *const anyopaque, optlen: u32) c_int; const timespec_t = extern struct { tv_sec: i64, tv_nsec: i64 }; extern "c" fn nanosleep(req: *const timespec_t, rem: ?*timespec_t) c_int; extern "c" fn getpid() i32; -const timeval = extern struct { tv_sec: i64, tv_usec: i32, _pad: i32 = 0 }; -const SOL_SOCKET: c_int = if (builtin.os.tag == .linux) 1 else 0xFFFF; -const SO_RCVTIMEO: c_int = if (builtin.os.tag == .linux) 20 else 0x1006; - fn sleepMs(ms: u64) void { var ts = timespec_t{ .tv_sec = @intCast(ms / 1000), .tv_nsec = @intCast((ms % 1000) * std.time.ns_per_ms) }; _ = nanosleep(&ts, null); @@ -125,10 +116,6 @@ fn spawnRuntime( const proc = try platform_process.spawnProcess(gpa, "zig-out/bin/weld-runtime", &argv); try server.acceptOne(); - // 5 s recv timeout on the accepted socket (engine-zig-conventions §13). - var tv = timeval{ .tv_sec = 5, .tv_usec = 0 }; - _ = setsockopt(server.client.?.impl.fd, SOL_SOCKET, SO_RCVTIMEO, &tv, @sizeOf(timeval)); - var hello_buf: [framing.frameSizeOf(messages.ProtocolHello)]u8 = undefined; _ = try server.recvHello(&hello_buf); try server.sendHelloAck(true, ""); @@ -142,17 +129,23 @@ fn spawnRuntime( return .{ .vp = vp, .proc = proc }; } -/// Graceful teardown: `Shutdown` → `ShutdownAck` → reap the runtime. +/// Polls of `sleepMs(10)` a runtime is given to exit before it is killed: no +/// loaded runner reaches it, and it stays inside the runner's per-test deadline. +const exit_polls: usize = 6_000; + +/// Graceful teardown: `Shutdown` → `ShutdownAck` → reap the runtime, killed +/// once `exit_polls` have passed. fn teardown(server: *ipc.server.IpcServer, proc: *platform_process.Process) void { const sd = messages.Shutdown{}; server.connection().sendMessage(messages.Shutdown, 0, &sd) catch {}; var sa_buf: [framing.frameSizeOf(messages.ShutdownAck)]u8 = undefined; _ = server.connection().recvMessage(messages.ShutdownAck, &sa_buf) catch {}; var attempts: usize = 0; - while (attempts < 50) : (attempts += 1) { - if (platform_process.waitNonblock(proc) catch null) |_| break; + while (attempts < exit_polls) : (attempts += 1) { + if (platform_process.waitNonblock(proc) catch null) |_| return; sleepMs(10); } + platform_process.kill(proc) catch {}; } test "SaveProject is acked by ProjectSaved with the same seq_id" { diff --git a/tests/ipc/crash_recovery.zig b/tests/ipc/crash_recovery.zig index a76f8f70..7ffec653 100644 --- a/tests/ipc/crash_recovery.zig +++ b/tests/ipc/crash_recovery.zig @@ -128,12 +128,24 @@ fn spawnAndHandshake( return .{ .vp = vp, .proc = proc }; } -fn reap(io: std.Io, proc: *platform_process.Process) void { +/// Polls of `sleepMs(10)` a runtime is given to exit before it is killed: no +/// loaded runner reaches it, and it stays inside the runner's per-test deadline. +const exit_polls: usize = 6_000; + +/// The runtime's exit code, or null once `exit_polls` have passed and it has +/// been killed. +fn waitExit(io: std.Io, proc: *platform_process.Process) !?i32 { var attempts: usize = 0; - while (attempts < 200) : (attempts += 1) { - if (platform_process.waitNonblock(proc) catch null) |_| return; + while (attempts < exit_polls) : (attempts += 1) { + if (try platform_process.waitNonblock(proc)) |code| return code; sleepMs(io, 10); } + platform_process.kill(proc) catch {}; + return null; +} + +fn reap(io: std.Io, proc: *platform_process.Process) void { + _ = waitExit(io, proc) catch null; } test "runtime kill -9 → the editor's receive ends in EOF" { @@ -242,20 +254,8 @@ test "editor close → runtime detects EOF + exits clean code 0" { // runtime sees EOF on its next recv and exits 0. server.deinit(); - // The bounded poll below — 200 × 10 ms — IS the §13 internal timeout: it - // exits on its own and `exit_code != null` is what fails if the runtime - // never leaves. No duration assertion is needed on top of it. - var exit_code: ?i32 = null; - var poll: usize = 0; - while (poll < 200) : (poll += 1) { - if (try platform_process.waitNonblock(&sp.proc)) |code| { - exit_code = code; - break; - } - sleepMs(io, 10); - } - try std.testing.expect(exit_code != null); - try std.testing.expectEqual(@as(i32, 0), exit_code.?); + const exit_code = try waitExit(io, &sp.proc) orelse return error.RuntimeNeverExited; + try std.testing.expectEqual(@as(i32, 0), exit_code); } test "kill -9 + best-effort replay of post-save commands" { diff --git a/tests/ipc/process.zig b/tests/ipc/process.zig index addfc0c8..c81447c0 100644 --- a/tests/ipc/process.zig +++ b/tests/ipc/process.zig @@ -26,6 +26,22 @@ fn sleepMs(ms: u64) void { _ = nanosleep(&ts, null); } +/// Polls of `sleepMs(10)` a child is given to exit before it is killed: no +/// loaded runner reaches it, and it stays inside the runner's per-test deadline. +const exit_polls: usize = 6_000; + +/// `proc`'s exit code, or null once `exit_polls` have passed and it has been +/// killed. +fn waitExit(proc: *process.Process) !?i32 { + var attempts: usize = 0; + while (attempts < exit_polls) : (attempts += 1) { + if (try process.waitNonblock(proc)) |code| return code; + sleepMs(10); + } + process.kill(proc) catch {}; + return null; +} + // `/bin/true` lives at `/usr/bin/true` on macOS (and is also at // `/bin/true` on Linux). `/bin/sleep` is canonical on both. const true_path = if (builtin.os.tag == .macos) "/usr/bin/true" else "/bin/true"; @@ -37,19 +53,8 @@ test "spawn true(1) and reap with waitNonblock returns exit 0" { const argv = [_][]const u8{true_path}; var proc = try process.spawnProcess(gpa, true_path, &argv); - - // Poll up to ~1 s for the child to exit. /bin/true is near- - // instant; the loop bound exists to keep the test from hanging - // if the binary is missing or the spawn fails silently. - var attempts: usize = 0; - while (attempts < 100) : (attempts += 1) { - if (try process.waitNonblock(&proc)) |code| { - try std.testing.expectEqual(@as(i32, 0), code); - return; - } - sleepMs(10); - } - return error.ChildNeverExited; + const code = try waitExit(&proc) orelse return error.ChildNeverExited; + try std.testing.expectEqual(@as(i32, 0), code); } extern "c" fn getpid() i32; @@ -71,22 +76,17 @@ test "spawn-then-kill terminates a long-running child" { if (!is_posix) return error.SkipZigTest; const gpa = std.testing.allocator; - const argv = [_][]const u8{ "/bin/sleep", "30" }; + // Longer than `exit_polls` allow, so a kill that did nothing cannot pass + // for the child's own exit. + const argv = [_][]const u8{ "/bin/sleep", "600" }; var proc = try process.spawnProcess(gpa, "/bin/sleep", &argv); // Give the child a moment to actually become alive in the kernel // table — without this, `kill(pid, SIGKILL)` can race against // the spawn returning before the child is reapable on macOS. sleepMs(20); - // Don't actually wait 30 s — kill and reap. try process.kill(&proc); - - var attempts: usize = 0; - while (attempts < 100) : (attempts += 1) { - if (try process.waitNonblock(&proc)) |_| return; - sleepMs(10); - } - return error.ChildNeverDied; + _ = try waitExit(&proc) orelse return error.ChildNeverDied; } test "spawnProcess runs a Windows binary and reaps exit 0" { @@ -99,15 +99,8 @@ test "spawnProcess runs a Windows binary and reaps exit 0" { const argv = [_][]const u8{ exe, "/c", "exit 0" }; var proc = try process.spawnProcess(gpa, exe, &argv); - var attempts: usize = 0; - while (attempts < 200) : (attempts += 1) { - if (try process.waitNonblock(&proc)) |code| { - try std.testing.expectEqual(@as(i32, 0), code); - return; - } - sleepMs(10); - } - return error.ChildNeverExited; + const code = try waitExit(&proc) orelse return error.ChildNeverExited; + try std.testing.expectEqual(@as(i32, 0), code); } // ------------------------------------------------------- quoteArg tests -- From 96910b87155ad47d20b731ac746490550be0aba2 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 27 Sep 2026 16:59:01 +0200 Subject: [PATCH 098/141] test(render): make the shader reload test watch its own probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The watcher's first scan compiles every shader of the directory, and the callback took the first one: on APFS triangle.frag.glsl comes first and the probe last, so the test's two checks read another shader's compile. Measured: with an invalid probe the old test passes and the new one, which ignores every path but the probe's, fails. Its timing goes with it, per the rewritten §13: the 5 s wait bound detected a hang, which --test-timeout does, and the 1500 ms bound stood in for C0.3's 200 ms, which bench/shader_hot_reload.zig now measures. Co-Authored-By: Claude Opus 5.5 --- build.zig | 2 +- tests/render/shader_hot_reload.zig | 45 ++++++------------------------ 2 files changed, 9 insertions(+), 38 deletions(-) diff --git a/build.zig b/build.zig index d481b625..ca0e3cfd 100644 --- a/build.zig +++ b/build.zig @@ -1099,7 +1099,7 @@ pub fn build(b: *std.Build) void { // GAL capture helper surface coverage (encodePpm + // Device.captureFrameToPPM); §13 consumer test, runs on every platform. .{ .path = "tests/render/capture_helper.zig", .render = true }, - // hot-reload filewatch latency < 200 ms. + // hot-reload filewatch compiles a dropped shader. .{ .path = "tests/render/shader_hot_reload.zig", .render = true, .runtime_env = true }, // vk_gen whitelist closure (variant filtering + closure // convergence under 20 iterations). diff --git a/tests/render/shader_hot_reload.zig b/tests/render/shader_hot_reload.zig index 57b6c4e7..27c17966 100644 --- a/tests/render/shader_hot_reload.zig +++ b/tests/render/shader_hot_reload.zig @@ -1,14 +1,9 @@ -//! Shader hot-reload latency. -//! -//! Drops a probe `.frag.glsl` into `assets/shaders/`, starts the -//! `shader_pipeline.hot_reload` watcher on a 10 ms poll interval, and measures -//! the time between the probe's creation and the `on_recompile` callback. The -//! specified gate is < 200 ms. Needs `glslc` (`test_env`): without it the -//! watcher does not start. +//! Shader hot-reload: the watcher compiles a probe `.frag.glsl` dropped into +//! `assets/shaders/`. Needs `glslc` (`test_env`): without it the watcher does +//! not start. The < 200 ms latency is `bench/shader_hot_reload.zig`'s. const std = @import("std"); const test_env = @import("test_env"); -const builtin = @import("builtin"); const hot_reload = @import("weld_render").shader_pipeline.hot_reload; const compiler_mod = @import("weld_render").shader_pipeline.compiler; const time_mod = @import("weld_core").platform.time; @@ -23,28 +18,20 @@ const PROBE_SOURCE: []const u8 = \\ ; const POLL_MS: u32 = 10; -// The < 200 ms figure gates the RUNTIME hot-reload on ReleaseFast hardware. -// This test runs in Debug or ReleaseSafe and spawns `glslc` cold on every -// iteration, which adds 300-700 ms of process startup on Apple Silicon and less -// on Linux — so its own bound is 1500 ms, enough to confirm the watcher reacts -// along the filewatch → spawn → callback path without flaking on a slow runner. -// The strict gate is enforced by the manual GPU validation on the reference -// machine, in ReleaseFast. -const LATENCY_GATE_NS: u64 = 1500 * std.time.ns_per_ms; -const WAIT_TIMEOUT_NS: u64 = 5 * std.time.ns_per_s; const ProbeState = struct { fired: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), /// The recompile produced SPIR-V: a failed one fires the callback too. compiled: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), - end_ns: std.atomic.Value(u64) = std.atomic.Value(u64).init(0), }; +// The watcher's first scan compiles every shader of the directory, so the +// callback fires for the others too. fn onRecompile(ctx_opaque: ?*anyopaque, path: []const u8, spv: ?[]const u8, diag: ?[]const u8) void { - _ = .{ path, diag }; + _ = diag; + if (!std.mem.eql(u8, path, PROBE_REL_PATH)) return; const state: *ProbeState = @ptrCast(@alignCast(ctx_opaque.?)); state.compiled.store(if (spv) |bytes| bytes.len > 0 else false, .release); - state.end_ns.store(time_mod.nowNanos(), .release); state.fired.store(true, .release); } @@ -61,7 +48,7 @@ fn writeProbe(io: std.Io) !void { try writer.interface.flush(); } -test "filewatch triggers recompile under 200 ms" { +test "filewatch compiles a shader dropped into the watched directory" { const allocator = std.testing.allocator; const io = std.testing.io; @@ -71,8 +58,6 @@ test "filewatch triggers recompile under 200 ms" { defer deleteProbe(io); var state = ProbeState{}; - const start_ns = time_mod.nowNanos(); - var watcher = hot_reload.init(allocator, .{ .io = io, .root = "assets/shaders", @@ -83,22 +68,8 @@ test "filewatch triggers recompile under 200 ms" { defer watcher.deinit(); try watcher.start(); - // Wait for the callback to fire (or timeout). The watcher thread - // discovers the probe on its next scan and recompiles it; the gate - // is the full path detect → spawn glslc → callback. while (!state.fired.load(.acquire)) { - const now = time_mod.nowNanos(); - if (now - start_ns > WAIT_TIMEOUT_NS) { - return error.HotReloadTimeout; - } time_mod.sleepPrecise(io, 1 * std.time.ns_per_ms) catch {}; } - - const elapsed_ns = state.end_ns.load(.acquire) - start_ns; - std.log.info("hot-reload latency: {d:.3} ms (gate {d} ms)", .{ - @as(f64, @floatFromInt(elapsed_ns)) / 1e6, - LATENCY_GATE_NS / std.time.ns_per_ms, - }); try std.testing.expect(state.compiled.load(.acquire)); - try std.testing.expect(elapsed_ns < LATENCY_GATE_NS); } From b96cd3c22a783452f6f72e8be485e3aef6d721bf Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 27 Sep 2026 16:59:27 +0200 Subject: [PATCH 099/141] test: move duration gates out of correctness tests into benches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §13, rewritten: a correctness test measures no duration, and a time requirement goes to a bench under protocol. reference_500's median parse under 50 ms and the Etch hot reload under 500 ms are C0.2's metrics, which C1.6 keeps: bench/etch_reference.zig measures the parse, a rule-body reload and a reload of the reference file. The shader reload's C0.3 200 ms goes to bench/shader_hot_reload.zig, the watcher at its default poll on a private directory. The Etch `measure` test's 100 s ceiling answers no requirement and goes. Both benches write bench/reports/_.md with the commit, the machine, the mode and the protocol mention engine-phase-0-criteria.md asks for, take --smoke, and take --protocol for a cold-isolated run, which a bench cannot observe. Verdicts are on the maximum: the criteria bound every parse and every reload. Co-Authored-By: Claude Opus 5.5 --- bench/etch_reference.zig | 200 ++++++++++++++++++++++++++++++ bench/report_header.zig | 85 +++++++++++++ bench/shader_hot_reload.zig | 149 ++++++++++++++++++++++ build.zig | 52 +++++++- src/etch/test_runner.zig | 1 - tests/etch/hot_reload_test.zig | 19 +-- tests/etch/reference_500_test.zig | 34 +---- 7 files changed, 490 insertions(+), 50 deletions(-) create mode 100644 bench/etch_reference.zig create mode 100644 bench/report_header.zig create mode 100644 bench/shader_hot_reload.zig diff --git a/bench/etch_reference.zig b/bench/etch_reference.zig new file mode 100644 index 00000000..18415871 --- /dev/null +++ b/bench/etch_reference.zig @@ -0,0 +1,200 @@ +//! C0.2's two time metrics (`engine-phase-0-criteria.md`), both kept by C1.6: +//! the 500+ line reference file parsed in < 50 ms, and an interpreter hot +//! reload in < 500 ms from the edited source to the first tick running it. +//! +//! A reload is re-parse, type-check, `Interpreter.compile` on the live world, +//! then one tick; the source is already in memory, so reading the saved file is +//! outside it. Two reloads are measured: a rule-body edit of a one-rule program, +//! alternating between two bodies, and the reference file recompiled unchanged. +//! The verdict is on the maximum, the criterion bounding every parse and every +//! reload. +//! +//! `--smoke` runs each row once and writes nothing. `--protocol` records that +//! the run followed the cold-isolated protocol. Writes +//! `bench/reports/etch_reference_.md`. + +const std = @import("std"); +const weld_etch = @import("weld_etch"); +const weld_core = @import("weld_core"); +const report = @import("report_header.zig"); + +const World = weld_core.ecs.world.World; +const ComponentId = weld_core.ecs.registry.ComponentId; +const Interpreter = weld_etch.Interpreter; +const Diagnostic = weld_etch.Diagnostic; + +const reference_src = @embedFile("reference_500_lines"); + +const counter_bodies = [2][]const u8{ + \\component Counter { value: int = 0 } + \\rule tick(entity: Entity) + \\ when entity has Counter + \\{ + \\ entity.get_mut(Counter).value += 1 + \\} + , + \\component Counter { value: int = 0 } + \\rule tick(entity: Entity) + \\ when entity has Counter + \\{ + \\ entity.get_mut(Counter).value += 5 + \\} +}; + +const parse_gate_ns: u64 = 50 * std.time.ns_per_ms; +const reload_gate_ns: u64 = 500 * std.time.ns_per_ms; + +const Row = struct { + name: []const u8, + gate_ns: u64, + dist: report.Distribution, + samples: usize, +}; + +fn lineCount(s: []const u8) usize { + return std.mem.count(u8, s, "\n") + 1; +} + +fn checkClean(gpa: std.mem.Allocator, arena: *weld_etch.Ast) !void { + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try weld_etch.typeCheck(gpa, arena, &diags); + if (diags.items.len != 0) return error.UnexpectedTypeDiagnostic; +} + +fn benchParse(gpa: std.mem.Allocator, io: std.Io, warmup: usize, samples: []u64) !void { + var i: usize = 0; + while (i < warmup + samples.len) : (i += 1) { + const t0 = std.Io.Clock.now(.awake, io); + var pr = try weld_etch.parseSource(gpa, reference_src); + const t1 = std.Io.Clock.now(.awake, io); + defer pr.deinit(gpa); + if (pr.diagnostics.len != 0) return error.UnexpectedParseDiagnostic; + if (i >= warmup) samples[i - warmup] = report.elapsedNs(t0, t1); + } +} + +/// A program compiled on a world, with the parse its interpreter was built from. +/// The interpreter binds where it first ticks, so a session is filled in place +/// and never moved. +const Session = struct { + pr: weld_etch.parser.ParseResult, + interp: Interpreter, + + fn load(self: *Session, gpa: std.mem.Allocator, world: *World, source: []const u8) !void { + self.pr = try weld_etch.parseSource(gpa, source); + errdefer self.pr.deinit(gpa); + if (self.pr.diagnostics.len != 0) return error.UnexpectedParseDiagnostic; + try checkClean(gpa, &self.pr.ast); + self.interp = try Interpreter.compile(gpa, &self.pr.ast, world); + errdefer self.interp.deinit(); + _ = try self.interp.runFor(world, 1); + } + + fn deinit(self: *Session, gpa: std.mem.Allocator) void { + self.interp.deinit(); + self.pr.deinit(gpa); + } +}; + +/// Reloads `sources[i % sources.len]` onto a world first running `sources[0]` +/// with one entity carrying `entity_component`. +fn benchReload( + gpa: std.mem.Allocator, + io: std.Io, + sources: []const []const u8, + entity_component: []const u8, + warmup: usize, + samples: []u64, +) !void { + var world = World.init(); + defer world.deinit(gpa); + var sessions: [2]Session = undefined; + var live = [2]bool{ false, false }; + defer for (&sessions, live) |*session, l| if (l) session.deinit(gpa); + + try sessions[0].load(gpa, &world, sources[0]); + live[0] = true; + const cid = world.registry.idOf(entity_component) orelse return error.MissingComponent; + _ = try world.spawnDynamic(gpa, &[_]ComponentId{cid}); + + var i: usize = 0; + while (i < warmup + samples.len) : (i += 1) { + const slot = (i + 1) % 2; + if (live[slot]) sessions[slot].deinit(gpa); + live[slot] = false; + const t0 = std.Io.Clock.now(.awake, io); + try sessions[slot].load(gpa, &world, sources[(i + 1) % sources.len]); + const t1 = std.Io.Clock.now(.awake, io); + live[slot] = true; + if (i >= warmup) samples[i - warmup] = report.elapsedNs(t0, t1); + } +} + +fn writeReport(gpa: std.mem.Allocator, io: std.Io, rows: []const Row, protocol: bool) ![]const u8 { + var path_buf: [128]u8 = undefined; + const path = try report.datedPath(&path_buf, io, "etch_reference"); + var file = try std.Io.Dir.cwd().createFile(io, path, .{}); + defer file.close(io); + var buf: [4096]u8 = undefined; + var fw = file.writer(io, &buf); + const w = &fw.interface; + + try report.write(gpa, io, w, "Etch reference file and hot reload", protocol); + try w.print("Reference file: `tests/etch/reference_500_lines.etch`, {d} lines.\n\n", .{lineCount(reference_src)}); + try w.writeAll("| Row | Samples | Median | p99 | Max | Gate | Verdict (max) |\n|---|---|---|---|---|---|---|\n"); + for (rows) |r| { + try w.print("| {s} | {d} | {d:.3} ms | {d:.3} ms | {d:.3} ms | < {d} ms | {s} |\n", .{ + r.name, r.samples, report.ms(r.dist.median), report.ms(r.dist.p99), + report.ms(r.dist.max), r.gate_ns / std.time.ns_per_ms, if (r.dist.max < r.gate_ns) "GO" else "NO-GO", + }); + } + try w.flush(); + return try gpa.dupe(u8, path); +} + +pub fn main(init: std.process.Init) !void { + const gpa = init.gpa; + const io = init.io; + const args = try init.minimal.args.toSlice(init.arena.allocator()); + + var smoke = false; + for (args[1..]) |a| if (std.mem.eql(u8, a, "--smoke")) { + smoke = true; + }; + const protocol = report.protocolFlag(args[1..]); + + const parse_n: usize = if (smoke) 1 else 200; + const counter_n: usize = if (smoke) 1 else 200; + const reference_n: usize = if (smoke) 1 else 50; + const warmup: usize = if (smoke) 0 else 10; + + const parse_samples = try gpa.alloc(u64, parse_n); + defer gpa.free(parse_samples); + const counter_samples = try gpa.alloc(u64, counter_n); + defer gpa.free(counter_samples); + const reference_samples = try gpa.alloc(u64, reference_n); + defer gpa.free(reference_samples); + + try benchParse(gpa, io, warmup, parse_samples); + try benchReload(gpa, io, &counter_bodies, "Counter", warmup, counter_samples); + try benchReload(gpa, io, &.{reference_src}, "RefProbe", warmup, reference_samples); + + const rows = [_]Row{ + .{ .name = "parse, reference file", .gate_ns = parse_gate_ns, .dist = .of(parse_samples), .samples = parse_n }, + .{ .name = "reload, rule-body edit", .gate_ns = reload_gate_ns, .dist = .of(counter_samples), .samples = counter_n }, + .{ .name = "reload, reference file", .gate_ns = reload_gate_ns, .dist = .of(reference_samples), .samples = reference_n }, + }; + for (rows) |r| { + std.debug.print("{s}: median {d:.3} ms, p99 {d:.3} ms, max {d:.3} ms (gate < {d} ms)\n", .{ + r.name, report.ms(r.dist.median), report.ms(r.dist.p99), report.ms(r.dist.max), r.gate_ns / std.time.ns_per_ms, + }); + } + if (smoke) return; + const path = try writeReport(gpa, io, &rows, protocol); + defer gpa.free(path); + std.debug.print("wrote {s}\n", .{path}); +} diff --git a/bench/report_header.zig b/bench/report_header.zig new file mode 100644 index 00000000..bc5c9a8c --- /dev/null +++ b/bench/report_header.zig @@ -0,0 +1,85 @@ +//! The header of an archived bench report (`engine-phase-0-criteria.md` § Rapport +//! de bench): the commit measured, the machine, the build mode, and whether the +//! run followed the cold-isolated protocol. + +const std = @import("std"); +const builtin = @import("builtin"); + +/// `--protocol` on the command line: the operator ran under the cold-isolated or +/// thermal-aware protocol, which the bench cannot observe. +pub fn protocolFlag(args: []const [:0]const u8) bool { + for (args) |a| if (std.mem.eql(u8, a, "--protocol")) return true; + return false; +} + +/// `git` run in the working directory, trimmed, or null when it fails. +fn git(gpa: std.mem.Allocator, io: std.Io, argv: []const []const u8) ?[]u8 { + const result = std.process.run(gpa, io, .{ .argv = argv }) catch return null; + defer gpa.free(result.stderr); + switch (result.term) { + .exited => |code| if (code == 0) { + const trimmed = std.mem.trim(u8, result.stdout, " \n\r\t"); + const out = gpa.dupe(u8, trimmed) catch null; + gpa.free(result.stdout); + return out; + }, + else => {}, + } + gpa.free(result.stdout); + return null; +} + +/// Writes the title and the four facts the protocol asks of every report. +pub fn write(gpa: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, title: []const u8, protocol: bool) !void { + const commit = git(gpa, io, &.{ "git", "rev-parse", "HEAD" }); + defer if (commit) |c| gpa.free(c); + const status = git(gpa, io, &.{ "git", "status", "--porcelain", "--untracked-files=no" }); + defer if (status) |s| gpa.free(s); + + try w.print("# {s}\n\n", .{title}); + try w.print("- Commit: {s}", .{commit orelse "unknown"}); + if (status) |s| if (s.len > 0) try w.writeAll(" with uncommitted changes"); + try w.writeAll("\n"); + try w.print("- Machine: {s}, {s}-{s}\n", .{ builtin.cpu.model.name, @tagName(builtin.cpu.arch), @tagName(builtin.os.tag) }); + try w.print("- Zig {d}.{d}.{d}, {s}\n", .{ builtin.zig_version.major, builtin.zig_version.minor, builtin.zig_version.patch, @tagName(builtin.mode) }); + try w.print("- Protocol: {s}\n\n", .{if (protocol) "cold-isolated, compliant" else "dev run — not opposable"}); +} + +/// `bench/reports/_.md`, dated by the wall clock. +pub fn datedPath(buf: []u8, io: std.Io, name: []const u8) ![]const u8 { + const wall = std.Io.Clock.now(.real, io); + const secs: u64 = @intCast(@max(@as(i96, 0), wall.toSeconds())); + const day = (std.time.epoch.EpochSeconds{ .secs = secs }).getEpochDay(); + const year_day = day.calculateYearDay(); + const month_day = year_day.calculateMonthDay(); + return std.fmt.bufPrint(buf, "bench/reports/{s}_{d:0>4}-{d:0>2}-{d:0>2}.md", .{ + name, year_day.year, month_day.month.numeric(), @as(u8, month_day.day_index) + 1, + }); +} + +/// Median, 99th percentile and maximum of a sample set. +pub const Distribution = struct { + median: u64, + p99: u64, + max: u64, + + /// Sorts `samples` in place. + pub fn of(samples: []u64) Distribution { + std.mem.sort(u64, samples, {}, std.sort.asc(u64)); + return .{ + .median = samples[samples.len / 2], + .p99 = samples[(samples.len * 99) / 100], + .max = samples[samples.len - 1], + }; + } +}; + +/// Nanoseconds from `a` to `b` on the awake clock. +pub fn elapsedNs(a: std.Io.Timestamp, b: std.Io.Timestamp) u64 { + return @intCast(@max(@as(i96, 0), a.durationTo(b).nanoseconds)); +} + +/// Nanoseconds as milliseconds. +pub fn ms(ns: u64) f64 { + return @as(f64, @floatFromInt(ns)) / std.time.ns_per_ms; +} diff --git a/bench/shader_hot_reload.zig b/bench/shader_hot_reload.zig new file mode 100644 index 00000000..bdb1e985 --- /dev/null +++ b/bench/shader_hot_reload.zig @@ -0,0 +1,149 @@ +//! C0.3's shader hot reload in < 200 ms (`engine-phase-0-criteria.md`): from +//! the write of an edited `.frag.glsl` to the watcher's `on_recompile` carrying +//! its SPIR-V. +//! +//! The watcher runs with its default configuration on a private directory +//! holding one probe, whose body alternates between two colours so every write +//! is an edit. The first compile, from the watcher's initial scan, is not +//! measured. Needs `glslc`. The verdict is on the maximum, the criterion +//! bounding every reload. +//! +//! `--smoke` measures one reload and writes nothing. `--protocol` records that +//! the run followed the cold-isolated protocol. Writes +//! `bench/reports/shader_hot_reload_.md`. + +const std = @import("std"); +const hot_reload = @import("weld_render").shader_pipeline.hot_reload; +const compiler = @import("weld_render").shader_pipeline.compiler; +const report = @import("report_header.zig"); + +const watch_root = ".weld-cache/bench-shader-watch"; +const probe_path = watch_root ++ "/probe.frag.glsl"; + +const probe_sources = [2][]const u8{ + \\#version 450 + \\layout(location = 0) out vec4 outColor; + \\void main() { outColor = vec4(0.25, 0.5, 0.5, 1.0); } + \\ + , + \\#version 450 + \\layout(location = 0) out vec4 outColor; + \\void main() { outColor = vec4(0.75, 0.5, 0.5, 1.0); } + \\ +}; + +const gate_ns: u64 = 200 * std.time.ns_per_ms; +/// A recompile that has not arrived by then is reported as missing rather than +/// waited for. +const give_up_ns: u64 = 60 * std.time.ns_per_s; + +const Probe = struct { + io: std.Io, + compiles: std.atomic.Value(u32) = .init(0), + failures: std.atomic.Value(u32) = .init(0), + last_ns: std.atomic.Value(u64) = .init(0), + origin: std.Io.Timestamp, +}; + +fn onRecompile(ctx: ?*anyopaque, path: []const u8, spv: ?[]const u8, diag: ?[]const u8) void { + _ = diag; + const probe: *Probe = @ptrCast(@alignCast(ctx.?)); + if (!std.mem.eql(u8, path, probe_path)) return; + probe.last_ns.store(report.elapsedNs(probe.origin, std.Io.Clock.now(.awake, probe.io)), .release); + if (spv == null or spv.?.len == 0) _ = probe.failures.fetchAdd(1, .acq_rel); + _ = probe.compiles.fetchAdd(1, .acq_rel); +} + +/// Written beside the probe and renamed over it, so a scan never reads it half +/// written. +fn writeProbe(io: std.Io, source: []const u8) !void { + const staged = watch_root ++ "/probe.staged"; + const cwd = std.Io.Dir.cwd(); + { + var file = try cwd.createFile(io, staged, .{ .truncate = true }); + defer file.close(io); + var buf: [256]u8 = undefined; + var w = file.writer(io, &buf); + try w.interface.writeAll(source); + try w.interface.flush(); + } + try std.Io.Dir.rename(cwd, staged, cwd, probe_path, io); +} + +fn awaitCompile(io: std.Io, probe: *Probe, count: u32) !void { + const start = std.Io.Clock.now(.awake, io); + while (probe.compiles.load(.acquire) < count) { + if (report.elapsedNs(start, std.Io.Clock.now(.awake, io)) > give_up_ns) return error.NoRecompile; + std.Io.sleep(io, .{ .nanoseconds = std.time.ns_per_ms }, .awake) catch {}; + } + if (probe.failures.load(.acquire) != 0) return error.ProbeFailedToCompile; +} + +pub fn main(init: std.process.Init) !void { + const gpa = init.gpa; + const io = init.io; + const args = try init.minimal.args.toSlice(init.arena.allocator()); + + var smoke = false; + for (args[1..]) |a| if (std.mem.eql(u8, a, "--smoke")) { + smoke = true; + }; + const protocol = report.protocolFlag(args[1..]); + + if (!compiler.isAvailable(gpa, io)) { + std.debug.print("glslc is not on PATH: nothing to measure\n", .{}); + return error.GlslcUnavailable; + } + + const n: usize = if (smoke) 1 else 30; + const samples = try gpa.alloc(u64, n); + defer gpa.free(samples); + + const cwd = std.Io.Dir.cwd(); + try cwd.createDirPath(io, watch_root); + defer cwd.deleteTree(io, watch_root) catch {}; + try writeProbe(io, probe_sources[0]); + + var probe = Probe{ .io = io, .origin = std.Io.Clock.now(.awake, io) }; + var watcher = hot_reload.init(gpa, .{ + .io = io, + .root = watch_root, + .on_recompile = onRecompile, + .callback_ctx = @ptrCast(&probe), + }); + defer watcher.deinit(); + try watcher.start(); + try awaitCompile(io, &probe, 1); + + for (samples, 0..) |*sample, i| { + const before = report.elapsedNs(probe.origin, std.Io.Clock.now(.awake, io)); + try writeProbe(io, probe_sources[(i + 1) % 2]); + try awaitCompile(io, &probe, @intCast(i + 2)); + sample.* = probe.last_ns.load(.acquire) - before; + } + + const dist = report.Distribution.of(samples); + std.debug.print("shader reload: median {d:.3} ms, p99 {d:.3} ms, max {d:.3} ms (gate < {d} ms)\n", .{ + report.ms(dist.median), report.ms(dist.p99), report.ms(dist.max), gate_ns / std.time.ns_per_ms, + }); + if (smoke) return; + + var path_buf: [128]u8 = undefined; + const path = try report.datedPath(&path_buf, io, "shader_hot_reload"); + var file = try cwd.createFile(io, path, .{}); + defer file.close(io); + var buf: [4096]u8 = undefined; + var fw = file.writer(io, &buf); + const w = &fw.interface; + try report.write(gpa, io, w, "Shader hot reload", protocol); + try w.print("Watcher poll interval: {d} ms (default). {d} reloads, one warm-up compile.\n\n", .{ + (hot_reload.Config{ .io = io, .on_recompile = onRecompile }).poll_interval_ms, n, + }); + try w.writeAll("| Median | p99 | Max | Gate | Verdict (max) |\n|---|---|---|---|---|\n"); + try w.print("| {d:.3} ms | {d:.3} ms | {d:.3} ms | < {d} ms | {s} |\n", .{ + report.ms(dist.median), report.ms(dist.p99), report.ms(dist.max), + gate_ns / std.time.ns_per_ms, if (dist.max < gate_ns) "GO" else "NO-GO", + }); + try w.flush(); + std.debug.print("wrote {s}\n", .{path}); +} diff --git a/build.zig b/build.zig index ca0e3cfd..bb140a8c 100644 --- a/build.zig +++ b/build.zig @@ -959,10 +959,10 @@ pub fn build(b: *std.Build) void { // Compilation is the cross-phase invariant. .{ .path = "tests/etch/ast_stable_interface.zig", .etch = true, .dedicated_step = "test-ast-stable" }, // interpreter hot-reload: edit rule body → AST swap → - // behaviour change on the same live world, measured < 500 ms. + // behaviour change on the same live world. .{ .path = "tests/etch/hot_reload_test.zig", .etch = true, .dedicated_step = "test-hot-reload" }, - // full-grammar 500+ line integration reference: parse - // < 50 ms + type-check clean + Level-A interpret. + // full-grammar 500+ line integration reference: parse + + // type-check clean + Level-A interpret. .{ .path = "tests/etch/reference_500_test.zig", .etch = true, .dedicated_step = "test-ref500" }, // `@storage` consumed end to end: the mode reaches the registry, a // sparse component leaves the archetype signature, a rule selects on it @@ -1832,6 +1832,27 @@ pub fn build(b: *std.Build) void { ); render_bench_step.dependOn(&render_bench_run.step); + // C0.3's shader hot reload; needs `glslc`. + const shader_reload_bench_module = b.createModule(.{ + .root_source_file = b.path("bench/shader_hot_reload.zig"), + .target = target, + .optimize = optimize, + }); + shader_reload_bench_module.addImport("weld_render", render_module); + const shader_reload_bench_exe = b.addExecutable(.{ + .name = "shader-hot-reload-bench", + .root_module = shader_reload_bench_module, + }); + b.installArtifact(shader_reload_bench_exe); + const shader_reload_bench_run = b.addRunArtifact(shader_reload_bench_exe); + shader_reload_bench_run.step.dependOn(b.getInstallStep()); + if (b.args) |args| shader_reload_bench_run.addArgs(args); + const shader_reload_bench_step = b.step( + "bench-shader-hot-reload", + "Run the shader hot-reload bench (writes bench/reports/shader_hot_reload_.md)", + ); + shader_reload_bench_step.dependOn(&shader_reload_bench_run.step); + // ------------------------------------------- adler32 baseline bench -- // // Inaugural foundation/simd kernel throughput baseline. No parity target @@ -2037,6 +2058,31 @@ pub fn build(b: *std.Build) void { ); etch_bench_step.dependOn(&etch_bench_run.step); + // C0.2's reference-file parse and interpreter hot reload. + const etch_reference_bench_module = b.createModule(.{ + .root_source_file = b.path("bench/etch_reference.zig"), + .target = target, + .optimize = optimize, + }); + etch_reference_bench_module.addImport("weld_etch", etch_module); + etch_reference_bench_module.addImport("weld_core", core_module); + etch_reference_bench_module.addAnonymousImport("reference_500_lines", .{ + .root_source_file = b.path("tests/etch/reference_500_lines.etch"), + }); + const etch_reference_bench_exe = b.addExecutable(.{ + .name = "etch-reference-bench", + .root_module = etch_reference_bench_module, + }); + b.installArtifact(etch_reference_bench_exe); + const etch_reference_bench_run = b.addRunArtifact(etch_reference_bench_exe); + etch_reference_bench_run.step.dependOn(b.getInstallStep()); + if (b.args) |args| etch_reference_bench_run.addArgs(args); + const etch_reference_bench_step = b.step( + "bench-etch-reference", + "Run the reference-file parse and hot-reload bench (writes bench/reports/etch_reference_.md)", + ); + etch_reference_bench_step.dependOn(&etch_reference_bench_run.step); + // --------------------------------------- Etch → Zig codegen tool --- // // `tools/etch_cook` is a standalone CLI that runs the codegen on a diff --git a/src/etch/test_runner.zig b/src/etch/test_runner.zig index 9676bb67..e99575a2 100644 --- a/src/etch/test_runner.zig +++ b/src/etch/test_runner.zig @@ -539,7 +539,6 @@ test "measure returns a positive duration" { \\ while i < 5000 { i += 1 } \\ } \\ assert(elapsed > 0.0s) - \\ assert(elapsed < 100.0s) \\} ); defer report.deinit(); diff --git a/tests/etch/hot_reload_test.zig b/tests/etch/hot_reload_test.zig index c61f2f43..60e3bf87 100644 --- a/tests/etch/hot_reload_test.zig +++ b/tests/etch/hot_reload_test.zig @@ -1,5 +1,5 @@ -//! Interpreter hot-reload — edit a rule body → AST swap → behaviour change, -//! measured under 500 ms. +//! Interpreter hot-reload — edit a rule body → AST swap → behaviour change. Its +//! < 500 ms is `bench/etch_reference.zig`'s. //! //! There is no in-place AST swap: the Interpreter compiles its own copy of the //! AST and derives its compiled tables eagerly, so a reload re-parses the edited @@ -19,7 +19,6 @@ const EntityId = weld_core.ecs.entity.EntityId; const ComponentId = weld_core.ecs.registry.ComponentId; const Interpreter = weld_etch.Interpreter; const Diagnostic = weld_etch.Diagnostic; -const time = weld_core.platform.time; // Source A and source B differ ONLY in the rule body (+= 1 vs += 5); the // `Counter` declaration is byte-identical so the reload preserves its id. @@ -66,7 +65,7 @@ fn readCounter(world: *World) i64 { return v; } -test "interpreter hot-reload: edit rule body -> AST swap -> behaviour change < 500 ms" { +test "interpreter hot-reload: edit rule body -> AST swap -> behaviour change" { const gpa = std.testing.allocator; var world = World.init(); defer world.deinit(gpa); @@ -89,9 +88,8 @@ test "interpreter hot-reload: edit rule body -> AST swap -> behaviour change < 5 const v_a = readCounter(&world); try std.testing.expectEqual(@as(i64, 3), v_a); - // The hot-reload critical section: edit to source B, re-parse, re-compile on - // the SAME world, then the first tick under the new rule. - const t0 = time.nowNanos(); + // Edit to source B, re-parse, re-compile on the SAME world, then the first + // tick under the new rule. var pr_b = try weld_etch.parseSource(gpa, src_b); defer pr_b.deinit(gpa); try std.testing.expectEqual(@as(usize, 0), pr_b.diagnostics.len); @@ -100,19 +98,12 @@ test "interpreter hot-reload: edit rule body -> AST swap -> behaviour change < 5 var interp_b = try Interpreter.compile(gpa, &pr_b.ast, &world); defer interp_b.deinit(); _ = try interp_b.runFor(&world, 1); - const elapsed_ns = time.nowNanos() - t0; // The behaviour changed on the SAME entity of the SAME live world: the new // rule adds 5, so 3 -> 8 and the old += 1 rule no longer runs. const v_b = readCounter(&world); try std.testing.expectEqual(@as(i64, 8), v_b); try std.testing.expect(v_b != v_a); - - std.debug.print( - "[hot-reload] edit -> AST swap -> first new tick: {d} ns ({d:.3} ms)\n", - .{ elapsed_ns, @as(f64, @floatFromInt(elapsed_ns)) / std.time.ns_per_ms }, - ); - try std.testing.expect(elapsed_ns < 500 * std.time.ns_per_ms); } /// Source A's `Counter`, one more field. Same name, different layout. diff --git a/tests/etch/reference_500_test.zig b/tests/etch/reference_500_test.zig index 147f6b46..d7b45e10 100644 --- a/tests/etch/reference_500_test.zig +++ b/tests/etch/reference_500_test.zig @@ -3,7 +3,7 @@ //! One 500+ line file mixing EVERY v0.6 construct: the Level-A foundations, the //! seventeen domain constructs, Level-C scene/prefab, generics and async. It is //! the at-scale integration proof: -//! • PARSE the whole file < 50 ms (measured median, the headline gate); +//! • PARSE the whole file clean — its < 50 ms is `bench/etch_reference.zig`'s; //! • TYPE-CHECK the whole file clean (every construct coexists in one unit); //! • INTERPRET the Level-A behaviour (a dedicated `RefProbe` rule ticks the //! live world — the byte-exact interp behaviour at scale). @@ -17,7 +17,6 @@ //! mirrors the established per-program world-state-vs-serialized-IR separation. const std = @import("std"); -const builtin = @import("builtin"); const weld_etch = @import("weld_etch"); const weld_core = @import("weld_core"); @@ -26,7 +25,6 @@ const EntityId = weld_core.ecs.entity.EntityId; const ComponentId = weld_core.ecs.registry.ComponentId; const Interpreter = weld_etch.Interpreter; const Diagnostic = weld_etch.Diagnostic; -const time = weld_core.platform.time; const reference_src = @embedFile("reference_500_lines.etch"); @@ -38,7 +36,7 @@ fn countLines(s: []const u8) usize { return n; } -test "reference_500_lines: ≥500 lines, parses clean, type-checks clean, parse median < 50 ms" { +test "reference_500_lines: ≥500 lines, parses clean, type-checks clean" { const gpa = std.testing.allocator; const lines = countLines(reference_src); @@ -68,34 +66,6 @@ test "reference_500_lines: ≥500 lines, parses clean, type-checks clean, parse } } try std.testing.expectEqual(@as(usize, 0), diags.items.len); - - // PARSE-TIME — median of K passes, gate < 50 ms. - const K = 50; - var samples: [K]u64 = undefined; - var k: usize = 0; - while (k < K) : (k += 1) { - const t0 = time.nowNanos(); - var p = try weld_etch.parseSource(gpa, reference_src); - const dt = time.nowNanos() - t0; - p.deinit(gpa); - samples[k] = dt; - } - std.mem.sort(u64, &samples, {}, std.sort.asc(u64)); - const median = samples[K / 2]; - std.debug.print( - "[ref500] parse median ({s}): {d} ns ({d:.4} ms) over {d} passes\n", - .{ @tagName(builtin.mode), median, @as(f64, @floatFromInt(median)) / std.time.ns_per_ms, K }, - ); - // The < 50 ms gate is a ReleaseSafe verdict: a parse-time verdict is never - // taken in Debug, where the parser walks 5-10× slower. So the strict gate is - // asserted in a release mode alone and Debug guards only against a - // pathological regression. The re-bench is - // `zig build test-ref500 -Doptimize=ReleaseSafe`. - if (builtin.mode == .Debug) { - try std.testing.expect(median < 300 * std.time.ns_per_ms); - } else { - try std.testing.expect(median < 50 * std.time.ns_per_ms); - } } test "reference_500_lines: Level-A interpret — the RefProbe rule ticks the live world" { From 027b9b59437546ba99c0e82f02ab290aac530540 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 27 Sep 2026 17:02:16 +0200 Subject: [PATCH 100/141] test(bench): fail the etch reload bench when a reload runs nothing A reload measured at 4 microseconds in ReleaseFast needed proof that it ran. Each reload ticks once, so the entity's field after the last reload is known in advance, and the bench now fails when it differs. Measured: with the reload's tick removed it fails ReloadDidNotRun. Co-Authored-By: Claude Opus 5.5 --- bench/etch_reference.zig | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/bench/etch_reference.zig b/bench/etch_reference.zig index 18415871..c92f9021 100644 --- a/bench/etch_reference.zig +++ b/bench/etch_reference.zig @@ -19,12 +19,15 @@ const weld_core = @import("weld_core"); const report = @import("report_header.zig"); const World = weld_core.ecs.world.World; +const EntityId = weld_core.ecs.entity.EntityId; const ComponentId = weld_core.ecs.registry.ComponentId; const Interpreter = weld_etch.Interpreter; const Diagnostic = weld_etch.Diagnostic; const reference_src = @embedFile("reference_500_lines"); +/// What each rule-body reload adds to `Counter.value` in its one tick. +const counter_steps = [2]i64{ 1, 5 }; const counter_bodies = [2][]const u8{ \\component Counter { value: int = 0 } \\rule tick(entity: Entity) @@ -100,16 +103,30 @@ const Session = struct { } }; +/// An `int` field of the one entity `benchReload` spawns. +fn readField(world: *World, component: []const u8, field: []const u8) !i64 { + const loc = world.dynamicLocation(EntityId{ .index = 0, .generation = 0 }) orelse return error.MissingEntity; + const arch = world.dynamicArchetype(loc.archetype_idx); + const cid = world.registry.idOf(component) orelse return error.MissingComponent; + const slot = arch.componentSlot(arch.chunks.items[loc.chunk_idx], arch.componentIndex(cid).?, loc.slot); + const fd = world.registry.findField(cid, field) orelse return error.MissingField; + var v: i64 = 0; + @memcpy(std.mem.asBytes(&v), slot[fd.offset .. fd.offset + 8]); + return v; +} + /// Reloads `sources[i % sources.len]` onto a world first running `sources[0]` -/// with one entity carrying `entity_component`. +/// with one entity carrying `entity_component`, and returns the entity's +/// `field` after the last reload's tick. fn benchReload( gpa: std.mem.Allocator, io: std.Io, sources: []const []const u8, entity_component: []const u8, + field: []const u8, warmup: usize, samples: []u64, -) !void { +) !i64 { var world = World.init(); defer world.deinit(gpa); var sessions: [2]Session = undefined; @@ -132,6 +149,7 @@ fn benchReload( live[slot] = true; if (i >= warmup) samples[i - warmup] = report.elapsedNs(t0, t1); } + return readField(&world, entity_component, field); } fn writeReport(gpa: std.mem.Allocator, io: std.Io, rows: []const Row, protocol: bool) ![]const u8 { @@ -180,8 +198,13 @@ pub fn main(init: std.process.Init) !void { defer gpa.free(reference_samples); try benchParse(gpa, io, warmup, parse_samples); - try benchReload(gpa, io, &counter_bodies, "Counter", warmup, counter_samples); - try benchReload(gpa, io, &.{reference_src}, "RefProbe", warmup, reference_samples); + // Every reload's tick ran the program it compiled, or these differ. + const counter_value = try benchReload(gpa, io, &counter_bodies, "Counter", "value", warmup, counter_samples); + var counter_expected: i64 = 0; + for (0..warmup + counter_n) |i| counter_expected += counter_steps[(i + 1) % 2]; + if (counter_value != counter_expected) return error.ReloadDidNotRun; + const probe_ticks = try benchReload(gpa, io, &.{reference_src}, "RefProbe", "ticks", warmup, reference_samples); + if (probe_ticks != @as(i64, @intCast(warmup + reference_n))) return error.ReloadDidNotRun; const rows = [_]Row{ .{ .name = "parse, reference file", .gate_ns = parse_gate_ns, .dist = .of(parse_samples), .samples = parse_n }, From 525c47f56c0123a052a3933a6b8849e5cec910d9 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 27 Sep 2026 17:04:31 +0200 Subject: [PATCH 101/141] =?UTF-8?q?docs(brief):=20record=20the=20time-boun?= =?UTF-8?q?d=20class=20swept=20under=20the=20new=20=C2=A713?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sweep by role, the census it corrected, the shader test's blind oracle closed on the way, the six counter-factuals and the non-vacuity one, the dev-run bench figures, and the six cases raised untouched. CLAUDE.md follows: the class entry, and the IPC bounded-receive entry and residual, whose premise the rewritten §13 withdraws. The two bench reports are the first runs of their benches, dev runs, not opposable. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 8 +- bench/reports/etch_reference_2026-09-27.md | 14 ++++ bench/reports/shader_hot_reload_2026-09-27.md | 12 +++ briefs/m1.d-phase-1-debt.md | 75 +++++++++++++++++++ 4 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 bench/reports/etch_reference_2026-09-27.md create mode 100644 bench/reports/shader_hot_reload_2026-09-27.md diff --git a/CLAUDE.md b/CLAUDE.md index 1f93c5bf..9259a1db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,7 +122,7 @@ commit, so a milestone still in review has no row here. - **M1.D.18 — a table component under sustained churn grows its chunk count until the DISPATCH FAILS (opened at M1.B/G11, measured, mechanism named)**. `Archetype.removeSwap` compacts INSIDE one chunk only — `chunk_idx` is fixed and the last slot OF THAT CHUNK fills the hole — and `archetype.zig` has NO release path: no `chunks.pop`, no `swapRemove`, no `entity_count == 0` test, no shrink. So the count follows the CUMULATIVE number of adds and never the live population. Measured by `bench/ecs_hybrid_crossover.zig` at `payload=64B`, fraction 1.0, churn 60/carrier/s: **128 chunks at the first tick, 8200 within the window**, for 20 000 entities over 8 archetypes — 2.4 entities per chunk where the payload allows ~156 — after which `jobs.Scheduler.dispatchBatch` returns `error.TooManyChunks` at its `workers × 8192` capacity. **The consequence is a HARD dispatch failure, not slowness**, and the population that reaches it is any durable churning load — precisely the load `@storage(.sparse)` exists to serve, whose range count is CONSTANT in the same report row. **Invisible to C0.1, which never churns.** NOT fix-as-you-go and the reason is structural, not convenience: the remedy is inter-chunk compaction or a partial-chunk free list, and **moving an entity between chunks invalidates the `chunk_ptr` of every live `ComponentRef`** — the type M1.B/G5 built, whose table arm deliberately holds a chunk pointer — so the fix touches a contract this milestone just froze, and it interacts with `chunkAt(i)`'s stability during a dispatch. That is a milestone, not a commit. Owner: the chunk-lifecycle owner; Guy carries the corpus side. **THE BLOCKER THIS ENTRY RECORDS NO LONGER EXISTS, measured at M1.D/S5/G6**: `M1.D.21` removed `chunk_ptr` in S2/G1 of that same milestone, one session BEFORE this sentence was written, so `ComponentRef` is `{entity, component_id, mutable}` and re-resolves at every access. Second deferral condition satisfied by work from elsewhere, after `M1.D.12`. And the half of the remedy that is REUSE — not compaction — landed at S5/G7 as `Archetype.first_partial`: it moves NO entity, because `removeSwap` leaves a dense prefix and free space at the tail, so its invalidation set is empty. Its benefit is deliberately UNMEASURED and no figure is offered, the instrument being absent (`DynamicQuery` exposes neither `chunkAt` nor `chunkCount`). - **`M1.D.14` gets its SEVENTH and EIGHTH measurements, and its first treatment (M1.B/G11)**. That plan row already carries this debt by name — *"le job `bench-ecs-smoke (windows-2025)` a une queue de durée qui franchit son budget `timeout-minutes: 10`"* — with six measurements at near-constant code (5m08, 6m52, 8m19, 9m21, 9m28, 9m38), factor 1.9, two cancellations and two green re-runs at the SAME SHA, and it names raising the budget as one of two options while taking neither. **M1.B adds 9m37 (passed, 23 seconds of headroom) and 11m28 (cancelled) and TAKES that option**: 10 → 20 minutes. M1.B also made the job heavier, measured rather than assumed — the step runs `zig build bench-ecs`, whose run step depends on the install step, so it compiles EVERY installed artifact, verified by deleting `zig-out/bin/` and watching `ecs-hybrid-crossover-bench` reappear beside `ecs-benchmark`. What the raise does NOT remove is the runner's intrinsic variance, which `M1.D.14`'s own analysis already establishes as the cause, nor the standing question of whether the Windows bench belongs in the PR matrix. *An earlier draft of this entry opened a parallel record under a new number; a debt treated under any name but its own stays open in the document that carries it.* Raised again to 30 minutes at M1.D/S5; detail in the brief. - **A CELL OF THE CI MATRIX HANGS, TEN TIMES MEASURED, AND THE CLASS HAS NO HOME IN THE CORPUS (opened at M1.B/G11, needs a number)**. Distinct from `M1.D.14`, which carries the DURATION of the bench job: this is the PENDING of a matrix cell that gates merges. **Cell:** `build-and-test (windows-2025, ReleaseSafe)`, both precisions. **Signature:** `error: test runner failed to respond for ~1m`, with **zero** occurrences of the sibling class `failed without output` — the two have never been co-present. **Count: TEN**, all on that cell, every one exonerated by a green re-run at the SAME SHA (three recorded before M1.B, two at M1.B/G10-G11, three at M1.B/P3-P4, one at M1.E/G12 on `6a2bb8e`, one at M1.E on `387ab97`). **The ninth, at M1.E/G12 on `6a2bb8e`, is the most informative the class has produced and it is the LARGEST BY AN ORDER**: `2164/2196 tests passed (32 skipped)` against a declared windows floor of 2250 gives **54 TESTS LOST**, where the previous counts were 1, 5, 6, 8 and 14. It ran 38.1 min against a 55-minute budget, so it is NOT the sibling timeout recorded below it — that one has every step green and no lost test — and the log carries ZERO `error: '…' failed:` lines, so no assertion fired. Fifty-four tests is a whole step of substance rather than a straggler, which narrows what can be hanging: the class is not a slow tail on a small step. **THE THIRTEENTH, at M1.A on `565012c`, hangs TWICE IN ONE RUN and the new discriminant reads BOTH**: signature present twice, sibling absent, zero assertions, `2253/2285 tests passed (32 skipped)` against a declared floor of 2288 — **THREE tests lost across two hung steps** — 52.6 minutes against 55 with conclusion `failure`. The two `failed command:` lines name `fecde19354ea9ccbd9ecb5d20cdb00ac` and `2fe9c3b586059afa4881744abc5715ef`: **two different executables in ONE build**, which is the within-run twin of the within-SHA comparison the twelfth produced. Four distinct identities are now recorded across three attempts and no two agree. **CLOSED at M1.D/S5 as `M1.D.19`**: a Zig 0.16 defect — the windows spawn leaves a test's pipe ends inheritable, so a concurrent compiler holds its end of stream — worked around by `--test-timeout 180s` on the matrix `zig build test`. Detail in the brief; the upstream report, not filed, is in `briefs/artifacts/`. The series of losses is 1, 5, 6, 8, 14, 54, 2, 1, 3. **THE TWELFTH, at M1.A on `e054b26`, ties the smallest loss and adds a collateral the class did not carry**: `2255/2287 tests passed (32 skipped)` against a declared floor of 2288 gives **ONE test lost**, signature present once, sibling class absent, zero `error: '…' failed:` lines, and **53.0 minutes against a 55-minute budget with conclusion `failure` and not `cancelled`** — which excludes `M1.D.27` on both of its discriminants at once rather than on duration alone. The series of losses is now 1, 5, 6, 8, 14, 54, 2, 1. **The collateral is that two debts met on one job**: the run carries `Zig cache is 11222302720 bytes, over the 10737418240 cap — SKIPPING the final save`, which is `M1.D.10`'s mechanism, and a skipped final save leaves the NEXT run on that cell colder, which is `M1.D.27`'s trigger. Neither debt is new; their interaction is recorded nowhere else. **IT FAILED ITS FIRST SAME-SHA RE-RUN, and that re-run produced a THIRD DISCRIMINANT this entry records as impossible.** Attempt 2: signature once, sibling absent, zero assertions, `2256/2287 (31 skipped)` against 2288 — **one test lost again**, where every previous pair of failures at one SHA had lost DIFFERENT counts, which is the shape an implicated test would produce. Refuted by measurement: this entry states that *"naming the step from the log does not work"*, which is true of the step's SOURCE name and FALSE of its identity — **the `failed command:` line immediately following the `failed to respond` message carries the hung step's build-cache hash**, and the two attempts differ (`01a58047…` against `c810df3f…`). Two DIFFERENT executables hung at one SHA, so no test is implicated, and the equal loss explains itself: both hung steps sit in the contiguous family whose neighbours all report `0 pass, 1 skip (1 total)`, where a hang costs exactly one test whichever member it hits. *The count was never the discriminant; the identity is, and it is one grep away in every log this class has already produced.* **THE ELEVENTH, at M1.A on `3f22cf6`, is the smallest loss the class has produced and the cleanest measurement of it**: `2245/2277 tests passed (32 skipped)` against a declared floor of 2279 gives **2 TESTS LOST**, with the signature present once, the sibling class absent, zero `error: '…' failed:` lines, and 52.7 minutes against a 55-minute budget — so `M1.D.27` is excluded by duration and by conclusion (`fail`, not `cancelled`). The series of losses is now 1, 5, 6, 8, 14, 54 and 2, which continues to refute any single implicated test. Cleared by a re-run at the SAME SHA. **THE TENTH, at M1.E on `387ab97`, is the first to hang TWICE IN ONE RUN**: two `failed to respond` twelve minutes apart (19:49:14 and 20:01:15 UTC) on two DIFFERENT test executables, and `301/304 steps succeeded (2 failed)` accounts for exactly those two. **And the loss stopped following the step count**: `2217/2249 tests passed (32 skipped)` against the same 2250 floor gives **ONE test lost for TWO hung steps**, so at least one of the two cost no test at all — where the counts so far had been 1, 5, 6, 8, 14 and 54, always for a single step. That asymmetry is NOT explained here: the natural reading, a runner that blocks after its last result is already reported, is plausible and unmeasured, and this class has already paid for two hypotheses issued on a plausible reading. What it does strengthen is the `0664f28` discriminant — two different steps, in one run, on a commit whose diff is comments and two Markdown files. It ran 40.2 min, SHORTER than the sibling timeout because it aborted on the hang rather than finishing, carries ZERO `error: '…' failed:`, and exonerated on the FIRST re-run. **AT ONE SHA (`0664f28`) THE CELL FAILED THREE TIMES AND LOST THREE DIFFERENT COUNTS — 14, 1 and 5 tests — hence three different step sets.** That is a discriminant the class did not previously have, and it is the strongest evidence yet that no single test is implicated: a test that hangs deterministically blocks the same step every time and loses the same number. **And the frequency moved**: the five occurrences preceding that SHA each exonerated on the FIRST same-SHA re-run, where this one took two — f64 green on re-run 1, f32 failing again with a third lost count and green only on re-run 2. Two collateral facts from the same investigation: `pre-push` runs `zig build test -Doptimize=ReleaseSafe` (`lefthook.yml:31`), so the failing cell's MODE is green on the dev machine at every push; and pushing over an in-flight run marks that run `failure` with no evidence of its own (`aa7416c`), which is the recorded status-predicate hazard seen from the other side. **Two discriminants, and only one of them works today.** (1) `declared − collected` from the `Build Summary` line `--summary all` already produces: at the M1.B occurrence, `302/304 steps succeeded (1 failed); 2103/2135 tests passed` against a declared windows floor of 2143 gives **8 tests lost**, so the hung step holds eight — a SIZE, computed and not inferred. (2) Naming the step from the log **does not work**: a hung step emits no output at all, so the per-step summary that would name it is exactly what is missing, and `--log-failed` returns the aggregate. Naming it needs either a per-step timeout that identifies its target or a step-by-step run. What DOES survive is a negative discriminant used at G11: a step that printed its own report AND its `failed command:` artifact has COMPLETED, which is how the M1.B/G10 scheduler-dispatch tests were exonerated without a re-run. **The corpus carries no foyer for this**: the signature returns zero occurrences across the corpus (measured), so ten occurrences on a merge-gating cell live only in a succession of briefs. -- **`M1.D.29` — CLOSED at M1.D/S5: `win32_thread_safety_test`'s 30 s wall-clock bound is gone**; it only detected a hang, and `--test-timeout`, passed by every CI matrix cell, names a hung test. Without that flag a running test has no deadline at all (Zig 0.16, measured). **Open, for arbitration: the rest of the class** — duration gates in correctness tests, home-made hang detectors, the 5 s `test_watchdog`, the Wayland twin — against `engine-zig-conventions.md` §13's required internal timeout, and the pre-push hook and `test-runtime-env` passing no `--test-timeout`. Detail in the brief, S5/G9 terdecies. +- **`M1.D.29` — CLOSED at M1.D/S5: `win32_thread_safety_test`'s 30 s wall-clock bound is gone**; it only detected a hang, and `--test-timeout`, passed by every CI matrix cell, names a hung test. Without that flag a running test has no deadline at all (Zig 0.16, measured). **The class was ruled and swept at M1.D/S5**: `engine-zig-conventions.md` §13 was rewritten, `--test-timeout` now runs wherever the suite does, pure hang detectors are gone, child-process waits are wide and kill on the bound, and duration gates left the tests for `bench/etch_reference.zig` and `bench/shader_hot_reload.zig`. **Raised to Guy and untouched**: the 5 s watchdog of the eight files that register a scheduler, whose dump is the suite's only scheduler-state diagnostic; `sleepPrecise`'s two tests; the async loader's `ticks >= 1`; `threading_test`'s spin "so the parent has time"; `process.zig`'s 20 ms pause before a kill; and `waitNonblock` answering 0 for a child killed by a signal. Detail in the brief, S5/G9 terdecies and quindecies. - **The sparse-driven disjunctive path's first-use allocation is BOUNDED but not ISOLATED (opened at M1.1.15-era, measured at M1.B/G10)**. The entity-keyed disjunctive path allocates an `AutoHashMapUnmanaged` the FIRST time a sparse-driven term appears and reuses it after, so steady state is allocation-free like `merge_cursors` — the same shape as `contact_constraint.zig`'s `deferred` residual. `bench/ecs_hybrid_crossover.zig` now bounds it: the sparse arm's first-tick allocation count is **constant at 3** across all 28 cells of every configuration, where the table arm's grows **2 → 183** with churn. **What the bench does NOT do is isolate the map from the other two allocations on that tick** — it establishes that the quantity is a small constant and does not grow with fraction or churn, which is what the debt needed, and not which of the three is the map. Stated rather than implied. - **`D-M0.2.1-c01-baseline-investigation` is CLOSED, and it was tracking a phantom (closed at M1.B/G11)**. It followed the divergence between C0.1's 3.74 ms and a "14.2 ms M0.1 baseline" that **no M0.1 artifact carries**: the squash commit body, the annotated tag and `briefs/M0.1-ecs-full.md:316` all read **3.84 ms in ReleaseFast**, and `git log --all --grep=14.2` returns two commits of which the earlier is `df67e1c` (M0.2.1 itself). The real delta is **2.6 %**. Closed by naming it here and in the M1.B brief plus a head note on the dated report — `engine-audit-checklist.md` carries zero occurrences of the identifier (measured), so the debt lived only in a brief, and a brief is a document of its commit and is not edited. - **`build-and-test (windows-2025, ReleaseSafe)` HAD NO HEADROOM AT ITS 55-MINUTE BUDGET, and a comment-only commit was enough to spend it (opened at M1.E/G11, measured, mechanism named; the Release budget is 75 minutes since `c3e6d316`, Debug 35)**. **NOT the hang class**, whose count stays at eight: there, tests are LOST and the signature is `test runner failed to respond`. Here the job's conclusion is `cancelled` and **EVERY step succeeded**, `zig build test` and `Complete job` included — the job ran to its end and the wall arrived as it finished. Duration **55.0 min against `timeout-minutes: 55`** (`ci.yml:216`), and `fail-fast: false` means the other fourteen jobs ran green; `ci-gate` failed only because a required cell was not `success`. **Located step by step**: `zig build` 11.0 min, **`zig build test` 38.0 min**, the three cache steps 4.8 min together — so `M1.D.10`'s cache purging is NOT the cause here. The same cell on the two preceding commits of the same branch: `fb3d912` **12.5 min**, `7a7fc1b` **37.6 min**. A factor of 4.4 on a diff that is comments only. **The mechanism is the commit's own shape**, and it is `M1.D.8`'s transposed to another cell: the changed files are the tree's most-depended-upon Tier 0 headers — `world.zig`, `interp.zig`, `query.zig`, `observers.zig`, `archetype.zig`, `hybrid_query.zig`, `registry.zig`, `sparse_storage.zig`, both schedulers — and a comment edit changes a file's hash, so every compile step whose closure reaches them recompiles. A documentation pass over Tier 0 therefore costs the same cache as a refactor of it. **THE AMPLITUDE IS ATTRIBUTED, by a re-run at the SAME SHA whose only difference is that the first attempt's cache save had run**: attempt 2 took **10.3 min** where attempt 1 took 55.0, with `zig build test` at **3.2 min against 38.0** — a factor of **11.9** — and `zig build` at 4.0 against 11.0. So 34.8 of those 38 minutes were COLD-CACHE COMPILATION and not test execution, measured on one commit with the apparatus held fixed, which is `M1.D.8`'s finding with a clean before/after instead of a comparison across runs. The runner's intrinsic variance is NOT needed to explain it. The first of the two options was taken at `c3e6d316`: the Release budget went from 55 to 75 minutes. **AND A WARM CACHE IS NOT GUARANTEED BY A RE-RUN**: at M1.E on `387ab97`, another comment-only Tier 0 commit, the f64 leg of the same cell hung (the hang class above), and its re-run — which therefore started from whatever the FAILED attempt had left — took **53.1 min**, passing with **1.9 minutes of headroom**. The two classes appeared on one cell in one run, the hang on attempt 1 and the cold-cache cost on attempt 2, which does not merge them — one loses tests and prints `failed to respond`, the other has every step green and pays in minutes — but it is the second commit in two milestones where a comment-only edit to Tier 0 headers put this cell within two minutes of its wall. Owner: unassigned. @@ -167,14 +167,14 @@ commit, so a milestone still in review has no row here. - **M1.1.13.1 open items, all named and none deferred debt**: (a) **kinematic-contact stiffening** — the pinned Box2D source stiffens the contact hertz 2× for STATIC endpoints only, keying on the absence of a solver body, which a kinematic body has; extending it to kinematic contacts would silently cover every platform, lift and moving door, none of which the measurement behind the factor covers. Named open item, not a default. (b) **speculative inter-frame contacts** — the `min(s + penetration_slop, 0)` dead zone is an INTERIM persistence mechanism and a documented divergence from the source, which gets the same effect from speculative manifold margins (`4·slop`); the day speculative contacts land, that offset goes to zero and restores exact parity. (c) **the reference's `restitution == 0` early-out is not ported** — the frozen predicate has two clauses, and with `e == 0` the update reduces to a Gauss-Seidel remainder the relax sweep has already driven to ≈ 0, not a bounce. - **Open design item — faithful port of the reference friction model (M1.1.7 RD-3, re-pointed at M1.1.13.1)**: order (friction first, non-penetration last, cone clamped with the previous iteration's λₙ) + per-manifold aggregation (one tangential budget for the patch) + twist friction around the normal, **together and never in fragments**. The three were measured individually in scratch at M1.1.7 and each made the five-box stack worse at f32 (tables in `briefs/M1.1.7-solver-ngs-position.md` RD-3, f32 and f64, 600/1200/1800 ticks); they are pieces of a model that is only coherent whole. Needs a dedicated milestone. The M1.1.6 comment crediting "Jolt order" to normal-then-friction was a false attribution and died with `velocity_solver.zig`; **the divergence itself retired at M1.1.13.1** — the pinned Box2D v3 source is normal-first too, and friction now runs in the relax sweep only, after every normal point of its constraint. What that scheduling change made visible is the quantity to watch: the creation-order permutation spread grew from under 1e-3 m to 6.6e-3 m laterally while TIGHTENING four orders along the contact normal (2e-8 m), which is the settling transient of an order-sensitive Gauss-Seidel sweep and the thing a whole-model port would be expected to improve. - **NGS resting fixed point grows with chain length (M1.1.8 RD-2) — pre-existing, exposed, unowned**: M1.1.7 RD-1 established that the slop is a fixed point approached from above, measured on a single box. At six chained contacts the attained value no longer returns under `slop + 16·floatEps·6` within 200 ticks, at either precision, WITH OR WITHOUT sleeping — the never-slept control fails identically, which is the proof the characteristic is pre-existing and not a wake artefact. Five boxes settle at 0.004999 (f32), under the bound; the sixth crosses it. To be characterised BEFORE joints (M1.1.16), which lengthen chains. Not scoped to a milestone yet. -- **IPC crash-recovery tests assert BEHAVIOUR, not latency (decided at M1.1.9)** — four wall-clock assertions were removed from `tests/ipc/crash_recovery.zig` and every behaviour assertion kept: `expectError(error.UnexpectedEof)` proves detection, `exit_code != null` + `== 0` prove the clean exit, `result.complete` + `replayed == 3` prove the replay. The reason is not that the bounds were too tight: `try expect(nowMs() - t0 < 100)` **is not a hang guard at all**, since it runs only once the `recvFrame` loop has already returned — an EOF that never arrived would hang forever and the assertion would never fire. What it measured was kernel scheduling latency between `kill` and EOF, with no Weld code on that path, on a machine the test suite itself saturates. MEASURED with a temporary probe: 0-1 ms idle, 18-64 ms at load average 5, 14-67 ms at load average 32-91, and one crossing of the 100 ms bound during a pre-push run, which is what refused the push. C0.4 carries no figure — its metric is functional and its verification names the file — so nothing normative was weakened; the numbers live in `engine-phase-0-plan.md:371` and `validation/s6-go-nogo.md` G4, and the M0.7 brief's acceptance line ("detection < 100 ms, replay < 500 ms aggregate", `briefs/M0.7-ipc-scm-rights-windows-fuzz.md:88`) is a closed record that is NOT patched. A duration is a benchmark, not a test (`engine-zig-conventions.md` §13). **Residual, verified not assumed:** the two remaining `recvFrame` sites have no timeout of any kind — `connection.recvFrame` has neither a non-blocking variant nor a deadline (`src/core/ipc/connection.zig:123` and `:157` are the only receive entries), and the IPC test targets are built by a loop that does NOT wire `test_watchdog` (only the `test_specs` loop does, `build.zig:618`). A hang there does not stall the other IPC cases — one exe per case, by deliberate design — but the build step never completes, so `zig build test` hangs as a whole. Closing §13 for real needs a bounded receive primitive in Tier 0 IPC; owned by whoever next opens that surface, not by a physics milestone. +- **IPC crash-recovery tests assert BEHAVIOUR, not latency (decided at M1.1.9)** — four wall-clock assertions were removed from `tests/ipc/crash_recovery.zig` and every behaviour assertion kept: `expectError(error.UnexpectedEof)` proves detection, `exit_code != null` + `== 0` prove the clean exit, `result.complete` + `replayed == 3` prove the replay. The reason is not that the bounds were too tight: `try expect(nowMs() - t0 < 100)` **is not a hang guard at all**, since it runs only once the `recvFrame` loop has already returned — an EOF that never arrived would hang forever and the assertion would never fire. What it measured was kernel scheduling latency between `kill` and EOF, with no Weld code on that path, on a machine the test suite itself saturates. MEASURED with a temporary probe: 0-1 ms idle, 18-64 ms at load average 5, 14-67 ms at load average 32-91, and one crossing of the 100 ms bound during a pre-push run, which is what refused the push. C0.4 carries no figure — its metric is functional and its verification names the file — so nothing normative was weakened; the numbers live in `engine-phase-0-plan.md:371` and `validation/s6-go-nogo.md` G4, and the M0.7 brief's acceptance line ("detection < 100 ms, replay < 500 ms aggregate", `briefs/M0.7-ipc-scm-rights-windows-fuzz.md:88`) is a closed record that is NOT patched. A duration is a benchmark, not a test (`engine-zig-conventions.md` §13). Its residual — `recvFrame` has no deadline, so a hung receive hung `zig build test` — closed at M1.D/S5 with §13's rewrite: `--test-timeout` names the hung test and runs the next. - **Public surface precision boundary — ADDRESSED at M1.1.15, and re-pointed (opened M1.1.9)**: the entry as written said the widening was one grouped decision over `BodyDescriptor`, the interface pose, the query results and the ECS `Transform`, owned by the freeze milestone. `engine-physics-queries.md` §1.11.8 was REWRITTEN on 2026-08-21 and reframes it: there are THREE scalars, the public surface follows the WORLD scalar rather than a literal type, `large_world` (`ARCH-022`) is what moves it, and `large_world = true` IMPLIES `-Dphysics_f64` while the converse stays legitimate and distinct. M1.1.15 delivered the part that could be delivered without `large_world`: ONE named crossing (`forge/api/precision.zig`) replacing four diverged private helpers, a named `WorldReal` so no crossing spells a literal `f32`, and a two-halved mechanical verification — a lint rule for the narrowing direction, the `f64` type system for the widening one. **What remains is `large_world` itself**, which crosses `Transform`, the hierarchical `TransformSystem`, serialisation and Render, and is a project of its own. Owner: unassigned; the first non-forge module needing the world scalar meets it first. - **M1.D.13 — `proxyOf` was linear and step 2 called it twice per retained pair (opened at M1.1.15, CLOSED at M1.1.15.1)**: replaced by a dense `BodyId`-keyed index, `proxyOf` O(1). Closed by MEASUREMENT and not by inspection: with P held constant at 2 209 and N moved ×3.72 by a field of far statics that pair with nothing, the frame time moves ×1.14 (ReleaseFast) — a Θ(P·N) step 2 would demand 33.1 million extra endpoint resolutions per frame for a measured delta of 70 µs, i.e. 2.1 picoseconds each. `bodies[i].proxy` stays AUTHORITATIVE and the index is DERIVED through the single point `rebindProxy`; growing the floor instead of adding far statics moves N and P together and discriminates nothing, which is the trap the first version of the bench fell into. Instrument: `bench/physics_forge_3d_integration.zig`; record: `bench/results/physics_forge_3d_integration.md`. - **M1.D.12 — the world scalar has a forge-local home and an engine-wide meaning (opened at M1.1.15)**: `forge/api/precision.zig` is FORGE's single crossing point and its header says so honestly, but `WorldReal` describes the ENGINE. The day `large_world` lands, Kinesis, Render and scene serialisation all need it and none of them can import `weld_forge`, so there will be either a SECOND point — the exact defect M1.1.15 spent itself removing — or a MOVE. Left deliberately: the cost of moving is a file relocation plus rerouting eleven internal call sites, with NO API change, so it is cheap now and cheap later and pointless before a second consumer exists. Owner: the milestone delivering `large_world`, or the first non-forge module needing the world scalar — Kinesis at M1.2.x will meet it first. - **The two preconditions of the M1.1.15.2 freeze are DECIDED (opened at M1.1.15, closed at M1.1.15.1)**: `ModuleContext` is minted at `src/core/module_context.zig` with four fields (`engine-tier-interfaces.md` §0), so the freeze can type its assert block's first entry; and the pose setters are settled `void` and allocation-free, the reservation seam having been taken rather than the error channel — `Broadphase.update` is infallible on a moved-log uniqueness invariant (`engine-tier-interfaces.md` §1). Both were narrative in this table and are now pointers, per the corpus convention. What the freeze still owes itself: `WELD_PHYSICS_PROTOCOL_VERSION`, the THIRTY surface guards — thirty and not twenty-seven, §12 disambiguating the two counts — and `api.JointDescriptor`/`api.JointId`, which are declared nowhere and which its own plan line presumes. - **Far-field conditioning is characterised, not fixed (M1.1.9)**: `engine-physics-forge.md` §1.11.4 bis. The normal's LENGTH is a structural invariant at any distance because the kernel normalises it. Its ORIENTATION degrades as `ulp(distance) / radius` at `f32` — about 1e-4 at 5 km on a unit shape — and a rim-grazing hit/miss decision becomes unresolvable inside that same band, about 4 mm at 50 km. MEASURED, in f32 on origin (−3000.4, −3999.7, 0) direction (0.6, 0.8, 0), radius 1: current kernel 0.999915421, general quadratic in f64 arithmetic 0.999999999, general quadratic in f32 0.4999512, `a`-corrected perpendicular form in f32 bit-identical to the current kernel because `f32(d · d)` rounds to one. So the information is in the inputs and f32 arithmetic does not extract it, and solving the full quadratic is strictly worse. `-Dphysics_f64` is Phase 1's answer, clean to 1e-12 out to 100 km. A compensated or double-width intermediate would recover the rest at roughly twice the cost of the hot-path dot products; that decision belongs to M1.1.15, which owns precision, not to the milestone that writes the kernels. Any acceptance suite that only exercises axis-aligned rays sees none of this — the cancellation is exactly zero there. - **M1.1.9 scope boundary (queries: raycast)**: only the raycast is implemented; the COMPLETE family's signatures freeze here because a comptime strategy interface cannot gain a method after M1.1.15 (§1.11.7) — the deferral rule, not zeal. `error.UnsupportedShape` is structurally UNREACHABLE through the query path today: `shape.supportShape` maps a box to `radius = 0` unconditionally and the store holds only sphere/box/capsule, so no `SupportShape` reaching a kernel from a body can be a rounded box. The latch is required by construction, E3 pins the error at kernel level, and the end-to-end path becomes exerciseable at M1.1.11 with Plane and MeshShape. Dated unreachability, not debt. A query takes `*const BodyManager` and therefore CANNOT wake anything, which makes "a sleeping body answers and stays asleep" structural rather than merely tested. The `0.003886328` far-from-origin figure recorded in the M1.1.8 brief is not reproducible from a rebuilt probe (both legs read `0.003882778`, on `main` itself): a frozen brief records what its own probe measured, and a future re-measurement should not chase it. -- **Tier 0 IPC — bounded receive, unowned (opened at M1.1.9)**: `engine-zig-conventions.md` §13 line 897 requires an internal timeout ≤ 5 s with clean resource teardown for any test awaiting an external resource. `connection.recvFrame` has neither a non-blocking variant nor a deadline (`src/core/ipc/connection.zig:123` and `:157` are the only receive entries), and the IPC test targets are built by a loop that does not wire `test_watchdog` (only the `test_specs` loop does, `build.zig:618`), so a hang there never stalls the sibling IPC cases but never lets `zig build test` complete either. Closing §13 for real needs a bounded receive primitive in Tier 0 IPC. Owned by whoever next opens that surface; not a physics milestone. +- **Tier 0 IPC — bounded receive — CLOSED at M1.D/S5 (opened at M1.1.9)**: the premise was §13's required internal timeout of at most 5 s, which the rewritten §13 withdraws; `--test-timeout`, passed wherever the suite runs, names a hung receive and runs the next test, so a test needs no bounded receive primitive. - **Three M1.1.8 leftovers, re-pointed (the entry said M1.1.15 owned them; M1.1.15 closed and did not)**: the wake fixpoint's ROUND COUNT is still unpinned; the production W4 wiring DID land at M1.1.15 and leaves the list; and `build`'s per-tick deferred-index buffer — `rigid/contact_constraint.zig:578`, allocated and freed every tick because `build` owns no state — is now MEASURED rather than merely named: an 11 000-body scene at rest allocates **5 280 times over 240 steady-state frames** (2 400 alloc + 2 880 remap, 22 per tick), where the same scene kept awake allocates ZERO, all 8 809 retained pairs landing in that list and regrowing it from empty each tick. The zero-allocation property C1.1 asks for therefore holds in the configuration the gate measures and FALLS in the one a shipped game runs. Instrument: `bench/physics_forge_3d_integration.zig`. Owner: unassigned — the orchestrator's scratch is still where it goes. - **Windows bench job budget (`bench.yml`, 10 minutes at M1.1.11.1, 30 since M1.D/S5 — see `M1.D.14` above)**: marginal and WILL recur. Closed by EXPERIMENT at M1.1.11.1, not by argument: the job was cancelled at 9m28 on `bench-ecs-smoke (windows-2025)`, passed on rerun at 7m34 on the SAME commit, and `build-and-test (windows-2025, ReleaseSafe)` — which compiles the whole forge suite including the `i1024`/`i8192` tiers — passed in 42m7, so the code is not implicated. The runner is intrinsically at the edge: `build-and-test (windows-2025, Debug)` takes 9m19 for comparable work against a 10-minute budget that includes checkout, Zig setup and cache restore. Two options, neither taken here: raise the budget, or drop the Windows bench from the PR matrix. Whoever hits the next cancellation should read this entry before suspecting their change. - **Frictionless-slider residual (since M1.1.11.1, QUALIFIED at M1.1.14)**: a FRICTIONLESS, undamped slider on a flat mesh seam retains more speed than it started with. Measured at M1.1.12 as `5.0000005` of 5 at f32 — one ULP — and **exactly `5` at f64**; that measurement is dated and stands. At M1.1.13.1 the f32 figure moved to **5.000002**, four ULP, under the substepped solver. **RE-MEASURED AT M1.1.14 AFTER THE FLOAT ENVIRONMENT WAS PINNED: it PERSISTS, unchanged at f32 to the bit, so the unpinned environment was NOT its cause** — the first branch of the alternative M1.1.14's brief imposed. f64 now shows **3 ULP** where M1.1.12 saw none. ~~The inference that used to settle this — "a solver adding energy would add it at both precisions", concluding arithmetic BECAUSE f64 was at zero — is DEAD, killed by that f64 figure.~~ It is replaced by a RELATIVE discriminant with nine orders of margin: energy injected at a physical rate is precision-independent in relative terms, so reproducing the f32 excess at f64 would take `4 × 2^29 = 2^31` ULP (ULP at 5.0 being `2^-21` at f32 and `2^-50` at f64), against **three** measured. It is ROUNDING at the solver's working precision, not energy. The f64 residual's CAUSE is deliberately NOT attributed: the TGS Soft port and M1.1.14's explicit folds both sit between the two measurements and neither was measured against this scene. Pinned in ULP — a metre bound cannot discriminate at f64 — in `mesh_test.zig`; normative detail in `engine-physics-solver.md` §1.7.2. @@ -450,4 +450,4 @@ line, and never on a `tail`. --- -Last updated: 2026-09-22 +Last updated: 2026-09-27 diff --git a/bench/reports/etch_reference_2026-09-27.md b/bench/reports/etch_reference_2026-09-27.md new file mode 100644 index 00000000..7e8f7132 --- /dev/null +++ b/bench/reports/etch_reference_2026-09-27.md @@ -0,0 +1,14 @@ +# Etch reference file and hot reload + +- Commit: 027b9b59437546ba99c0e82f02ab290aac530540 +- Machine: apple_m4, aarch64-macos +- Zig 0.16.0, ReleaseFast +- Protocol: dev run — not opposable + +Reference file: `tests/etch/reference_500_lines.etch`, 1132 lines. + +| Row | Samples | Median | p99 | Max | Gate | Verdict (max) | +|---|---|---|---|---|---|---| +| parse, reference file | 200 | 0.185 ms | 0.299 ms | 0.325 ms | < 50 ms | GO | +| reload, rule-body edit | 200 | 0.005 ms | 0.009 ms | 0.009 ms | < 500 ms | GO | +| reload, reference file | 50 | 0.260 ms | 0.281 ms | 0.281 ms | < 500 ms | GO | diff --git a/bench/reports/shader_hot_reload_2026-09-27.md b/bench/reports/shader_hot_reload_2026-09-27.md new file mode 100644 index 00000000..b7ddb3d3 --- /dev/null +++ b/bench/reports/shader_hot_reload_2026-09-27.md @@ -0,0 +1,12 @@ +# Shader hot reload + +- Commit: b96cd3c22a783452f6f72e8be485e3aef6d721bf +- Machine: apple_m4, aarch64-macos +- Zig 0.16.0, ReleaseFast +- Protocol: dev run — not opposable + +Watcher poll interval: 50 ms (default). 30 reloads, one warm-up compile. + +| Median | p99 | Max | Gate | Verdict (max) | +|---|---|---|---|---| +| 119.311 ms | 186.412 ms | 186.412 ms | < 200 ms | GO | diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index e8294604..ed0f7973 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -7007,6 +7007,81 @@ the earlier point-1 batch, so the first summary read the old results — their timestamps, 07:44 against 15:23, said so; the fresh run is the one recorded here. Floor 2736 → 2745 on macOS and 2734 → 2743 on windows, read from the suite. +### S5/G9 quindecies — the class of time bounds, swept under the rewritten §13 + +Ruled, `engine-zig-conventions.md` §13 rewritten (`sha256 ab06e9c5…`): the hang +detector is `--test-timeout`, passed wherever the suite runs; an internal delay +survives only to free what the process's death would not — a child process — and +asserts no duration; a correctness test measures none, a time requirement going to a +bench under protocol. The prerequisite came first (`06dc4a98`): the pre-push hook's two +runs and `test-runtime-env` pass `--test-timeout 180s`, and the pre-push TSan rerun, a +raw `zig test` command with no way to take the flag, became `zig build +test-tsan-wayland`, cross-compiled for `x86_64-linux` since TSan does not build on macOS. + +**The census was short, and the class was measured again rather than the list.** A +regex over every clock and timeout token of the tree found what the sweep and its +critic had missed: `fd_passing.zig` and `handoff_fd.zig` install a 5 s receive timeout +on both endpoints, `transport.zig` carries three sites where one was counted, and the +reap loops of `crash_recovery.zig` and `catalogue.zig` were not counted at all. What +else it matched lies outside the class: `tick_until`'s timeout counts simulated ticks, +and the two IPC fuzzers run for a duration as a work budget and assert no time. + +By role, as ruled: + +- **Pure detectors, removed** (`7831e0d3`): the watchdog of `deque_test.zig` and + `threading_test.zig`, the only two of its ten files that register no scheduler; the + Wayland twin's 30 s bound, its workers joined as in `M1.D.29`; the async loader's + 5 s bound; and every receive timeout of the in-process IPC tests — `handshake`, + server and client, `transport` ×3, `fd_passing`, `handoff_fd`. **One of them carried + more than detection**: the handshake client's timeout was what let a server-side + failure be reported, the join otherwise waiting on a thread that reads an ack never + sent. The tests now close the server and release the thread before joining it. + Predicted and measured: a failure planted before the ack fails with its own error in + seconds; under the old order, without timeouts, the same test `timed out after + 19.999s` and the planted error is never reported. +- **Child-process waits, kept wide, killing on the bound** (`89516251`): `process.zig` + ×3, `crash_recovery.zig`'s clean-exit wait and reap, `catalogue.zig`'s teardown, + which never killed the runtime. 6 000 polls of 10 ms, about 60 s — 94 s at Windows' + default timer resolution — inside the 180 s deadline; the bound kills the child a + test killed by the runner would leave running, and fails by a named error. + `catalogue.zig`'s receive timeout goes: it freed nothing. The kill test's child + slept 30 s, inside the new bound, so a kill that did nothing would pass for the + child's own exit; it sleeps 600 s. Measured with the kill removed: `sleep 600` fails + `ChildNeverDied` after the bound with no sleeper left; `sleep 30` passes. +- **Duration gates, out of the tests** (`b96cd3c2`). `reference_500`'s median parse + under 50 ms and the Etch hot reload under 500 ms are C0.2's metrics, which C1.6 + keeps; they go to `bench/etch_reference.zig`, which measures the parse, a rule-body + reload and a reload of the reference file. The shader test's 1500 ms stood in for + C0.3's 200 ms, which `bench/shader_hot_reload.zig` measures on a private directory at + the watcher's default poll. The handshake's 100 ms and the Etch `measure` test's + 100 s ceiling answer no requirement and are gone. Both benches write + `bench/reports/_.md` with the commit, the machine, the mode and the + protocol mention `engine-phase-0-criteria.md` asks for, take `--smoke`, and take + `--protocol` for a cold-isolated run, which a bench cannot observe. A rule-body + reload measured at 4 µs needed proof that it ran: the Etch bench now fails when the + entity's field after the last reload differs from the one every tick implies, and + with the reload's tick removed it fails `ReloadDidNotRun` (`027b9b59`). Dev runs, + ReleaseFast, maxima against their gates: parse 0.325 ms / 50, rule-body reload + 0.009 ms and reference reload 0.281 ms / 500, shader reload 186.4 ms / 200 — the one + with a narrow margin, its median 119.3 ms at a 50 ms poll. + +**A defect found while sweeping, closed here** (`96910b87`): the shader test's oracle +never looked at its probe. The watcher's first scan compiles every shader of the +directory and the callback took the first; raw APFS order puts `triangle.frag.glsl` +first and the probe last. Predicted and measured: with an invalid probe the old test +passes and the new one, which ignores every path but the probe's, fails. + +**Raised to Guy, untouched**, each against the ruling as written: the watchdog of the +eight files that DO register a scheduler, whose timeout dumps the only scheduler-state +diagnostic of the suite; `sleepPrecise`'s two tests; the async loader's `ticks >= 1`, +which a fast load or a slow main thread fails; `threading_test`'s child spinning 10 000 +hints "so the parent has time"; `process.zig`'s 20 ms pause before the kill; and +`waitNonblock`, which answers 0 for a child killed by a signal, so a killed runtime +reads as a clean exit. + +Floor unchanged at 2745 / 2743: no test added or removed, four renamed, the suite at +2728 passed and 17 skipped on macOS, `zig build lint` clean. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree From 6fb9e53cd9d4f57c9edd3994699ab0314a517925 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 27 Sep 2026 18:09:21 +0200 Subject: [PATCH 102/141] chore: bring the sweep's comments under the comment rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sweep's comments broke engine-zig-conventions.md §12: decision reasoning in the bench headers, one bound documented in three files, a concurrency fact paraphrased, a false delay in handshake's ready_flag, and narrative or false headers left in the test files it touched. Each block is judged whole and kept, shortened or removed; the concurrency fact is restored word for word. The child-process wait now lives once, in tests/support/child_process.zig, imported by the IPC tests. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 5 +- bench/etch_reference.zig | 22 ++---- bench/report_header.zig | 7 +- bench/shader_hot_reload.zig | 17 +--- briefs/m1.d-phase-1-debt.md | 8 ++ build.zig | 19 ++--- lefthook.yml | 11 --- src/etch/interp.zig | 1 - tests/assets/loader_async.zig | 4 - tests/etch/hot_reload_test.zig | 14 ---- tests/etch/reference_500_test.zig | 19 +---- tests/ipc/catalogue.zig | 37 +-------- tests/ipc/crash_recovery.zig | 78 +++---------------- tests/ipc/fd_passing.zig | 26 +------ tests/ipc/handoff_fd.zig | 51 ++---------- tests/ipc/handshake.zig | 26 ++----- tests/ipc/process.zig | 40 ++-------- tests/ipc/transport.zig | 11 +-- tests/platform/threading_test.zig | 5 -- tests/platform/wayland_thread_safety_test.zig | 25 ++---- tests/platform/win32_thread_safety_test.zig | 35 ++------- tests/render/shader_hot_reload.zig | 4 - tests/support/child_process.zig | 20 +++++ 23 files changed, 99 insertions(+), 386 deletions(-) create mode 100644 tests/support/child_process.zig diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b24adcc9..be63f7a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,9 +125,8 @@ jobs: run: | set -euo pipefail s=$SECONDS - # --test-timeout gives each test a deadline, the only one: without it a hung test hangs - # the build. It also raises Zig's 60 s response floor: on windows a compiler spawned - # beside a test can inherit its pipe and hold the end of stream until that compiler exits. + # 180 s and not Zig's 60 s response floor: on windows a compiler spawned beside a test can + # inherit its pipe and hold the end of stream until that compiler exits. zig build test --summary all --test-timeout 180s -Doptimize=${{ matrix.mode }} -Dphysics_f64=${{ matrix.precision }} -Dcpu=${{ env.ZIG_CPU }} 2>&1 | tee test-out.txt rc=${PIPESTATUS[0]} if [ "$rc" -ne 0 ]; then exit "$rc"; fi diff --git a/bench/etch_reference.zig b/bench/etch_reference.zig index c92f9021..773c340b 100644 --- a/bench/etch_reference.zig +++ b/bench/etch_reference.zig @@ -1,17 +1,5 @@ -//! C0.2's two time metrics (`engine-phase-0-criteria.md`), both kept by C1.6: -//! the 500+ line reference file parsed in < 50 ms, and an interpreter hot -//! reload in < 500 ms from the edited source to the first tick running it. -//! -//! A reload is re-parse, type-check, `Interpreter.compile` on the live world, -//! then one tick; the source is already in memory, so reading the saved file is -//! outside it. Two reloads are measured: a rule-body edit of a one-rule program, -//! alternating between two bodies, and the reference file recompiled unchanged. -//! The verdict is on the maximum, the criterion bounding every parse and every -//! reload. -//! -//! `--smoke` runs each row once and writes nothing. `--protocol` records that -//! the run followed the cold-isolated protocol. Writes -//! `bench/reports/etch_reference_.md`. +//! `--smoke` runs each row once and writes no report; `--protocol` records a +//! cold-isolated run. const std = @import("std"); const weld_etch = @import("weld_etch"); @@ -44,6 +32,8 @@ const counter_bodies = [2][]const u8{ \\} }; +/// C0.2's bounds (`engine-phase-0-criteria.md`). A reload is timed from the +/// parse of a source already in memory to its first tick. const parse_gate_ns: u64 = 50 * std.time.ns_per_ms; const reload_gate_ns: u64 = 500 * std.time.ns_per_ms; @@ -80,9 +70,7 @@ fn benchParse(gpa: std.mem.Allocator, io: std.Io, warmup: usize, samples: []u64) } } -/// A program compiled on a world, with the parse its interpreter was built from. -/// The interpreter binds where it first ticks, so a session is filled in place -/// and never moved. +/// Filled in place and never moved: its interpreter binds where it first ticks. const Session = struct { pr: weld_etch.parser.ParseResult, interp: Interpreter, diff --git a/bench/report_header.zig b/bench/report_header.zig index bc5c9a8c..dd9fbcf5 100644 --- a/bench/report_header.zig +++ b/bench/report_header.zig @@ -1,7 +1,3 @@ -//! The header of an archived bench report (`engine-phase-0-criteria.md` § Rapport -//! de bench): the commit measured, the machine, the build mode, and whether the -//! run followed the cold-isolated protocol. - const std = @import("std"); const builtin = @import("builtin"); @@ -29,7 +25,8 @@ fn git(gpa: std.mem.Allocator, io: std.Io, argv: []const []const u8) ?[]u8 { return null; } -/// Writes the title and the four facts the protocol asks of every report. +/// Writes the title and the four facts `engine-phase-0-criteria.md` § Rapport de +/// bench asks of every archived report. pub fn write(gpa: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, title: []const u8, protocol: bool) !void { const commit = git(gpa, io, &.{ "git", "rev-parse", "HEAD" }); defer if (commit) |c| gpa.free(c); diff --git a/bench/shader_hot_reload.zig b/bench/shader_hot_reload.zig index bdb1e985..f2cfd93e 100644 --- a/bench/shader_hot_reload.zig +++ b/bench/shader_hot_reload.zig @@ -1,16 +1,5 @@ -//! C0.3's shader hot reload in < 200 ms (`engine-phase-0-criteria.md`): from -//! the write of an edited `.frag.glsl` to the watcher's `on_recompile` carrying -//! its SPIR-V. -//! -//! The watcher runs with its default configuration on a private directory -//! holding one probe, whose body alternates between two colours so every write -//! is an edit. The first compile, from the watcher's initial scan, is not -//! measured. Needs `glslc`. The verdict is on the maximum, the criterion -//! bounding every reload. -//! -//! `--smoke` measures one reload and writes nothing. `--protocol` records that -//! the run followed the cold-isolated protocol. Writes -//! `bench/reports/shader_hot_reload_.md`. +//! `--smoke` measures one reload and writes no report; `--protocol` records a +//! cold-isolated run. const std = @import("std"); const hot_reload = @import("weld_render").shader_pipeline.hot_reload; @@ -32,6 +21,8 @@ const probe_sources = [2][]const u8{ \\ }; +/// C0.3's bound (`engine-phase-0-criteria.md`), timed from the probe's write to +/// its `on_recompile` carrying SPIR-V. const gate_ns: u64 = 200 * std.time.ns_per_ms; /// A recompile that has not arrived by then is reported as missing rather than /// waited for. diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index ed0f7973..d37a6264 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -7082,6 +7082,14 @@ reads as a clean exit. Floor unchanged at 2745 / 2743: no test added or removed, four renamed, the suite at 2728 passed and 17 skipped on macOS, `zig build lint` clean. +**The comments this sweep wrote broke the comment rule, and Guy caught it**: decision +reasoning in the two bench headers, the `exit_polls` doc in three files, a concurrency fact +paraphrased in the Wayland test, a false `ready_flag` delay, narrative headers left in the +files it touched. One pass, by hand: the child wait now lives once, in +`tests/support/child_process.zig`, and every comment the sweep wrote or touched, with the +headers of the test files it opened, was judged whole. Floor unchanged, 2728 / 2745 on +macOS, `zig build lint` clean, the IPC binaries cross-compiled for windows. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree diff --git a/build.zig b/build.zig index bb140a8c..2eee2871 100644 --- a/build.zig +++ b/build.zig @@ -958,11 +958,7 @@ pub fn build(b: *std.Build) void { // AST stable interface freeze: thirty Level-1 entry points (§10.3.1). // Compilation is the cross-phase invariant. .{ .path = "tests/etch/ast_stable_interface.zig", .etch = true, .dedicated_step = "test-ast-stable" }, - // interpreter hot-reload: edit rule body → AST swap → - // behaviour change on the same live world. .{ .path = "tests/etch/hot_reload_test.zig", .etch = true, .dedicated_step = "test-hot-reload" }, - // full-grammar 500+ line integration reference: parse + - // type-check clean + Level-A interpret. .{ .path = "tests/etch/reference_500_test.zig", .etch = true, .dedicated_step = "test-ref500" }, // `@storage` consumed end to end: the mode reaches the registry, a // sparse component leaves the archetype signature, a rule selects on it @@ -1099,7 +1095,6 @@ pub fn build(b: *std.Build) void { // GAL capture helper surface coverage (encodePpm + // Device.captureFrameToPPM); §13 consumer test, runs on every platform. .{ .path = "tests/render/capture_helper.zig", .render = true }, - // hot-reload filewatch compiles a dropped shader. .{ .path = "tests/render/shader_hot_reload.zig", .render = true, .runtime_env = true }, // vk_gen whitelist closure (variant filtering + closure // convergence under 20 iterations). @@ -1109,7 +1104,6 @@ pub fn build(b: *std.Build) void { .{ .path = "tests/vk_gen/raw_variants.zig" }, // asset registry stale-handle (generation) acceptance. .{ .path = "tests/assets/handle_generation.zig", .asset_pipeline = true }, - // async loader + lifecycle. .{ .path = "tests/assets/loader_async.zig", .asset_pipeline = true }, // DEFLATE/zlib inflate known-vector acceptance. .{ .path = "tests/assets/deflate_vectors.zig", .asset_pipeline = true }, @@ -1246,9 +1240,7 @@ pub fn build(b: *std.Build) void { psnr_step.dependOn(&psnr_run.step); } - // The pre-push ThreadSanitizer rerun of the Wayland stress test, invoked on - // Linux by `lefthook.yml`. `weld_core` is instrumented too: the backend - // under stress lives there. + // `weld_core` is instrumented too: the backend under stress lives there. { const tsan_foundation = b.createModule(.{ .root_source_file = b.path("src/foundation/root.zig"), @@ -1416,6 +1408,12 @@ pub fn build(b: *std.Build) void { "tests/ipc/crash_recovery.zig", "tests/ipc/fuzz_short.zig", }; + const child_process_module = b.createModule(.{ + .root_source_file = b.path("tests/support/child_process.zig"), + .target = target, + .optimize = optimize, + }); + child_process_module.addImport("weld_core", core_module); for (ipc_test_paths) |p| { const t_mod = b.createModule(.{ .root_source_file = b.path(p), @@ -1430,6 +1428,7 @@ pub fn build(b: *std.Build) void { .link_libc = true, }); t_mod.addImport("weld_core", core_module); + t_mod.addImport("child_process", child_process_module); const t = b.addTest(.{ .root_module = t_mod }); const run_t = b.addRunArtifact(t); // `tests/ipc/crash_recovery.zig` and `tests/ipc/catalogue.zig` @@ -1832,7 +1831,6 @@ pub fn build(b: *std.Build) void { ); render_bench_step.dependOn(&render_bench_run.step); - // C0.3's shader hot reload; needs `glslc`. const shader_reload_bench_module = b.createModule(.{ .root_source_file = b.path("bench/shader_hot_reload.zig"), .target = target, @@ -2058,7 +2056,6 @@ pub fn build(b: *std.Build) void { ); etch_bench_step.dependOn(&etch_bench_run.step); - // C0.2's reference-file parse and interpreter hot reload. const etch_reference_bench_module = b.createModule(.{ .root_source_file = b.path("bench/etch_reference.zig"), .target = target, diff --git a/lefthook.yml b/lefthook.yml index 2a527580..fde7d17e 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -1,12 +1,3 @@ -# Lefthook configuration for the Weld engine. -# See https://lefthook.dev/configuration/ for the reference. -# Spec: briefs/S0-bootstrap.md, "lefthook.yml specification". -# M0.0: `commit-msg` now invokes the Zig linter (`zig build lint-commit`) -# instead of the shell-script fallback, and `pre-commit` adds a -# `zig build lint` pass alongside `zig fmt --check`. -# M0.3: `pre-push` adds a TSan rerun of the Wayland stress test -# (Linux-only compensation for the absent TSan path in the CI matrix). - pre-commit: parallel: true commands: @@ -26,8 +17,6 @@ pre-push: commands: build: run: zig build - # --test-timeout is the only per-test deadline: without it a hung test - # hangs the push. test: run: zig build test --test-timeout 180s test-release: diff --git a/src/etch/interp.zig b/src/etch/interp.zig index 7f1c0523..04dd80a1 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -17322,7 +17322,6 @@ const observed_source = \\rule seen(entity: Entity, value: Health) {} ; -/// Listeners on `cid`'s `on_add` list. fn onAddListeners(world: *World, cid: ComponentId) usize { return if (world.observer_registry.on_add.get(cid)) |list| list.items.len else 0; } diff --git a/tests/assets/loader_async.zig b/tests/assets/loader_async.zig index a7ed0c3d..70898b74 100644 --- a/tests/assets/loader_async.zig +++ b/tests/assets/loader_async.zig @@ -1,7 +1,3 @@ -//! Async loader + lifecycle acceptance. -//! -//! The main loop ticks while a load is in flight and the load completes. - const std = @import("std"); const assets = @import("weld_asset_pipeline"); diff --git a/tests/etch/hot_reload_test.zig b/tests/etch/hot_reload_test.zig index 60e3bf87..67a6a2b9 100644 --- a/tests/etch/hot_reload_test.zig +++ b/tests/etch/hot_reload_test.zig @@ -1,15 +1,3 @@ -//! Interpreter hot-reload — edit a rule body → AST swap → behaviour change. Its -//! < 500 ms is `bench/etch_reference.zig`'s. -//! -//! There is no in-place AST swap: the Interpreter compiles its own copy of the -//! AST and derives its compiled tables eagerly, so a reload re-parses the edited -//! source into a fresh AST and re-runs `Interpreter.compile` on the SAME -//! `World`. Live world state (entities, component bytes) survives because the -//! world is external to the interpreter and `compile` is idempotent w.r.t. -//! already-registered components — it reuses the existing id rather than -//! erroring `DuplicateComponent`. The reload contract is a rule-body edit with -//! the declarations unchanged; a layout-changing reload is Phase 2+. - const std = @import("std"); const weld_etch = @import("weld_etch"); const weld_core = @import("weld_core"); @@ -88,8 +76,6 @@ test "interpreter hot-reload: edit rule body -> AST swap -> behaviour change" { const v_a = readCounter(&world); try std.testing.expectEqual(@as(i64, 3), v_a); - // Edit to source B, re-parse, re-compile on the SAME world, then the first - // tick under the new rule. var pr_b = try weld_etch.parseSource(gpa, src_b); defer pr_b.deinit(gpa); try std.testing.expectEqual(@as(usize, 0), pr_b.diagnostics.len); diff --git a/tests/etch/reference_500_test.zig b/tests/etch/reference_500_test.zig index d7b45e10..980ab97d 100644 --- a/tests/etch/reference_500_test.zig +++ b/tests/etch/reference_500_test.zig @@ -1,20 +1,5 @@ -//! `reference_500_lines.etch` — the full-grammar integration reference. -//! -//! One 500+ line file mixing EVERY v0.6 construct: the Level-A foundations, the -//! seventeen domain constructs, Level-C scene/prefab, generics and async. It is -//! the at-scale integration proof: -//! • PARSE the whole file clean — its < 50 ms is `bench/etch_reference.zig`'s; -//! • TYPE-CHECK the whole file clean (every construct coexists in one unit); -//! • INTERPRET the Level-A behaviour (a dedicated `RefProbe` rule ticks the -//! live world — the byte-exact interp behaviour at scale). -//! -//! The file is NOT cooked (codegen): it carries async + generic fragments which -//! are `UnsupportedConstruct` in codegen (the milestone-long invariant), so a -//! whole-file cook would fail-loud. The byte-exact interp↔codegen proof and the -//! Level-B/C codegen-compiles proof are carried by the exhaustive per-construct -//! differential corpus (programs 01-83): 01-75 Level-A byte-exact both backends, -//! 76-83 Level-B/C codegen-compiles + serialized-IR byte-identical. This split -//! mirrors the established per-program world-state-vs-serialized-IR separation. +//! The file is not cooked: its async and generic fragments are +//! `UnsupportedConstruct` in codegen. const std = @import("std"); const weld_etch = @import("weld_etch"); diff --git a/tests/ipc/catalogue.zig b/tests/ipc/catalogue.zig index 005e3244..ea2c4f82 100644 --- a/tests/ipc/catalogue.zig +++ b/tests/ipc/catalogue.zig @@ -1,15 +1,3 @@ -//! The extended message catalogue, in two layers: -//! -//! 1. Pure framing round-trips (`encode` → `decode` parity) for every -//! message of the catalogue — portable, no runtime, validating the -//! wire format and `schema_hash` of each type. -//! 2. End-to-end handler behaviour against the real `weld-runtime` -//! binary (POSIX-gated, like `crash_recovery.zig`; the SCM_RIGHTS -//! pivot makes the cross-process attach work on macOS too): -//! `SaveProject` → `ProjectSaved` (same seq_id), `LoadScene` with -//! an empty path → `RuntimeError` event, and `Play`/`Pause`/`Stop` -//! accepted without desync (an `Echo` after them still round-trips). - const std = @import("std"); const builtin = @import("builtin"); @@ -20,11 +8,10 @@ const messages = ipc.messages; const transport = ipc.transport; const viewport = ipc.viewport; const platform_process = weld_core.platform.process; +const child_process = @import("child_process"); const is_posix = builtin.os.tag == .linux or builtin.os.tag == .macos; -// ----------------------------------------------- pure framing round-trips -- - /// Encode a message, parse its header, decode it back, and assert the /// bytes survive the round-trip. Exercises the wire format + schema_hash /// for `T`. @@ -67,19 +54,10 @@ test "catalogue messages round-trip through encode/decode" { try roundTrip(messages.RuntimeError, err); } -// --------------------------------------------------- end-to-end fixtures -- - extern "c" fn unlink(path: [*:0]const u8) c_int; extern "c" fn shm_unlink(name: [*:0]const u8) i32; -const timespec_t = extern struct { tv_sec: i64, tv_nsec: i64 }; -extern "c" fn nanosleep(req: *const timespec_t, rem: ?*timespec_t) c_int; extern "c" fn getpid() i32; -fn sleepMs(ms: u64) void { - var ts = timespec_t{ .tv_sec = @intCast(ms / 1000), .tv_nsec = @intCast((ms % 1000) * std.time.ns_per_ms) }; - _ = nanosleep(&ts, null); -} - /// The viewport + child process produced by `spawnRuntime`. The /// `IpcServer` is **not** returned — it is caller-owned and stable, so /// its internal `conn.socket` pointer (set by `acceptOne` to @@ -129,23 +107,12 @@ fn spawnRuntime( return .{ .vp = vp, .proc = proc }; } -/// Polls of `sleepMs(10)` a runtime is given to exit before it is killed: no -/// loaded runner reaches it, and it stays inside the runner's per-test deadline. -const exit_polls: usize = 6_000; - -/// Graceful teardown: `Shutdown` → `ShutdownAck` → reap the runtime, killed -/// once `exit_polls` have passed. fn teardown(server: *ipc.server.IpcServer, proc: *platform_process.Process) void { const sd = messages.Shutdown{}; server.connection().sendMessage(messages.Shutdown, 0, &sd) catch {}; var sa_buf: [framing.frameSizeOf(messages.ShutdownAck)]u8 = undefined; _ = server.connection().recvMessage(messages.ShutdownAck, &sa_buf) catch {}; - var attempts: usize = 0; - while (attempts < exit_polls) : (attempts += 1) { - if (platform_process.waitNonblock(proc) catch null) |_| return; - sleepMs(10); - } - platform_process.kill(proc) catch {}; + _ = child_process.waitExit(proc) catch null; } test "SaveProject is acked by ProjectSaved with the same seq_id" { diff --git a/tests/ipc/crash_recovery.zig b/tests/ipc/crash_recovery.zig index 7ffec653..5bd7bf62 100644 --- a/tests/ipc/crash_recovery.zig +++ b/tests/ipc/crash_recovery.zig @@ -1,25 +1,3 @@ -//! Crash recovery and best-effort replay (C0.4), driving the real -//! `weld-runtime` binary end to end. It runs on Windows as well as POSIX: the -//! per-OS differences are isolated in `spawnAndHandshake` — POSIX hands the -//! viewport fd off via SCM_RIGHTS, Windows opens the named mapping by name -//! (§2.2) — and in the cleanup helpers, while the clock and sleep come from -//! cross-platform `std` with no POSIX externs. -//! -//! - kill -9 runtime → the editor's receive ends in EOF (detection). -//! - kill -9 → editor restarts + the first post-restart Echo round-trips. -//! - editor close → runtime detects EOF + exits clean (code 0). -//! - kill -9 + best-effort replay → after restart, every post-save pending -//! command is replayed and the replay reports complete (engine-ipc.md §7.2). -//! -//! What this file proves is BEHAVIOUR — detection, restart, clean exit, complete -//! replay — and no latency. The former `< 100 ms` / `< 500 ms` assertions were -//! removed: they measured kernel scheduling on a machine the test suite loads -//! itself, and a duration is a benchmark, not a test -//! (`engine-zig-conventions.md` §13). The measured figures live in -//! `validation/s6-go-nogo.md`. -//! -//! macOS exercises the same paths as Linux thanks to the SCM_RIGHTS pivot. - const std = @import("std"); const builtin = @import("builtin"); @@ -32,6 +10,7 @@ const command_log = ipc.command_log; const platform_process = weld_core.platform.process; const platform_time = weld_core.platform.time; const viewport = ipc.viewport; +const child_process = @import("child_process"); const is_windows = builtin.os.tag == .windows; const W = viewport.default_resolution.width; @@ -41,8 +20,6 @@ extern "c" fn getpid() i32; extern "c" fn unlink(path: [*:0]const u8) c_int; extern "c" fn shm_unlink(name: [*:0]const u8) i32; -/// Cross-platform sleep via the platform-time wrapper (`Sleep` / -/// `nanosleep`); `std.Thread.sleep` is gone in 0.16. Needs an `io`. fn sleepMs(io: std.Io, ms: u64) void { platform_time.sleepPrecise(io, ms * std.time.ns_per_ms) catch {}; } @@ -128,24 +105,8 @@ fn spawnAndHandshake( return .{ .vp = vp, .proc = proc }; } -/// Polls of `sleepMs(10)` a runtime is given to exit before it is killed: no -/// loaded runner reaches it, and it stays inside the runner's per-test deadline. -const exit_polls: usize = 6_000; - -/// The runtime's exit code, or null once `exit_polls` have passed and it has -/// been killed. -fn waitExit(io: std.Io, proc: *platform_process.Process) !?i32 { - var attempts: usize = 0; - while (attempts < exit_polls) : (attempts += 1) { - if (try platform_process.waitNonblock(proc)) |code| return code; - sleepMs(io, 10); - } - platform_process.kill(proc) catch {}; - return null; -} - -fn reap(io: std.Io, proc: *platform_process.Process) void { - _ = waitExit(io, proc) catch null; +fn reap(proc: *platform_process.Process) void { + _ = child_process.waitExit(proc) catch null; } test "runtime kill -9 → the editor's receive ends in EOF" { @@ -171,25 +132,15 @@ test "runtime kill -9 → the editor's receive ends in EOF" { sleepMs(io, 50); // let the runtime settle into its loops try platform_process.kill(&sp.proc); - // Detection is asserted as BEHAVIOUR — the receive ends in EOF — and NEVER - // as a duration. The kill→EOF latency is a kernel scheduling quantity with - // no Weld code on its path: measured here at 0-1 ms idle but 62-67 ms under - // the load `zig build test` creates for itself, and it crossed a 100 ms - // bound on one such run. Its home is the controlled measurement in - // `validation/s6-go-nogo.md`, `engine-zig-conventions.md` §13 keeping - // benchmarks out of tests. var scratch: [256]u8 = undefined; const detect_res = server.connection().recvFrame(&scratch); try std.testing.expectError(error.UnexpectedEof, detect_res); - reap(io, &sp.proc); + reap(&sp.proc); } test "runtime kill -9 → editor restarts + first post-restart Echo OK" { const gpa = std.testing.allocator; - var threaded = std.Io.Threaded.init(gpa, .{}); - defer threaded.deinit(); - const io = threaded.io(); const pid = getpid(); var sock_buf: [96]u8 = undefined; const socket_path = try ipc.transport.buildSocketPath(&sock_buf, "weld-restart"); @@ -208,7 +159,7 @@ test "runtime kill -9 → editor restarts + first post-restart Echo OK" { _ = server.connection().recvFrame(&scratch) catch {}; sp1.vp.close(); server.deinit(); - reap(io, &sp1.proc); + reap(&sp1.proc); // Second spawn + handshake + Echo round-trip. var server2 = ipc.server.IpcServer.init(gpa); @@ -227,7 +178,7 @@ test "runtime kill -9 → editor restarts + first post-restart Echo OK" { try server2.connection().sendMessage(messages.Shutdown, 0, &sd); var sa_buf: [framing.frameSizeOf(messages.ShutdownAck)]u8 = undefined; _ = server2.connection().recvMessage(messages.ShutdownAck, &sa_buf) catch {}; - reap(io, &sp2.proc); + reap(&sp2.proc); } test "editor close → runtime detects EOF + exits clean code 0" { @@ -254,7 +205,7 @@ test "editor close → runtime detects EOF + exits clean code 0" { // runtime sees EOF on its next recv and exits 0. server.deinit(); - const exit_code = try waitExit(io, &sp.proc) orelse return error.RuntimeNeverExited; + const exit_code = try child_process.waitExit(&sp.proc) orelse return error.RuntimeNeverExited; try std.testing.expectEqual(@as(i32, 0), exit_code); } @@ -281,8 +232,6 @@ test "kill -9 + best-effort replay of post-save commands" { var scratch: [256]u8 = undefined; - // ---- First session: establish a clean line, then queue pending - // post-save commands, then crash. ---- { var server = ipc.server.IpcServer.init(gpa); var sp = try spawnAndHandshake(&server, gpa, socket_path, shm, snap); @@ -306,9 +255,7 @@ test "kill -9 + best-effort replay of post-save commands" { try log.append(seq, @intFromEnum(messages.MsgType.spawn_entity), frame, 0); } - // Crash the runtime, then drain any buffered acks until EOF. Reaching - // EOF is the assertion; how long the kernel took to deliver it is not - // (see the first test of this file). + // Drains the acks the runtime sent before dying, until EOF. try platform_process.kill(&sp.proc); while (true) { _ = server.connection().recvFrame(&scratch) catch break; // EOF/broken @@ -316,7 +263,7 @@ test "kill -9 + best-effort replay of post-save commands" { sp.vp.close(); server.deinit(); - reap(io, &sp.proc); + reap(&sp.proc); } // 3 commands appended after the clean line, none acked. @@ -325,8 +272,7 @@ test "kill -9 + best-effort replay of post-save commands" { while (it.next()) |_| pending += 1; try std.testing.expectEqual(@as(usize, 3), pending); - // ---- Restart + replay. The fresh runtime reloads from the snapshot - // and re-acks the replayed commands. ---- + // The fresh runtime reloads the snapshot and re-acks the replayed commands. { var server = ipc.server.IpcServer.init(gpa); defer server.deinit(); @@ -335,8 +281,6 @@ test "kill -9 + best-effort replay of post-save commands" { const result = ipc.connection.replayCommands(server.connection(), &log, &scratch, 0); - // Completeness and the count are the contract; the aggregate duration - // is a measurement, and it lives in the validation record. try std.testing.expect(result.complete); try std.testing.expectEqual(@as(usize, 3), result.replayed); @@ -344,6 +288,6 @@ test "kill -9 + best-effort replay of post-save commands" { try server.connection().sendMessage(messages.Shutdown, 0, &sd); var sa_buf: [framing.frameSizeOf(messages.ShutdownAck)]u8 = undefined; _ = server.connection().recvMessage(messages.ShutdownAck, &sa_buf) catch {}; - reap(io, &sp.proc); + reap(&sp.proc); } } diff --git a/tests/ipc/fd_passing.zig b/tests/ipc/fd_passing.zig index db79fd5f..9437035d 100644 --- a/tests/ipc/fd_passing.zig +++ b/tests/ipc/fd_passing.zig @@ -1,18 +1,3 @@ -//! The fd-passing test — the editor side transfers an opened file -//! descriptor to the runtime side via -//! `IpcSocket.sendWithHandles` (SCM_RIGHTS ancillary data) and that -//! the runtime can write into the received fd, with the editor -//! observing the written bytes through its own end. -//! -//! On macOS the chosen fd is the read+write end of a pipe (`pipe(2)`), -//! since `memfd_create` is Linux-specific. The pipe is a clean -//! POSIX primitive supported on every Weld POSIX target, keeps the -//! test self-contained (no temp files), and exercises the same -//! cmsg path as `memfd_create`. -//! -//! Windows: `skipNow`. Handle passing there (`DuplicateHandle`) lands with the -//! GPU shared framebuffer (`engine-ipc.md` §4.7). - const std = @import("std"); const builtin = @import("builtin"); @@ -34,12 +19,9 @@ test "transmits an open fd via sendWithHandles and writes through it" { _ = unlink(path.ptr); defer _ = unlink(path.ptr); - // Editor side: open a pipe whose write end will be transferred - // to the runtime side, and whose read end stays local. var pipe_fds: [2]c_int = .{ -1, -1 }; if (pipe(&pipe_fds) != 0) return error.PipeFailed; defer _ = close(pipe_fds[0]); - // pipe_fds[1] is closed via the transfer + local close below. var listener = try transport.IpcSocket.listen(path); defer listener.close(); @@ -48,11 +30,9 @@ test "transmits an open fd via sendWithHandles and writes through it" { var server = try listener.accept(); defer server.close(); - // Editor sends the pipe write fd to the runtime via SCM_RIGHTS. - // SCM_RIGHTS requires a non-empty regular payload to ride along. + // SCM_RIGHTS needs a non-empty regular payload beside the fd. try client.sendWithHandles(&[_]u8{42}, &[_]transport.OsHandle{pipe_fds[1]}); - // The editor's own copy is no longer needed; the runtime side - // received its own duplicated fd referencing the same pipe. + // The receiving end holds its own duplicate of this fd. _ = close(pipe_fds[1]); var recv_buf: [16]u8 = undefined; @@ -64,12 +44,10 @@ test "transmits an open fd via sendWithHandles and writes through it" { try std.testing.expect(recv_handles[0] >= 0); defer _ = close(recv_handles[0]); - // Runtime writes a known byte sequence into the received fd. const payload = "weld-fd-roundtrip"; const wn = write(recv_handles[0], payload.ptr, payload.len); try std.testing.expectEqual(@as(isize, payload.len), wn); - // Editor reads from its end of the pipe and asserts. var read_buf: [64]u8 = undefined; const rn = read(pipe_fds[0], &read_buf, read_buf.len); try std.testing.expectEqual(@as(isize, payload.len), rn); diff --git a/tests/ipc/handoff_fd.zig b/tests/ipc/handoff_fd.zig index 1a180476..2f77ea10 100644 --- a/tests/ipc/handoff_fd.zig +++ b/tests/ipc/handoff_fd.zig @@ -1,29 +1,5 @@ -//! Shm attach via a received fd (`ShmRegion.fromFd`). -//! -//! Exercises the SCM_RIGHTS primary-attach pivot (`engine-ipc.md` -//! §4.8) at the `ShmRegion` level, one rung above the raw-socket fd -//! loopback of `tests/ipc/fd_passing.zig`: -//! -//! 1. Side A (editor) creates a region with `ShmRegion.create` and -//! keeps its fd via `ShmRegion.fd()`. -//! 2. A sends the fd to side B over an `AF_UNIX` socket via -//! `sendWithHandles` (the bytes payload stands in for the -//! `ShmRegionsHandoff` descriptor; the fd rides as ancillary -//! data). -//! 3. Side B (runtime) maps the received fd with `ShmRegion.fromFd` -//! — **no `shm_open`** — and writes a known pattern. -//! 4. Side A reads the same pattern back: both ends share the same -//! physical pages. -//! -//! Because `fromFd` never calls `shm_open(O_RDWR)`, this runs -//! intra-process even on macOS — the whole point of the pivot is that -//! it sidesteps the BSD shm cross-process `EACCES` quirk that forces -//! the `tests/ipc/shm_cases/*` split. Green on Linux + macOS. -//! -//! Windows: `error.SkipZigTest` — the Windows CPU shm attach stays by -//! name (`open`), the fd-passing pivot is POSIX-only (§4.8). The -//! `ShmRegion.fromFd` Windows path is asserted to return -//! `error.Unimplemented` instead. +//! `ShmRegion.fromFd` calls no `shm_open`, so these tests run in one process on +//! macOS too, unlike `tests/ipc/shm_cases/`. const std = @import("std"); const builtin = @import("builtin"); @@ -60,11 +36,9 @@ test "shm attach via received fd" { _ = unlink(sock_path.ptr); defer _ = unlink(sock_path.ptr); - // ---- Side A (editor): create the region, keep the fd. ---- var region_a = try shm.ShmRegion.create(region_name, region_size); defer region_a.close(); - // Editor zeroes the region as it would before any handoff. @memset(region_a.bytes(), 0); var listener = try transport.IpcSocket.listen(sock_path); @@ -74,7 +48,6 @@ test "shm attach via received fd" { var server = try listener.accept(); defer server.close(); - // ---- Handoff: editor → runtime, fd in ancillary data. ---- // The 1-byte payload stands in for the ShmRegionsHandoff frame; // SCM_RIGHTS requires at least one regular byte alongside the fd. try client.sendWithHandles(&[_]u8{1}, &[_]transport.OsHandle{region_a.fd()}); @@ -86,30 +59,23 @@ test "shm attach via received fd" { try std.testing.expectEqual(@as(usize, 1), result.handles); try std.testing.expect(recv_handles[0] >= 0); - // ---- Side B (runtime): map the received fd, NO shm_open. ---- var region_b = try shm.ShmRegion.fromFd(recv_handles[0], region_size); defer region_b.close(); - // Runtime writes a known pattern into its mapping. const pattern = "weld-shm-handoff-roundtrip"; @memcpy(region_b.bytes()[0..pattern.len], pattern); - // ---- Side A reads the same physical pages back. ---- try std.testing.expectEqualSlices( u8, pattern, region_a.bytes()[0..pattern.len], ); - // A trailing byte the runtime did not touch stays zero — proves we - // mapped the same region, not a private copy. try std.testing.expectEqual(@as(u8, 0), region_a.bytes()[pattern.len]); } test "fromFd is unimplemented on Windows (attach stays by name)" { if (is_posix) return error.SkipZigTest; - // The Windows CPU shm attach is by name (`open`); the fd-passing - // pivot is POSIX-only (§4.8). `fromFd` must fail loudly. try std.testing.expectError( error.Unimplemented, shm.ShmRegion.fromFd(transport.invalid_handle, 4096), @@ -123,8 +89,6 @@ fn zeroRegions() [messages.MAX_SHM_REGIONS]messages.ShmRegionDesc { test "acceptShmHandoff rejects fd/region_count mismatch and closes every fd" { if (!is_posix) return error.SkipZigTest; - // Two disposable fds, but a handoff that claims a single region — - // §8.3 requires fd count == region_count, so this is rejected. const fd0 = dup(2); const fd1 = dup(2); try std.testing.expect(fd0 >= 0 and fd1 >= 0); @@ -136,7 +100,6 @@ test "acceptShmHandoff rejects fd/region_count mismatch and closes every fd" { connection.acceptShmHandoff(&handoff, &handles), ); - // Both received fds were closed — no descriptor leak on rejection. try std.testing.expect(!fdOpen(fd0)); try std.testing.expect(!fdOpen(fd1)); } @@ -162,8 +125,6 @@ test "acceptShmHandoff rejects region_count above MAX_SHM_REGIONS" { test "acceptShmHandoff returns the viewport fd and closes unmapped region fds" { if (!is_posix) return error.SkipZigTest; - // A well-formed two-region handoff: the runtime maps only the - // viewport (regions[0]); the second region's fd must be closed. const fd0 = dup(2); const fd1 = dup(2); try std.testing.expect(fd0 >= 0 and fd1 >= 0); @@ -172,8 +133,8 @@ test "acceptShmHandoff returns the viewport fd and closes unmapped region fds" { const handles = [_]transport.OsHandle{ fd0, fd1 }; const viewport_fd = try connection.acceptShmHandoff(&handoff, &handles); - try std.testing.expectEqual(fd0, viewport_fd); // handles[0] returned - try std.testing.expect(fdOpen(fd0)); // caller owns it — still open - try std.testing.expect(!fdOpen(fd1)); // unmapped region fd closed - _ = close(fd0); // caller cleans up the viewport fd + try std.testing.expectEqual(fd0, viewport_fd); + try std.testing.expect(fdOpen(fd0)); + try std.testing.expect(!fdOpen(fd1)); + _ = close(fd0); // the caller owns the returned fd } diff --git a/tests/ipc/handshake.zig b/tests/ipc/handshake.zig index a5610f98..d85923d5 100644 --- a/tests/ipc/handshake.zig +++ b/tests/ipc/handshake.zig @@ -1,12 +1,6 @@ -//! The handshake — a full `ProtocolHello` ↔ `ProtocolHelloAck` -//! round-trip via `IpcServer` + `IpcClient`, exercised in-process -//! with a dedicated thread for the runtime side (the server's -//! `acceptOne` is blocking). -//! -//! Each test closes its server and releases the runtime thread before -//! joining it, so a failure before the ack ends the thread's wait and is -//! reported instead of hanging the join. The Unix socket file is cleaned up on every scope -//! exit via `defer forceUnlink`. +//! Each test closes its server and releases the runtime thread before joining +//! it, so a failure before the ack ends the thread's wait and is reported +//! instead of hanging the join. const std = @import("std"); const builtin = @import("builtin"); @@ -31,11 +25,7 @@ const RuntimeArgs = struct { path: []const u8, capabilities: u32, accepted_out: *u8, - /// Flipped to 1 by the parent thread once `IpcServer.listen` has - /// returned. The runtime spins on this with a 10 ms sleep so a - /// rapid `connect()` does not race against an unarmed listener - /// (POSIX returns ECONNREFUSED on macOS when the listener has - /// not transitioned to LISTEN yet). + /// Set once the server listens: a `connect()` before that is refused. ready_flag: *std.atomic.Value(u8), }; @@ -87,9 +77,6 @@ test "full handshake completes" { defer server.deinit(); try server.listen(path); - // Drop the starter pistol after the listener is armed. Without - // this the client thread can hit `connect()` before the server - // installs its socket — `ECONNREFUSED` on macOS. ready_flag.store(1, .release); try server.acceptOne(); @@ -133,12 +120,9 @@ test "version mismatch produces explicit rejection" { var hello_buf: [framing.frameSizeOf(messages.ProtocolHello)]u8 = undefined; var hello = try server.recvHello(&hello_buf); - // Simulate a mismatch by overwriting the runtime-supplied - // protocol version with a bogus future value. In a real - // scenario the field would carry the bogus value on its own. hello.protocol_version +%= 7; if (ipc.server.IpcServer.validateHello(hello)) |_| { - try std.testing.expect(false); // unreachable — validateHello should have failed + try std.testing.expect(false); } else |_| { try server.sendHelloAck(false, "protocol mismatch"); } diff --git a/tests/ipc/process.zig b/tests/ipc/process.zig index c81447c0..aef78005 100644 --- a/tests/ipc/process.zig +++ b/tests/ipc/process.zig @@ -1,14 +1,9 @@ -//! Process tests — `platform.process.spawnProcess` + `waitNonblock` -//! + `isAlive` against the real `/bin/true` and `/bin/sleep` binaries -//! (POSIX-gated). Plus `quoteArg`, the Windows command-line quoter, tested -//! cross-platform through golden cases and a round-trip against a reference -//! `CommandLineToArgvW` parser — so no Windows is needed to exercise it. - const std = @import("std"); const builtin = @import("builtin"); const weld_core = @import("weld_core"); const process = weld_core.platform.process; +const child_process = @import("child_process"); const is_posix = builtin.os.tag == .linux or builtin.os.tag == .macos; @@ -26,22 +21,6 @@ fn sleepMs(ms: u64) void { _ = nanosleep(&ts, null); } -/// Polls of `sleepMs(10)` a child is given to exit before it is killed: no -/// loaded runner reaches it, and it stays inside the runner's per-test deadline. -const exit_polls: usize = 6_000; - -/// `proc`'s exit code, or null once `exit_polls` have passed and it has been -/// killed. -fn waitExit(proc: *process.Process) !?i32 { - var attempts: usize = 0; - while (attempts < exit_polls) : (attempts += 1) { - if (try process.waitNonblock(proc)) |code| return code; - sleepMs(10); - } - process.kill(proc) catch {}; - return null; -} - // `/bin/true` lives at `/usr/bin/true` on macOS (and is also at // `/bin/true` on Linux). `/bin/sleep` is canonical on both. const true_path = if (builtin.os.tag == .macos) "/usr/bin/true" else "/bin/true"; @@ -53,7 +32,7 @@ test "spawn true(1) and reap with waitNonblock returns exit 0" { const argv = [_][]const u8{true_path}; var proc = try process.spawnProcess(gpa, true_path, &argv); - const code = try waitExit(&proc) orelse return error.ChildNeverExited; + const code = try child_process.waitExit(&proc) orelse return error.ChildNeverExited; try std.testing.expectEqual(@as(i32, 0), code); } @@ -76,8 +55,8 @@ test "spawn-then-kill terminates a long-running child" { if (!is_posix) return error.SkipZigTest; const gpa = std.testing.allocator; - // Longer than `exit_polls` allow, so a kill that did nothing cannot pass - // for the child's own exit. + // Longer than `child_process.exit_polls` allow, so a kill that did nothing + // cannot pass for the child's own exit. const argv = [_][]const u8{ "/bin/sleep", "600" }; var proc = try process.spawnProcess(gpa, "/bin/sleep", &argv); @@ -86,27 +65,20 @@ test "spawn-then-kill terminates a long-running child" { // the spawn returning before the child is reapable on macOS. sleepMs(20); try process.kill(&proc); - _ = try waitExit(&proc) orelse return error.ChildNeverDied; + _ = try child_process.waitExit(&proc) orelse return error.ChildNeverDied; } test "spawnProcess runs a Windows binary and reaps exit 0" { if (builtin.os.tag != .windows) return error.SkipZigTest; - // Anti-regression: the first real Windows run hit `CreateProcessW` → - // `error.SpawnFailed`. The path is exercised with a binary guaranteed - // present, `cmd.exe /c exit 0`. const gpa = std.testing.allocator; const exe = "C:\\Windows\\System32\\cmd.exe"; const argv = [_][]const u8{ exe, "/c", "exit 0" }; var proc = try process.spawnProcess(gpa, exe, &argv); - const code = try waitExit(&proc) orelse return error.ChildNeverExited; + const code = try child_process.waitExit(&proc) orelse return error.ChildNeverExited; try std.testing.expectEqual(@as(i32, 0), code); } -// ------------------------------------------------------- quoteArg tests -- -// -// `quoteArg` is pure and cross-platform, so these run on every host. - /// Reference re-implementation of `CommandLineToArgvW`, UTF-8 (the /// metacharacters are all ASCII). Used to prove `quoteArg` output parses /// back to the original argument. Faithful to the documented rules: diff --git a/tests/ipc/transport.zig b/tests/ipc/transport.zig index 50c1488f..fb10b4f2 100644 --- a/tests/ipc/transport.zig +++ b/tests/ipc/transport.zig @@ -1,11 +1,6 @@ -//! Transport — `IpcSocket.listen/connect/accept/send/recv` on a real OS socket. -//! -//! Writing 64 KB single-threaded on an AF_UNIX SOCK_STREAM deadlocks once the -//! kernel send buffer fills, no reader draining it, so large-payload tests spawn -//! a reader thread that consumes bytes in parallel. The listen socket and any -//! unix socket file are unlinked on test scope exit (`defer`). -//! -//! POSIX only: the tests address unix socket files. +//! Writing 64 KB on one thread deadlocks once the kernel send buffer fills, so a +//! large payload is drained by a reader thread. POSIX only: the tests address +//! unix socket files. const std = @import("std"); const builtin = @import("builtin"); diff --git a/tests/platform/threading_test.zig b/tests/platform/threading_test.zig index fb796548..12b92474 100644 --- a/tests/platform/threading_test.zig +++ b/tests/platform/threading_test.zig @@ -1,8 +1,3 @@ -//! `setAffinity` + `setPriority` smoke on spawned thread. -//! -//! A spawned thread completes its work after `setAffinity` and `setPriority` -//! both return without error. - const std = @import("std"); const weld = @import("weld_core"); const threading = weld.platform.threading; diff --git a/tests/platform/wayland_thread_safety_test.zig b/tests/platform/wayland_thread_safety_test.zig index defb97f5..31e0912f 100644 --- a/tests/platform/wayland_thread_safety_test.zig +++ b/tests/platform/wayland_thread_safety_test.zig @@ -1,13 +1,6 @@ -//! Wayland concurrent createWindow + destroyWindow stress. -//! -//! Concurrent `createWindow` and `destroyWindow` on 8 threads against the -//! Wayland backend's module-level state: the libwayland loader's once-init and -//! `wayland.live_state`. -//! -//! This is the FUNCTIONAL pass. The explicit data-race check is the lefthook -//! pre-push `-fsanitize=thread` rerun. -//! -//! Skipped on non-Linux runners. +//! The state under stress is the libwayland loader's once-init and +//! `wayland.live_state`. The data-race check is the pre-push rerun under +//! ThreadSanitizer, `zig build test-tsan-wayland`. const std = @import("std"); const test_env = @import("test_env"); @@ -15,9 +8,8 @@ const builtin = @import("builtin"); const weld = @import("weld_core"); const NUM_THREADS: u32 = 8; -// Each iteration round-trips with the compositor, which a headless or nested -// one stretches considerably; a deadlock is caught by the runner's per-test -// deadline, which this count must stay well inside. +// Each iteration round-trips with the compositor: this count must stay well +// inside the runner's per-test deadline on a headless one. const ITERATIONS_PER_THREAD: u32 = 100; const Ctx = struct { @@ -38,13 +30,12 @@ fn workerStress(ctx: *Ctx) void { } // Memory non-corruption under concurrent backend creation, not multi-backend -// coherence, which the "one Backend per process" invariant leaves out: the -// non-atomic `live_state` is raced between threads here, harmlessly for this -// pattern. +// coherence, which the "one Backend per process" invariant leaves out. The +// global non-atomic live_state var is raced between threads here, with no +// consequence on the tested pattern. test "concurrent createWindow + destroyWindow" { if (builtin.os.tag != .linux) return test_env.absent("a Linux host"); - // Heap accounting is not what this test checks. const gpa = std.heap.page_allocator; // Probe first, so a missing compositor is reported as one and not as diff --git a/tests/platform/win32_thread_safety_test.zig b/tests/platform/win32_thread_safety_test.zig index fa718a87..4da455ae 100644 --- a/tests/platform/win32_thread_safety_test.zig +++ b/tests/platform/win32_thread_safety_test.zig @@ -1,20 +1,11 @@ -//! Win32 thread safety stress. -//! -//! Concurrent `createWindow` and `destroyWindow` — 8 threads — with -//! `class_atom` stable, `class_open_count` back to 0, and no deadlock. -//! -//! Skipped on non-Windows runners (the test exercises the live Win32 API). -//! The file compiles on all platforms but the `win32_backend` import only -//! resolves on Windows targets. - const std = @import("std"); const builtin = @import("builtin"); const weld = @import("weld_core"); const window_api = weld.platform.window; const NUM_THREADS: u32 = 8; -// A deadlock is caught by the runner's per-test deadline (`--test-timeout` in -// CI), which this count must stay well inside on a loaded windows runner. +// This count must stay well inside the runner's per-test deadline on a loaded +// windows runner. const ITERATIONS_PER_THREAD: u32 = 100; const Ctx = struct { @@ -45,18 +36,12 @@ fn workerStress(ctx: *Ctx) void { test "concurrent createWindow + destroyWindow" { if (builtin.os.tag != .windows) return error.SkipZigTest; - // Heap accounting is not what this test checks. const gpa = std.heap.page_allocator; var ctxs: [NUM_THREADS]Ctx = undefined; var threads: [NUM_THREADS]std.Thread = undefined; - // Warm-up: trigger the class once-init before reading atom_before. - // Without this warm-up, atom_before would be 0 (no class yet) and - // the stability check (atom_before == atom_after) would trivially - // fail. The gate is 'class atom stable across the 8×N - // concurrent create/destroy cycles' — not 'class atom equals 0 - // at test start'. + // Registers the class, so `atom_before` is the atom the stress must keep. { var warmup = try window_api.Window.create(gpa, .{}); warmup.destroy(); @@ -80,18 +65,8 @@ test "concurrent createWindow + destroyWindow" { try std.testing.expectEqual(atom_before, atom_after); try std.testing.expectEqual(@as(u32, 0), window_api.classOpenCount()); - // Brief gate is "no deadlock, class_atom stable, class_open_count - // returns to 0" — the three assertions above. It does NOT - // gate "every create succeeded". On the GitHub Actions windows-2025 - // runner, a small fraction of the 800 CreateWindowExW calls under - // 8-way concurrent stress return NULL (transient — most likely a - // USER object kernel quota momentarily exhausted by the cycling - // pace). The invariants still hold (atom unchanged, refcount - // returns to 0, no deadlock), confirming the thread-safety patch is - // sound. We tolerate < 5% transient create failures here; a stricter - // test would need a less synthetic stress (real WM_* traffic + DPI - // tracking) and is deferred to Phase 0+ when the editor exercises - // the path organically. + // A few `CreateWindowExW` calls return NULL under this stress on a CI + // runner, so creates may fail below 5 %. var total_errs: u32 = 0; for (&ctxs) |*c| total_errs += c.err_count.load(.acquire); const total_attempts: u32 = NUM_THREADS * ITERATIONS_PER_THREAD; diff --git a/tests/render/shader_hot_reload.zig b/tests/render/shader_hot_reload.zig index 27c17966..2a01556b 100644 --- a/tests/render/shader_hot_reload.zig +++ b/tests/render/shader_hot_reload.zig @@ -1,7 +1,3 @@ -//! Shader hot-reload: the watcher compiles a probe `.frag.glsl` dropped into -//! `assets/shaders/`. Needs `glslc` (`test_env`): without it the watcher does -//! not start. The < 200 ms latency is `bench/shader_hot_reload.zig`'s. - const std = @import("std"); const test_env = @import("test_env"); const hot_reload = @import("weld_render").shader_pipeline.hot_reload; diff --git a/tests/support/child_process.zig b/tests/support/child_process.zig new file mode 100644 index 00000000..01843a4c --- /dev/null +++ b/tests/support/child_process.zig @@ -0,0 +1,20 @@ +const std = @import("std"); +const weld_core = @import("weld_core"); +const process = weld_core.platform.process; +const time = weld_core.platform.time; + +/// Polls of 10 ms a child is given to exit before it is killed: no loaded +/// runner reaches it, and it stays inside the runner's per-test deadline. +pub const exit_polls: usize = 6_000; + +/// `proc`'s exit code, or null once `exit_polls` have passed and it has been +/// killed. +pub fn waitExit(proc: *process.Process) !?i32 { + var attempts: usize = 0; + while (attempts < exit_polls) : (attempts += 1) { + if (try process.waitNonblock(proc)) |code| return code; + time.sleepPrecise(std.testing.io, 10 * std.time.ns_per_ms) catch {}; + } + process.kill(proc) catch {}; + return null; +} From 48a4d7996c1f3920af782a89b341e707da7f3251 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 27 Sep 2026 19:07:39 +0200 Subject: [PATCH 103/141] fix(platform): report a child a signal killed as killed, not as exit 0 waitNonblock read the exit-status bits of every status, which are zero when a signal ended the child, so a runtime killed by SIGKILL read as a clean exit. It now answers minus the signal number on POSIX; on windows it bit-casts the exit code, which @intCast would trap for an NTSTATUS. The kill test asserts -9, red first as predicted (expected -9, found 0). Its child now creates a file before it execs its sleep and the test waits for that file, where it slept 20 ms and hoped the child was up. Co-Authored-By: Claude Opus 5.5 --- src/core/platform/process.zig | 14 ++++++++------ tests/ipc/process.zig | 26 ++++++++++++++++---------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/core/platform/process.zig b/src/core/platform/process.zig index 087341c2..943d8db4 100644 --- a/src/core/platform/process.zig +++ b/src/core/platform/process.zig @@ -297,9 +297,10 @@ pub fn spawnProcess( } } -/// Polls without blocking. Returns `null` if the child is still -/// alive, or its exit code if it has terminated. Reaps zombies on -/// POSIX so subsequent `isAlive(pid)` calls don't lie. +/// Polls without blocking: `null` while the child runs, else its exit code — +/// on POSIX minus the signal number when a signal killed it, on Windows the +/// process exit code as `GetExitCodeProcess` returns it. Reaps zombies on POSIX +/// so subsequent `isAlive(pid)` calls don't lie. pub fn waitNonblock(proc: *Process) Error!?i32 { switch (builtin.os.tag) { .linux, .macos => { @@ -307,8 +308,9 @@ pub fn waitNonblock(proc: *Process) Error!?i32 { const r = posix.waitpid(proc.pid, &status, posix.WNOHANG); if (r == 0) return null; // still alive if (r < 0) return error.WaitFailed; - // WEXITSTATUS macro: (status >> 8) & 0xFF - return @intCast((status >> 8) & 0xFF); + const s: u32 = @bitCast(status); + if (std.c.W.IFSIGNALED(s)) return -@as(i32, @intCast(@intFromEnum(std.c.W.TERMSIG(s)))); + return std.c.W.EXITSTATUS(s); }, .windows => { const handle = proc.handle orelse return error.WaitFailed; @@ -319,7 +321,7 @@ pub fn waitNonblock(proc: *Process) Error!?i32 { if (win.GetExitCodeProcess(handle, &code) == 0) return error.WaitFailed; _ = win.CloseHandle(handle); proc.handle = null; - return @intCast(code); + return @bitCast(code); }, else => @compileError("waitNonblock: unsupported OS"), } diff --git a/tests/ipc/process.zig b/tests/ipc/process.zig index aef78005..dba864ab 100644 --- a/tests/ipc/process.zig +++ b/tests/ipc/process.zig @@ -37,6 +37,8 @@ test "spawn true(1) and reap with waitNonblock returns exit 0" { } extern "c" fn getpid() i32; +extern "c" fn unlink(path: [*:0]const u8) c_int; +extern "c" fn access(path: [*:0]const u8, mode: c_int) c_int; test "isAlive returns true for current pid, false for impossible pid" { if (!is_posix) return error.SkipZigTest; @@ -55,17 +57,21 @@ test "spawn-then-kill terminates a long-running child" { if (!is_posix) return error.SkipZigTest; const gpa = std.testing.allocator; - // Longer than `child_process.exit_polls` allow, so a kill that did nothing - // cannot pass for the child's own exit. - const argv = [_][]const u8{ "/bin/sleep", "600" }; - - var proc = try process.spawnProcess(gpa, "/bin/sleep", &argv); - // Give the child a moment to actually become alive in the kernel - // table — without this, `kill(pid, SIGKILL)` can race against - // the spawn returning before the child is reapable on macOS. - sleepMs(20); + var ready_buf: [64]u8 = undefined; + const ready = try std.fmt.bufPrintZ(&ready_buf, "/tmp/weld-test-kill-ready-{d}", .{getpid()}); + _ = unlink(ready.ptr); + defer _ = unlink(ready.ptr); + const script = try std.fmt.allocPrint(gpa, ": > {s}; exec /bin/sleep 600", .{ready}); + defer gpa.free(script); + // The sleep outlasts what `child_process.exit_polls` allow, so a kill that + // did nothing cannot pass for the child's own exit. + const argv = [_][]const u8{ "/bin/sh", "-c", script }; + + var proc = try process.spawnProcess(gpa, "/bin/sh", &argv); + while (access(ready.ptr, 0) != 0) sleepMs(1); try process.kill(&proc); - _ = try child_process.waitExit(&proc) orelse return error.ChildNeverDied; + const code = try child_process.waitExit(&proc) orelse return error.ChildNeverDied; + try std.testing.expectEqual(@as(i32, -9), code); } test "spawnProcess runs a Windows binary and reaps exit 0" { From 8e4d1f374c54a99406564fd4ea32f0c6143ea876 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 27 Sep 2026 19:07:42 +0200 Subject: [PATCH 104/141] test: synchronise the loader and thread tests on events, not time The async loader test counted busy-loop ticks until the load was ready and required at least one, which a fast load or a descheduled main thread fails. It now hands beginLoad an Io whose file open waits for the test: ready() is false by construction until the test releases it, and a counter proves the load's open went through that gate. threading_test's child spun 10 000 hints so the parent had time to set its affinity and priority, and a thread that had already exited could fail setAffinity on windows. The child now waits for the parent's signal, released by a defer so a failed call still ends it. Co-Authored-By: Claude Opus 5.5 --- tests/assets/loader_async.zig | 39 ++++++++++++++++++++++++------- tests/platform/threading_test.zig | 27 ++++++++++----------- 2 files changed, 42 insertions(+), 24 deletions(-) diff --git a/tests/assets/loader_async.zig b/tests/assets/loader_async.zig index 70898b74..c33c2033 100644 --- a/tests/assets/loader_async.zig +++ b/tests/assets/loader_async.zig @@ -26,6 +26,26 @@ fn cookTextureBin(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, name: []c try file.writeStreamingAll(io, bin); } +const gated_path = "x.texture.bin"; +var open_released = std.atomic.Value(bool).init(false); +var gated_opens = std.atomic.Value(u32).init(0); +var base_vtable: *const std.Io.VTable = undefined; + +/// The base `Io`'s `dirOpenFile`, held for `gated_path` until the test sets +/// `open_released`. +fn gatedOpenFile( + userdata: ?*anyopaque, + dir: std.Io.Dir, + sub_path: []const u8, + options: std.Io.Dir.OpenFileOptions, +) std.Io.File.OpenError!std.Io.File { + if (std.mem.eql(u8, sub_path, gated_path)) { + _ = gated_opens.fetchAdd(1, .acq_rel); + while (!open_released.load(.acquire)) std.Thread.yield() catch {}; + } + return base_vtable.dirOpenFile(userdata, dir, sub_path, options); +} + test "async load does not block main thread" { const gpa = std.testing.allocator; const io = std.testing.io; @@ -37,20 +57,21 @@ test "async load does not block main thread" { 0xff, 0x00, 0x00, 0xff, 0x00, 0xff, 0x00, 0xff, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x00, 0xff, }; - try cookTextureBin(gpa, io, tmp.dir, "x.texture.bin", &rgba); + try cookTextureBin(gpa, io, tmp.dir, gated_path, &rgba); var loader = Loader.init(tmp.dir); defer loader.deinit(gpa); - var pending = try loader.beginLoad(gpa, io, "x.texture.bin"); - var ticks: usize = 0; - while (!pending.ready()) { - ticks += 1; - std.mem.doNotOptimizeAway(ticks); - } - try std.testing.expect(ticks >= 1); // the main loop advanced; the read ran off-thread + var gated_vtable = io.vtable.*; + gated_vtable.dirOpenFile = gatedOpenFile; + base_vtable = io.vtable; + const gated_io: std.Io = .{ .userdata = io.userdata, .vtable = &gated_vtable }; - const raw = try pending.wait(io); + var pending = try loader.beginLoad(gpa, gated_io, gated_path); + try std.testing.expect(!pending.ready()); + open_released.store(true, .release); + const raw = try pending.wait(gated_io); + try std.testing.expectEqual(@as(u32, 1), gated_opens.load(.acquire)); const handle = try loader.finish(gpa, raw); try std.testing.expectEqual(AssetType.texture, handle.assetType().?); diff --git a/tests/platform/threading_test.zig b/tests/platform/threading_test.zig index 12b92474..f0a5739c 100644 --- a/tests/platform/threading_test.zig +++ b/tests/platform/threading_test.zig @@ -9,29 +9,26 @@ test "setAffinity + setPriority on spawned thread" { } const Ctx = struct { + go: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), done: std.atomic.Value(u32) = std.atomic.Value(u32).init(0), fn run(self: *@This()) void { - // Spin so the parent has time to issue both calls before exit. - var i: u32 = 0; - while (i < 10_000) : (i += 1) { - std.atomic.spinLoopHint(); - } + while (!self.go.load(.acquire)) std.Thread.yield() catch {}; self.done.store(1, .release); } }; var ctx: Ctx = .{}; - var t = try std.Thread.spawn(.{}, Ctx.run, .{&ctx}); + { + var t = try std.Thread.spawn(.{}, Ctx.run, .{&ctx}); + defer t.join(); + // Both calls need a live thread, so it runs on only once they are done. + defer ctx.go.store(true, .release); - // Pin to core 0 — always exists. macOS no-ops. - try threading.setAffinity(t, 0); - // `.normal` and not `.high`: on POSIX without `CAP_SYS_NICE` the latter - // requires `SCHED_FIFO`/`RR`, and macOS no-ops either way. The contract - // under test is "returns without error", which holds on all three - // platforms. - try threading.setPriority(t, .normal); - - t.join(); + // Pin to core 0 — always exists. macOS no-ops. + try threading.setAffinity(t, 0); + // `.high` fails on POSIX without `CAP_SYS_NICE`. + try threading.setPriority(t, .normal); + } try std.testing.expectEqual(@as(u32, 1), ctx.done.load(.acquire)); } From f6f8d7217aaaaf363c78ccd3038ff1bfd7b65ec6 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 27 Sep 2026 19:07:46 +0200 Subject: [PATCH 105/141] test: bound the scheduler watchdog under the runner's 180 s deadline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §13, adjusted: an internal delay survives to produce a diagnostic the runner would not, and then fires under the runner's deadline. The watchdog of the eight files that register a scheduler goes from 5 s to 150 s, so its scheduler dump is written before the runner kills the test, and a loaded runner no longer trips it. The steady-state test's per-dispatch dump fires 10 s earlier, so a hang in a dispatch loop gets that richer dump. The stress harness keeps its 5 s, its own constant. Co-Authored-By: Claude Opus 5.5 --- build.zig | 4 --- tests/ecs/no_alloc_steady_state.zig | 19 +++++----- tests/ecs/no_alloc_steady_state_stress.zig | 12 ++++--- tests/support/watchdog.zig | 42 +++++++--------------- 4 files changed, 29 insertions(+), 48 deletions(-) diff --git a/build.zig b/build.zig index 2eee2871..c618b445 100644 --- a/build.zig +++ b/build.zig @@ -1118,10 +1118,6 @@ pub fn build(b: *std.Build) void { .{ .path = "tests/assets/wav_roundtrip.zig", .asset_pipeline = true }, .{ .path = "tests/assets/cache_diff.zig", .asset_pipeline = true }, }; - // The shared fail-fast watchdog for in-process concurrency tests. It covers - // the `Scheduler.deinit`-join site that masked a windows-2025/ReleaseSafe - // hang. Imported by tests as `@import("test_watchdog")` and compiled only - // into the specs that use it. const watchdog_module = b.createModule(.{ .root_source_file = b.path("tests/support/watchdog.zig"), .target = target, diff --git a/tests/ecs/no_alloc_steady_state.zig b/tests/ecs/no_alloc_steady_state.zig index cae53ce0..bfb82879 100644 --- a/tests/ecs/no_alloc_steady_state.zig +++ b/tests/ecs/no_alloc_steady_state.zig @@ -15,10 +15,8 @@ //! dispatch, so `hasPendingDeferred` returns false every frame. //! //! The measurement loop runs on a worker thread and the test thread polls a -//! `done` atomic against a 5 s budget (`engine-zig-conventions.md` §13). On -//! timeout it dumps the scheduler and event bus state through `livelock_dump` -//! and aborts with exit code 2, which is the signal the stress harness counts -//! hangs by. +//! `done` atomic; past `dispatch_timeout_ns` it dumps the scheduler and event +//! bus state through `livelock_dump` and aborts with exit code 2. const std = @import("std"); const weld_core = @import("weld_core"); @@ -183,15 +181,18 @@ fn dispatchLoop(args: *DispatchArgs) void { args.done.store(true, .release); } -/// Watchdog wrapper. Spawns `dispatchLoop` on a worker -/// thread, polls `done` every 50 ms up to a 5 s wall-clock budget. -/// On timeout, dumps the scheduler + event bus state to stderr and -/// aborts the test process with exit code 2 (= SchedulerLivelock). +/// Short of the test's global watchdog, so a hang inside a dispatch loop gets +/// this dump, the richer one, rather than the global's. +const dispatch_timeout_ns: i96 = watchdog.default_timeout_ns - 10 * std.time.ns_per_s; + +/// Spawns `dispatchLoop` on a worker thread and polls `done` every 50 ms; past +/// `dispatch_timeout_ns` it dumps the scheduler and event bus state to stderr +/// and exits the process with code 2. fn runWithWatchdog(args: *DispatchArgs) !void { const thread = try std.Thread.spawn(.{}, dispatchLoop, .{args}); const start = std.Io.Clock.now(.awake, args.io); - const timeout_ns: i96 = 5 * std.time.ns_per_s; + const timeout_ns = dispatch_timeout_ns; while (!args.done.load(.acquire)) { const now = std.Io.Clock.now(.awake, args.io); diff --git a/tests/ecs/no_alloc_steady_state_stress.zig b/tests/ecs/no_alloc_steady_state_stress.zig index 21808aaf..3a2dca12 100644 --- a/tests/ecs/no_alloc_steady_state_stress.zig +++ b/tests/ecs/no_alloc_steady_state_stress.zig @@ -25,8 +25,8 @@ //! inter-step gap past the worker spin window, forcing `work_available` parks — //! which is what exposes a lost wake if one exists. //! -//! Watchdog identical to `no_alloc_steady_state.zig`: 5 s per dispatch loop, -//! dump state and `exit(2)` on timeout. +//! Past `hang_timeout_ns`, per dispatch loop and for the whole run, it dumps +//! state and exits with code 2, the code the stress loop counts hangs by. const std = @import("std"); const weld_core = @import("weld_core"); @@ -273,11 +273,13 @@ fn dispatchLoop(args: *DispatchArgs) void { args.done.store(true, .release); } +const hang_timeout_ns: i96 = 5 * std.time.ns_per_s; + fn runWithWatchdog(args: *DispatchArgs) !void { const thread = try std.Thread.spawn(.{}, dispatchLoop, .{args}); const start = std.Io.Clock.now(.awake, args.io); - const timeout_ns: i96 = 5 * std.time.ns_per_s; + const timeout_ns = hang_timeout_ns; while (!args.done.load(.acquire)) { const now = std.Io.Clock.now(.awake, args.io); @@ -361,13 +363,13 @@ test "stress steady-state — composite scenario under concurrent CPU and alloca // A GLOBAL watchdog, covering the teardown `Scheduler.deinit()`/`join()` // that the per-dispatch `runWithWatchdog` below does NOT reach. Armed after - // the noise threads so its 5 s window wraps the scheduler lifecycle tightly + // the noise threads so its window wraps the scheduler lifecycle tightly // rather than the spin-up, with `defer disarm()` declared before // `defer jobs_sched.deinit` so LIFO keeps it armed through deinit and join. // It uses `io` and its own thread stack, never the counting `gpa`, so it // cannot perturb the measured delta. var wd: watchdog.Watchdog = .{}; - try wd.arm(io, watchdog.default_timeout_ns, "stress steady-state — composite scenario under concurrent CPU and allocator noise"); + try wd.arm(io, hang_timeout_ns, "stress steady-state — composite scenario under concurrent CPU and allocator noise"); defer wd.disarm(); var jobs_sched = try weld_core.jobs.scheduler.Scheduler.init(gpa, io); diff --git a/tests/support/watchdog.zig b/tests/support/watchdog.zig index 8926c0b2..0bc31275 100644 --- a/tests/support/watchdog.zig +++ b/tests/support/watchdog.zig @@ -1,39 +1,21 @@ -//! Permanent fail-fast watchdog for in-process concurrency tests. +//! Arm on a test's first line and `defer disarm()` at once, so the disarm runs +//! last, after `Scheduler.deinit`'s worker join, which it must cover: //! -//! Wraps an ENTIRE test — worker spawn and join AND `Scheduler.deinit`'s own -//! worker `join()` — so a deadlock or livelock FAILS with a state dump within -//! the timeout instead of hanging silently until the CI build-runner kills the -//! process at ~60 s. The deinit-join site is the gap that masked a -//! windows-2025/ReleaseSafe scheduler hang: the dispatcher-spin watchdog in -//! `publishWaveAndWait` does not reach it. It makes -//! `engine-zig-conventions.md` §13 — wait on a resource ⇒ ≤ 5 s internal -//! timeout, fail rather than hang — permanent. -//! -//! Usage — arm on the FIRST line and `defer disarm()` immediately, so disarm -//! is the LAST defer to run (LIFO), i.e. AFTER the scheduler's deinit-join: -//! -//! test "..." { -//! const io = std.testing.io; -//! var wd: watchdog.Watchdog = .{}; -//! try wd.arm(io, watchdog.default_timeout_ns, "test name"); -//! defer wd.disarm(); // runs LAST — after deinit -//! ... -//! var sched = try Scheduler.init(gpa, io); -//! defer sched.deinit(gpa); // runs BEFORE disarm → covered -//! wd.setScheduler(&sched); // dump shows scheduler state -//! ... -//! } +//! var wd: watchdog.Watchdog = .{}; +//! try wd.arm(io, watchdog.default_timeout_ns, "test name"); +//! defer wd.disarm(); +//! var sched = try Scheduler.init(gpa, io); +//! defer sched.deinit(gpa); +//! wd.setScheduler(&sched); const std = @import("std"); const weld_core = @import("weld_core"); const Scheduler = weld_core.jobs.scheduler.Scheduler; -/// 5 s — the `engine-zig-conventions.md` §13 ceiling for a test that waits on -/// an external/concurrency resource. Comfortably above any legitimate test -/// (waves drain in µs–ms) yet below the CI build-runner's ~60 s no-response -/// kill, so a fired watchdog is always a real deadlock/livelock. -pub const default_timeout_ns: i96 = 5 * std.time.ns_per_s; +/// Under the runner's 180 s per-test deadline, so the dump is written before the +/// runner kills the test. +pub const default_timeout_ns: i96 = 150 * std.time.ns_per_s; /// A side-thread watchdog. Runs concurrently with the test on the calling /// thread; if the test does not `disarm()` within `timeout_ns`, it dumps the @@ -85,7 +67,7 @@ pub const Watchdog = struct { const out = &w.interface; const secs: u64 = @intCast(@divTrunc(self.timeout_ns, std.time.ns_per_s)); out.print( - "\n=== M1.0.1 test watchdog: '{s}' did not finish within {d}s — deadlock/livelock (covers scheduler.deinit join) ===\n", + "\n=== test watchdog: '{s}' did not finish within {d}s — deadlock/livelock (covers scheduler.deinit join) ===\n", .{ self.label, secs }, ) catch {}; if (self.sched.load(.acquire)) |sched| { From 296f18ff3197ea916bb514a889c02e1e1cba71f7 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 27 Sep 2026 19:07:56 +0200 Subject: [PATCH 106/141] test(platform): keep one sleepPrecise test, on its lower bound only A sleep that returns early is a bug and load only lengthens it, so the lower bound stays; the 50 ms ceiling measured the host and goes, and no bench replaces it, the corpus giving sleep no figure. The test beside the code is kept and tests/platform/time_test.zig deleted: both of its tests duplicated time.zig's. The time.zig comment pointing at a stricter bound there was false. Floor 2745 -> 2743, read from the suite; 2741 on windows. Co-Authored-By: Claude Opus 5.5 --- build.zig | 1 - src/core/platform/time.zig | 11 +++------ tests/platform/time_test.zig | 44 ---------------------------------- tools/weld_lint/dead_tests.zig | 4 ++-- 4 files changed, 5 insertions(+), 55 deletions(-) delete mode 100644 tests/platform/time_test.zig diff --git a/build.zig b/build.zig index c618b445..f4cc100c 100644 --- a/build.zig +++ b/build.zig @@ -1061,7 +1061,6 @@ pub fn build(b: *std.Build) void { .{ .path = "tests/scene/load_resources_test.zig" }, // common platform layer tests. .{ .path = "tests/platform/fs_vfs_test.zig" }, - .{ .path = "tests/platform/time_test.zig" }, .{ .path = "tests/platform/threading_test.zig" }, .{ .path = "tests/platform/dynamic_lib_test.zig" }, // Win32 thread safety stress (Windows runner only). diff --git a/src/core/platform/time.zig b/src/core/platform/time.zig index 809661c6..a3001b31 100644 --- a/src/core/platform/time.zig +++ b/src/core/platform/time.zig @@ -129,16 +129,11 @@ pub fn nowNanos() u64 { } } -test "time.sleepPrecise: 1 ms accuracy" { +test "time.sleepPrecise: sleeps at least the time asked" { const io = std.testing.io; const start = nowNanos(); - try sleepPrecise(io, 1_000_000); // 1 ms - const elapsed_ns = nowNanos() - start; - // Tolerance: 50 ms ceiling for slow CI. The dedicated bench test in - // tests/platform/time_test.zig enforces the tighter bound - // (< 2 ms Win32 / < 1 ms Linux). - try std.testing.expect(elapsed_ns >= 1_000_000); - try std.testing.expect(elapsed_ns < 50_000_000); + try sleepPrecise(io, 1_000_000); + try std.testing.expect(nowNanos() - start >= 1_000_000); } test "time.nowNanos: monotonic and non-decreasing" { diff --git a/tests/platform/time_test.zig b/tests/platform/time_test.zig deleted file mode 100644 index d2613b4d..00000000 --- a/tests/platform/time_test.zig +++ /dev/null @@ -1,44 +0,0 @@ -//! `sleepPrecise` precision and `nowNanos` monotonicity. -//! -//! `sleepPrecise` accuracy and `nowNanos` monotonicity. -//! -//! The specified gates — under 2 ms on Win32, under 1 ms on Linux — are tight -//! and CI runners are noisy, so the ceiling asserted inline is far looser: this -//! file is for CORRECTNESS, and the strict gates belong to the bench. - -const std = @import("std"); -const weld = @import("weld_core"); -const time = weld.platform.time; -const builtin = @import("builtin"); - -test "sleepPrecise ms accuracy" { - const io = std.testing.io; - - // Warm up the once-init path (timeBeginPeriod on Win32, no-op POSIX). - try time.sleepPrecise(io, 500_000); // 0.5 ms - - const start = time.nowNanos(); - try time.sleepPrecise(io, 1_000_000); // 1 ms - const elapsed = time.nowNanos() - start; - - try std.testing.expect(elapsed >= 1_000_000); - // CI tolerance: the specified gate is 2 ms on Win32 and 1 ms on Linux, and - // 50 ms is allowed here because GitHub Actions macOS / Linux runners stall - // arbitrarily under contention. The bench harness (Phase 1+) will - // enforce the tight gate on the reference machine cold-isolated. - try std.testing.expect(elapsed < 50_000_000); -} - -test "nowNanos: monotonic across busy-wait" { - var prev = time.nowNanos(); - var i: u32 = 0; - while (i < 100) : (i += 1) { - var j: u32 = 0; - while (j < 1000) : (j += 1) { - std.atomic.spinLoopHint(); - } - const cur = time.nowNanos(); - try std.testing.expect(cur >= prev); - prev = cur; - } -} diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index a1d4d53b..744cf42d 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2743, - else => 2745, + .windows => 2741, + else => 2743, }; } From d8fa1d1091ffad71217a37883c62e517df0027eb Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 27 Sep 2026 19:08:00 +0200 Subject: [PATCH 107/141] docs(brief): record the seven points of the sweep, ruled and applied Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- briefs/m1.d-phase-1-debt.md | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9259a1db..8c5d46ef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,7 +122,7 @@ commit, so a milestone still in review has no row here. - **M1.D.18 — a table component under sustained churn grows its chunk count until the DISPATCH FAILS (opened at M1.B/G11, measured, mechanism named)**. `Archetype.removeSwap` compacts INSIDE one chunk only — `chunk_idx` is fixed and the last slot OF THAT CHUNK fills the hole — and `archetype.zig` has NO release path: no `chunks.pop`, no `swapRemove`, no `entity_count == 0` test, no shrink. So the count follows the CUMULATIVE number of adds and never the live population. Measured by `bench/ecs_hybrid_crossover.zig` at `payload=64B`, fraction 1.0, churn 60/carrier/s: **128 chunks at the first tick, 8200 within the window**, for 20 000 entities over 8 archetypes — 2.4 entities per chunk where the payload allows ~156 — after which `jobs.Scheduler.dispatchBatch` returns `error.TooManyChunks` at its `workers × 8192` capacity. **The consequence is a HARD dispatch failure, not slowness**, and the population that reaches it is any durable churning load — precisely the load `@storage(.sparse)` exists to serve, whose range count is CONSTANT in the same report row. **Invisible to C0.1, which never churns.** NOT fix-as-you-go and the reason is structural, not convenience: the remedy is inter-chunk compaction or a partial-chunk free list, and **moving an entity between chunks invalidates the `chunk_ptr` of every live `ComponentRef`** — the type M1.B/G5 built, whose table arm deliberately holds a chunk pointer — so the fix touches a contract this milestone just froze, and it interacts with `chunkAt(i)`'s stability during a dispatch. That is a milestone, not a commit. Owner: the chunk-lifecycle owner; Guy carries the corpus side. **THE BLOCKER THIS ENTRY RECORDS NO LONGER EXISTS, measured at M1.D/S5/G6**: `M1.D.21` removed `chunk_ptr` in S2/G1 of that same milestone, one session BEFORE this sentence was written, so `ComponentRef` is `{entity, component_id, mutable}` and re-resolves at every access. Second deferral condition satisfied by work from elsewhere, after `M1.D.12`. And the half of the remedy that is REUSE — not compaction — landed at S5/G7 as `Archetype.first_partial`: it moves NO entity, because `removeSwap` leaves a dense prefix and free space at the tail, so its invalidation set is empty. Its benefit is deliberately UNMEASURED and no figure is offered, the instrument being absent (`DynamicQuery` exposes neither `chunkAt` nor `chunkCount`). - **`M1.D.14` gets its SEVENTH and EIGHTH measurements, and its first treatment (M1.B/G11)**. That plan row already carries this debt by name — *"le job `bench-ecs-smoke (windows-2025)` a une queue de durée qui franchit son budget `timeout-minutes: 10`"* — with six measurements at near-constant code (5m08, 6m52, 8m19, 9m21, 9m28, 9m38), factor 1.9, two cancellations and two green re-runs at the SAME SHA, and it names raising the budget as one of two options while taking neither. **M1.B adds 9m37 (passed, 23 seconds of headroom) and 11m28 (cancelled) and TAKES that option**: 10 → 20 minutes. M1.B also made the job heavier, measured rather than assumed — the step runs `zig build bench-ecs`, whose run step depends on the install step, so it compiles EVERY installed artifact, verified by deleting `zig-out/bin/` and watching `ecs-hybrid-crossover-bench` reappear beside `ecs-benchmark`. What the raise does NOT remove is the runner's intrinsic variance, which `M1.D.14`'s own analysis already establishes as the cause, nor the standing question of whether the Windows bench belongs in the PR matrix. *An earlier draft of this entry opened a parallel record under a new number; a debt treated under any name but its own stays open in the document that carries it.* Raised again to 30 minutes at M1.D/S5; detail in the brief. - **A CELL OF THE CI MATRIX HANGS, TEN TIMES MEASURED, AND THE CLASS HAS NO HOME IN THE CORPUS (opened at M1.B/G11, needs a number)**. Distinct from `M1.D.14`, which carries the DURATION of the bench job: this is the PENDING of a matrix cell that gates merges. **Cell:** `build-and-test (windows-2025, ReleaseSafe)`, both precisions. **Signature:** `error: test runner failed to respond for ~1m`, with **zero** occurrences of the sibling class `failed without output` — the two have never been co-present. **Count: TEN**, all on that cell, every one exonerated by a green re-run at the SAME SHA (three recorded before M1.B, two at M1.B/G10-G11, three at M1.B/P3-P4, one at M1.E/G12 on `6a2bb8e`, one at M1.E on `387ab97`). **The ninth, at M1.E/G12 on `6a2bb8e`, is the most informative the class has produced and it is the LARGEST BY AN ORDER**: `2164/2196 tests passed (32 skipped)` against a declared windows floor of 2250 gives **54 TESTS LOST**, where the previous counts were 1, 5, 6, 8 and 14. It ran 38.1 min against a 55-minute budget, so it is NOT the sibling timeout recorded below it — that one has every step green and no lost test — and the log carries ZERO `error: '…' failed:` lines, so no assertion fired. Fifty-four tests is a whole step of substance rather than a straggler, which narrows what can be hanging: the class is not a slow tail on a small step. **THE THIRTEENTH, at M1.A on `565012c`, hangs TWICE IN ONE RUN and the new discriminant reads BOTH**: signature present twice, sibling absent, zero assertions, `2253/2285 tests passed (32 skipped)` against a declared floor of 2288 — **THREE tests lost across two hung steps** — 52.6 minutes against 55 with conclusion `failure`. The two `failed command:` lines name `fecde19354ea9ccbd9ecb5d20cdb00ac` and `2fe9c3b586059afa4881744abc5715ef`: **two different executables in ONE build**, which is the within-run twin of the within-SHA comparison the twelfth produced. Four distinct identities are now recorded across three attempts and no two agree. **CLOSED at M1.D/S5 as `M1.D.19`**: a Zig 0.16 defect — the windows spawn leaves a test's pipe ends inheritable, so a concurrent compiler holds its end of stream — worked around by `--test-timeout 180s` on the matrix `zig build test`. Detail in the brief; the upstream report, not filed, is in `briefs/artifacts/`. The series of losses is 1, 5, 6, 8, 14, 54, 2, 1, 3. **THE TWELFTH, at M1.A on `e054b26`, ties the smallest loss and adds a collateral the class did not carry**: `2255/2287 tests passed (32 skipped)` against a declared floor of 2288 gives **ONE test lost**, signature present once, sibling class absent, zero `error: '…' failed:` lines, and **53.0 minutes against a 55-minute budget with conclusion `failure` and not `cancelled`** — which excludes `M1.D.27` on both of its discriminants at once rather than on duration alone. The series of losses is now 1, 5, 6, 8, 14, 54, 2, 1. **The collateral is that two debts met on one job**: the run carries `Zig cache is 11222302720 bytes, over the 10737418240 cap — SKIPPING the final save`, which is `M1.D.10`'s mechanism, and a skipped final save leaves the NEXT run on that cell colder, which is `M1.D.27`'s trigger. Neither debt is new; their interaction is recorded nowhere else. **IT FAILED ITS FIRST SAME-SHA RE-RUN, and that re-run produced a THIRD DISCRIMINANT this entry records as impossible.** Attempt 2: signature once, sibling absent, zero assertions, `2256/2287 (31 skipped)` against 2288 — **one test lost again**, where every previous pair of failures at one SHA had lost DIFFERENT counts, which is the shape an implicated test would produce. Refuted by measurement: this entry states that *"naming the step from the log does not work"*, which is true of the step's SOURCE name and FALSE of its identity — **the `failed command:` line immediately following the `failed to respond` message carries the hung step's build-cache hash**, and the two attempts differ (`01a58047…` against `c810df3f…`). Two DIFFERENT executables hung at one SHA, so no test is implicated, and the equal loss explains itself: both hung steps sit in the contiguous family whose neighbours all report `0 pass, 1 skip (1 total)`, where a hang costs exactly one test whichever member it hits. *The count was never the discriminant; the identity is, and it is one grep away in every log this class has already produced.* **THE ELEVENTH, at M1.A on `3f22cf6`, is the smallest loss the class has produced and the cleanest measurement of it**: `2245/2277 tests passed (32 skipped)` against a declared floor of 2279 gives **2 TESTS LOST**, with the signature present once, the sibling class absent, zero `error: '…' failed:` lines, and 52.7 minutes against a 55-minute budget — so `M1.D.27` is excluded by duration and by conclusion (`fail`, not `cancelled`). The series of losses is now 1, 5, 6, 8, 14, 54 and 2, which continues to refute any single implicated test. Cleared by a re-run at the SAME SHA. **THE TENTH, at M1.E on `387ab97`, is the first to hang TWICE IN ONE RUN**: two `failed to respond` twelve minutes apart (19:49:14 and 20:01:15 UTC) on two DIFFERENT test executables, and `301/304 steps succeeded (2 failed)` accounts for exactly those two. **And the loss stopped following the step count**: `2217/2249 tests passed (32 skipped)` against the same 2250 floor gives **ONE test lost for TWO hung steps**, so at least one of the two cost no test at all — where the counts so far had been 1, 5, 6, 8, 14 and 54, always for a single step. That asymmetry is NOT explained here: the natural reading, a runner that blocks after its last result is already reported, is plausible and unmeasured, and this class has already paid for two hypotheses issued on a plausible reading. What it does strengthen is the `0664f28` discriminant — two different steps, in one run, on a commit whose diff is comments and two Markdown files. It ran 40.2 min, SHORTER than the sibling timeout because it aborted on the hang rather than finishing, carries ZERO `error: '…' failed:`, and exonerated on the FIRST re-run. **AT ONE SHA (`0664f28`) THE CELL FAILED THREE TIMES AND LOST THREE DIFFERENT COUNTS — 14, 1 and 5 tests — hence three different step sets.** That is a discriminant the class did not previously have, and it is the strongest evidence yet that no single test is implicated: a test that hangs deterministically blocks the same step every time and loses the same number. **And the frequency moved**: the five occurrences preceding that SHA each exonerated on the FIRST same-SHA re-run, where this one took two — f64 green on re-run 1, f32 failing again with a third lost count and green only on re-run 2. Two collateral facts from the same investigation: `pre-push` runs `zig build test -Doptimize=ReleaseSafe` (`lefthook.yml:31`), so the failing cell's MODE is green on the dev machine at every push; and pushing over an in-flight run marks that run `failure` with no evidence of its own (`aa7416c`), which is the recorded status-predicate hazard seen from the other side. **Two discriminants, and only one of them works today.** (1) `declared − collected` from the `Build Summary` line `--summary all` already produces: at the M1.B occurrence, `302/304 steps succeeded (1 failed); 2103/2135 tests passed` against a declared windows floor of 2143 gives **8 tests lost**, so the hung step holds eight — a SIZE, computed and not inferred. (2) Naming the step from the log **does not work**: a hung step emits no output at all, so the per-step summary that would name it is exactly what is missing, and `--log-failed` returns the aggregate. Naming it needs either a per-step timeout that identifies its target or a step-by-step run. What DOES survive is a negative discriminant used at G11: a step that printed its own report AND its `failed command:` artifact has COMPLETED, which is how the M1.B/G10 scheduler-dispatch tests were exonerated without a re-run. **The corpus carries no foyer for this**: the signature returns zero occurrences across the corpus (measured), so ten occurrences on a merge-gating cell live only in a succession of briefs. -- **`M1.D.29` — CLOSED at M1.D/S5: `win32_thread_safety_test`'s 30 s wall-clock bound is gone**; it only detected a hang, and `--test-timeout`, passed by every CI matrix cell, names a hung test. Without that flag a running test has no deadline at all (Zig 0.16, measured). **The class was ruled and swept at M1.D/S5**: `engine-zig-conventions.md` §13 was rewritten, `--test-timeout` now runs wherever the suite does, pure hang detectors are gone, child-process waits are wide and kill on the bound, and duration gates left the tests for `bench/etch_reference.zig` and `bench/shader_hot_reload.zig`. **Raised to Guy and untouched**: the 5 s watchdog of the eight files that register a scheduler, whose dump is the suite's only scheduler-state diagnostic; `sleepPrecise`'s two tests; the async loader's `ticks >= 1`; `threading_test`'s spin "so the parent has time"; `process.zig`'s 20 ms pause before a kill; and `waitNonblock` answering 0 for a child killed by a signal. Detail in the brief, S5/G9 terdecies and quindecies. +- **`M1.D.29` — CLOSED at M1.D/S5: `win32_thread_safety_test`'s 30 s wall-clock bound is gone**; it only detected a hang, and `--test-timeout`, passed by every CI matrix cell, names a hung test. Without that flag a running test has no deadline at all (Zig 0.16, measured). **The class was ruled and swept at M1.D/S5**: `engine-zig-conventions.md` §13 was rewritten, `--test-timeout` now runs wherever the suite does, pure hang detectors are gone, child-process waits are wide and kill on the bound, and duration gates left the tests for `bench/etch_reference.zig` and `bench/shader_hot_reload.zig`. **The seven raised cases were ruled and applied**: the scheduler watchdog fires at 150 s, under the runner's 180 s; `sleepPrecise` keeps its lower bound in one test; the loader, thread and kill tests synchronise on events; `waitNonblock` answers minus the signal number for a signalled child. Detail in the brief, S5/G9 terdecies to sexdecies. - **The sparse-driven disjunctive path's first-use allocation is BOUNDED but not ISOLATED (opened at M1.1.15-era, measured at M1.B/G10)**. The entity-keyed disjunctive path allocates an `AutoHashMapUnmanaged` the FIRST time a sparse-driven term appears and reuses it after, so steady state is allocation-free like `merge_cursors` — the same shape as `contact_constraint.zig`'s `deferred` residual. `bench/ecs_hybrid_crossover.zig` now bounds it: the sparse arm's first-tick allocation count is **constant at 3** across all 28 cells of every configuration, where the table arm's grows **2 → 183** with churn. **What the bench does NOT do is isolate the map from the other two allocations on that tick** — it establishes that the quantity is a small constant and does not grow with fraction or churn, which is what the debt needed, and not which of the three is the map. Stated rather than implied. - **`D-M0.2.1-c01-baseline-investigation` is CLOSED, and it was tracking a phantom (closed at M1.B/G11)**. It followed the divergence between C0.1's 3.74 ms and a "14.2 ms M0.1 baseline" that **no M0.1 artifact carries**: the squash commit body, the annotated tag and `briefs/M0.1-ecs-full.md:316` all read **3.84 ms in ReleaseFast**, and `git log --all --grep=14.2` returns two commits of which the earlier is `df67e1c` (M0.2.1 itself). The real delta is **2.6 %**. Closed by naming it here and in the M1.B brief plus a head note on the dated report — `engine-audit-checklist.md` carries zero occurrences of the identifier (measured), so the debt lived only in a brief, and a brief is a document of its commit and is not edited. - **`build-and-test (windows-2025, ReleaseSafe)` HAD NO HEADROOM AT ITS 55-MINUTE BUDGET, and a comment-only commit was enough to spend it (opened at M1.E/G11, measured, mechanism named; the Release budget is 75 minutes since `c3e6d316`, Debug 35)**. **NOT the hang class**, whose count stays at eight: there, tests are LOST and the signature is `test runner failed to respond`. Here the job's conclusion is `cancelled` and **EVERY step succeeded**, `zig build test` and `Complete job` included — the job ran to its end and the wall arrived as it finished. Duration **55.0 min against `timeout-minutes: 55`** (`ci.yml:216`), and `fail-fast: false` means the other fourteen jobs ran green; `ci-gate` failed only because a required cell was not `success`. **Located step by step**: `zig build` 11.0 min, **`zig build test` 38.0 min**, the three cache steps 4.8 min together — so `M1.D.10`'s cache purging is NOT the cause here. The same cell on the two preceding commits of the same branch: `fb3d912` **12.5 min**, `7a7fc1b` **37.6 min**. A factor of 4.4 on a diff that is comments only. **The mechanism is the commit's own shape**, and it is `M1.D.8`'s transposed to another cell: the changed files are the tree's most-depended-upon Tier 0 headers — `world.zig`, `interp.zig`, `query.zig`, `observers.zig`, `archetype.zig`, `hybrid_query.zig`, `registry.zig`, `sparse_storage.zig`, both schedulers — and a comment edit changes a file's hash, so every compile step whose closure reaches them recompiles. A documentation pass over Tier 0 therefore costs the same cache as a refactor of it. **THE AMPLITUDE IS ATTRIBUTED, by a re-run at the SAME SHA whose only difference is that the first attempt's cache save had run**: attempt 2 took **10.3 min** where attempt 1 took 55.0, with `zig build test` at **3.2 min against 38.0** — a factor of **11.9** — and `zig build` at 4.0 against 11.0. So 34.8 of those 38 minutes were COLD-CACHE COMPILATION and not test execution, measured on one commit with the apparatus held fixed, which is `M1.D.8`'s finding with a clean before/after instead of a comparison across runs. The runner's intrinsic variance is NOT needed to explain it. The first of the two options was taken at `c3e6d316`: the Release budget went from 55 to 75 minutes. **AND A WARM CACHE IS NOT GUARANTEED BY A RE-RUN**: at M1.E on `387ab97`, another comment-only Tier 0 commit, the f64 leg of the same cell hung (the hang class above), and its re-run — which therefore started from whatever the FAILED attempt had left — took **53.1 min**, passing with **1.9 minutes of headroom**. The two classes appeared on one cell in one run, the hang on attempt 1 and the cold-cache cost on attempt 2, which does not merge them — one loses tests and prints `failed to respond`, the other has every step green and pays in minutes — but it is the second commit in two milestones where a comment-only edit to Tier 0 headers put this cell within two minutes of its wall. Owner: unassigned. diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index d37a6264..fabc0d2f 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -7090,6 +7090,36 @@ files it touched. One pass, by hand: the child wait now lives once, in headers of the test files it opened, was judged whole. Floor unchanged, 2728 / 2745 on macOS, `zig build lint` clean, the IPC binaries cross-compiled for windows. +### S5/G9 sexdecies — the seven points of the sweep, ruled + +Ruled with §13 adjusted (`sha256 095c2db9…`): an internal delay also survives to produce +a diagnostic the runner would not, firing under the runner's deadline; and a test +synchronises on an event, never on a duration or a workload. + +- **The scheduler watchdog** fires at 150 s, under the runner's 180 s, in the eight files + that register a scheduler; the steady-state test's per-dispatch dump fires 10 s earlier, + so a hang in a dispatch loop gets the richer dump rather than the global one. The stress + harness keeps its 5 s, its own constant now instead of the shared default. +- **`sleepPrecise`** keeps its lower bound alone, in one test beside the code; + `tests/platform/time_test.zig` is deleted, both of its tests duplicating `time.zig`'s — + the `nowNanos` pair was the same class. Floor 2745 → 2743 on macOS, read from the suite, + and 2743 → 2741 on windows. +- **The async loader** is held at a gate the test controls: it hands `beginLoad` an `Io` + whose file open waits for the test, so `!ready()` after `beginLoad` is deterministic, + and a counter proves the loader's open went through the gate. +- **`threading_test`'s child** waits for the parent's signal, released by a `defer` so a + failed call still ends it. +- **The kill test's child** creates a file before it execs its sleep, and the test waits + for that file instead of 20 ms. +- **`waitNonblock`** answered 0 for a child killed by a signal, the exit-status bits of a + signalled status being zero: it now answers minus the signal number on POSIX, and on + windows bit-casts the exit code, which `@intCast` would have trapped for an NTSTATUS. + Red first, as predicted, `expected -9, found 0`; the IPC binaries cross-compile for + `x86_64-linux-gnu` and `x86_64-windows`. + +Suite 2726 / 2743 (17 skipped), 329/329 steps, `zig build lint` clean, the `dead-tests` +closure agreeing at 2743. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree From b359c5ea64a5e7bc08908d46e5c1e5ecfa33e5e3 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 27 Sep 2026 19:34:13 +0200 Subject: [PATCH 108/141] fix(etch): keep one stale structural command from dropping the flush flushStructural applied each deferred command with try: two rules despawning one entity in a tick made the second despawn refuse with StaleEntityHandle, which dropped the rest of the batch, skipped the arena reset and ended stepOnce, so a later add never applied. Red first, as predicted, out of runFor. A command on a dead entity is now a no-op, as a stale tag is; any other refusal counts as a runtime error of the tick and the batch goes on; only an allocation failure ends the tick, as on the deferred extension path. The observer callbacks return anyerror, so a native listener's refusal is what reaches the counted branch, and a second witness pins it. Counter-factuals, each alone: the stale arm returning the error reddens the first witness only, the counted arm returning it the second only. Floor 2743 -> 2745 on macOS, read from the suite. Co-Authored-By: Claude Opus 5.5 --- src/etch/interp.zig | 94 ++++++++++++++++++++++++++++++++-- tools/weld_lint/dead_tests.zig | 4 +- 2 files changed, 92 insertions(+), 6 deletions(-) diff --git a/src/etch/interp.zig b/src/etch/interp.zig index 04dd80a1..f3ccf463 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -2271,7 +2271,7 @@ pub const Interpreter = struct { // Apply deferred structural mutations (spawn/despawn/add/remove) last, so // any extension hook's structural change (enqueued just above) drains in // the same boundary, with observers firing per op. - try self.flushStructural(world); + try self.flushStructural(world, report); // Every invocation of the tick has ended, async drives included, which // never reset the arena. if (!self.suppress_body_store_resets) self.drainDeferredDecrefs(); @@ -5621,14 +5621,21 @@ pub const Interpreter = struct { /// add-on-present → `on_replaced`; add → `on_add`; remove → `on_remove`). /// Drains until empty: an observer body may itself enqueue more structural /// commands (routed back to the same buffer), and those apply in a later - /// round of this same boundary. Never runs mid-`iterateArchetype`. - fn flushStructural(self: *Interpreter, world: *World) !void { + /// round of this same boundary. Never runs mid-`iterateArchetype`. A command + /// on a dead entity is a no-op, as a stale tag is; any other refusal counts + /// as a runtime error of the tick and the batch goes on; only an allocation + /// failure ends the tick. + fn flushStructural(self: *Interpreter, world: *World, report: *RuntimeReport) !void { const reg = &world.observer_registry; if (reg.deferred == null) return; while (reg.deferred.?.commands.items.len > 0) { const batch = try reg.deferred.?.commands.toOwnedSlice(reg.deferred.?.gpa); defer reg.deferred.?.gpa.free(batch); - for (batch) |c| try observers_mod.applyWithObservers(c, reg, world, self.gpa); + for (batch) |c| observers_mod.applyWithObservers(c, reg, world, self.gpa) catch |err| switch (err) { + error.StaleEntityHandle => {}, + error.OutOfMemory => return error.OutOfMemory, + else => report.runtime_errors += 1, + }; } // All commands applied — reclaim the payload arena. reg.deferred.?.reset(); @@ -16513,6 +16520,85 @@ test "despawn defers — entity removed at flush" { try std.testing.expectEqual(@as(usize, 0), world.entityCount()); } +test "a stale structural command is skipped and the rest of the flush applies" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try checkCleanProgram(gpa, + \\component Doomed { d: i32 = 0 } + \\component Marker { m: i32 = 0 } + \\component Shield { amount: i32 = 0 } + \\rule first(entity: Entity) when entity has Doomed { + \\ entity.despawn() + \\} + \\rule second(entity: Entity) when entity has Doomed { + \\ entity.despawn() + \\} + \\rule shield(entity: Entity) when entity has Marker { + \\ entity.add(Shield { amount: 7 }) + \\} + ); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + try interp.bindToWorld(&world); + + const doomed = world.registry.idOf("Doomed").?; + const marker = world.registry.idOf("Marker").?; + const shield = world.registry.idOf("Shield").?; + const d = try world.spawnDynamic(gpa, &[_]ComponentId{doomed}); + const m = try world.spawnDynamic(gpa, &[_]ComponentId{marker}); + + const report = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 0), report.runtime_errors); + try std.testing.expect(world.dynamicLocation(d) == null); + try std.testing.expectEqual(@as(i32, 7), readI32(&world, m, shield, "amount").?); +} + +fn refuseObserved( + _: ?*anyopaque, + _: *World, + _: CoreEntityId, + _: ?ComponentId, + _: ?*const anyopaque, + _: ?*const anyopaque, + _: *CommandBuffer, +) anyerror!void { + return error.Refused; +} + +test "a refused structural command counts as a runtime error and the flush goes on" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try checkCleanProgram(gpa, + \\component Doomed { d: i32 = 0 } + \\component Marker { m: i32 = 0 } + \\component Shield { amount: i32 = 0 } + \\rule shield(entity: Entity) when entity has Marker { + \\ entity.add(Shield { amount: 7 }) + \\} + \\rule kill(entity: Entity) when entity has Doomed { + \\ entity.despawn() + \\} + ); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + try interp.bindToWorld(&world); + + const doomed = world.registry.idOf("Doomed").?; + const marker = world.registry.idOf("Marker").?; + const shield = world.registry.idOf("Shield").?; + try world.observer_registry.registerOnAdd(gpa, shield, null, &refuseObserved); + const d = try world.spawnDynamic(gpa, &[_]ComponentId{doomed}); + _ = try world.spawnDynamic(gpa, &[_]ComponentId{marker}); + + const report = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 1), report.runtime_errors); + try std.testing.expect(world.dynamicLocation(d) == null); +} + test "add defers — component present at flush" { const gpa = std.testing.allocator; var world = World.init(); diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 744cf42d..a1d4d53b 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2741, - else => 2743, + .windows => 2743, + else => 2745, }; } From 087ea3a1f0287c8c37ef977029ddef56178c1b65 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 27 Sep 2026 20:25:19 +0200 Subject: [PATCH 109/141] docs(brief): record point 2 and the windows floor read from CI Co-Authored-By: Claude Opus 5.5 --- briefs/m1.d-phase-1-debt.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index fabc0d2f..57fe7255 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -7120,6 +7120,25 @@ synchronises on an event, never on a duration or a workload. Suite 2726 / 2743 (17 skipped), 329/329 steps, `zig build lint` clean, the `dead-tests` closure agreeing at 2743. +CI on `d8fa1d10`, read job by job: all eighteen green, bench included; the four windows +cells collect 2741 (`2711/2741 tests passed (30 skipped)`), the floor derived for them, and +the conservation step passes with `-Dexpect-collected=2741`. + +### S5/G9 septendecies — point 2, a stale command dropped the structural flush + +Ruled: the rest of the batch always goes on, and a stale command is a silent no-op, as a +stale tag is — the form of the deferred extension path. `flushStructural` applied each +command with `try`, so two rules despawning one entity in a tick made the second despawn +refuse with `StaleEntityHandle`, drop the rest of the batch, skip the arena reset and end +`stepOnce`: a later `add` never applied. Red first, as predicted, out of `runFor`. The +observer callbacks return `anyerror`, so a native listener's refusal is what reaches the +counted branch; its own witness pins it — counted once, and the despawn after it applies. +Counter-factuals, each alone: the stale arm returning the error reddens the first witness +only; the counted arm returning it, the second only. **My first run of that second +counter-factual measured nothing**: the mutation left `report` unused and forty steps +failed to compile, which I read before believing it; rerun with a mutation that compiles. +Floor 2743 → 2745 on macOS, read from the suite; 2743 on windows, derived until CI reads it. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree From 447961b7fac68e314449630e4438395734d4e85a Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 27 Sep 2026 22:34:50 +0200 Subject: [PATCH 110/141] fix(etch): refuse an annotation on a const, alias, import or impl The parser discarded the annotations of four declarations, so @phase(.update) on a const passed etch check with no diagnostic. The four keep their annotation range now, and the checker validates it against its own target, where no builtin annotation applies: E0502, as on any misapplied annotation. A custom annotation stays accepted everywhere, as it is on every other target. Red first, four witnesses. Co-Authored-By: Claude Opus 5.5 --- src/etch/ast.zig | 12 +++++++++-- src/etch/parser.zig | 20 ++++++++++++------- src/etch/types.zig | 27 ++++++++++++++++++++++++- tests/etch/annotation_arg_test.zig | 32 +++++++++++++++++++++++++++++- tools/weld_lint/dead_tests.zig | 4 ++-- 5 files changed, 82 insertions(+), 13 deletions(-) diff --git a/src/etch/ast.zig b/src/etch/ast.zig index b0d6ac65..d5627610 100644 --- a/src/etch/ast.zig +++ b/src/etch/ast.zig @@ -437,6 +437,8 @@ pub const ImportDecl = struct { 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) + annotations_extra: u32 = 0, + annotations_len: u32 = 0, }; /// Side-slab entry for a `component` declaration: name + range into @@ -718,6 +720,8 @@ const RuleParam = struct { pub const TypeAliasDecl = struct { name: StringId, target: NodeId, + annotations_extra: u32 = 0, + annotations_len: u32 = 0, }; /// Side-slab entry for a top-level `const` declaration (`etch-grammar.md` §4.1: @@ -730,6 +734,8 @@ pub const ConstDecl = struct { name: StringId, type_node: NodeId, value: NodeId, + annotations_extra: u32 = 0, + annotations_len: u32 = 0, }; /// Side-slab entry for a top-level `test` block (`etch-grammar.md` §17: @@ -1345,6 +1351,8 @@ pub const ImplDecl = struct { /// `arena.generic_params`. In scope for every method body. generics_start: u32 = 0, generics_len: u32 = 0, + annotations_extra: u32 = 0, + annotations_len: u32 = 0, }; /// Shape of an enum variant (`etch-grammar.md` §5.8). @@ -3069,9 +3077,9 @@ pub const AstArena = struct { _ = try self.addItem(gpa, .struct_decl, struct_idx, zero_span); } - pub fn addTypeAlias(self: *AstArena, gpa: std.mem.Allocator, name: StringId, target: NodeId, span: SourceSpan) !NodeId { + pub fn addTypeAlias(self: *AstArena, gpa: std.mem.Allocator, decl: TypeAliasDecl, span: SourceSpan) !NodeId { const idx: u32 = @intCast(self.type_alias_decls.items.len); - try self.type_alias_decls.append(gpa, .{ .name = name, .target = target }); + try self.type_alias_decls.append(gpa, decl); return try self.addItem(gpa, .type_alias, idx, span); } diff --git a/src/etch/parser.zig b/src/etch/parser.zig index dfc91179..25405e5a 100644 --- a/src/etch/parser.zig +++ b/src/etch/parser.zig @@ -870,14 +870,18 @@ pub const Parser = struct { /// `Name` is a PascalCase type identifier; `Type` is any type node. The /// `kw_type` starter is mirrored in `recoverToTopLevel`'s stop-set. fn parseTypeAliasDecl(self: *Parser, annotations: AnnotationRange) ParseError!void { - _ = annotations; // type aliases carry no annotations in the v0.6 subset const kw_span = (try self.advance()).span; // 'type' const name_tok = try self.expect(.type_ident, "expected PascalCase alias name after 'type'"); const name_id = try self.internSlice(name_tok.span); _ = try self.expect(.eq, "expected '=' in type alias declaration"); const target = try self.parseType(); const target_span = self.arena.typeNodeSpan(target); - _ = try self.arena.addTypeAlias(self.gpa, name_id, target, .{ + _ = try self.arena.addTypeAlias(self.gpa, .{ + .name = name_id, + .target = target, + .annotations_extra = annotations.start, + .annotations_len = annotations.len, + }, .{ .byte_start = kw_span.byte_start, .byte_end = target_span.byte_end, }); @@ -893,10 +897,8 @@ pub const Parser = struct { /// checked at resolve. Top-level ONLY — `parseStmt` does not handle /// `kw_const`, so a `const` inside a block falls through to a parse error /// (part1 §4.5). The `kw_const` starter is mirrored in `recoverToTopLevel`'s - /// stop-set + the `parseTopLevel` error enumeration. Like `import` / `type`, - /// the v0.6 subset attaches no annotations to a const (range discarded). + /// stop-set + the `parseTopLevel` error enumeration. fn parseConstDecl(self: *Parser, annotations: AnnotationRange) ParseError!void { - _ = annotations; // const carries no annotations in the v0.6 subset const kw_span = (try self.advance()).span; // 'const' const name_tok = if (self.peek() == .ident or self.peek() == .type_ident) try self.advance() @@ -911,6 +913,8 @@ pub const Parser = struct { .name = name_id, .type_node = type_node, .value = value, + .annotations_extra = annotations.start, + .annotations_len = annotations.len, }, .{ .byte_start = kw_span.byte_start, .byte_end = self.arena.exprSpan(value).byte_end }); } @@ -3635,7 +3639,6 @@ pub const Parser = struct { /// `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) @@ -3682,6 +3685,8 @@ pub const Parser = struct { .module_alias = module_alias, .items_start = items_start, .items_len = items_len, + .annotations_extra = annotations.start, + .annotations_len = annotations.len, }, .{ .byte_start = kw_span.byte_start, .byte_end = end_span.byte_end }); } @@ -4939,7 +4944,6 @@ pub const Parser = struct { /// name is rejected with a clear pointer. Methods reuse `parseFnLike` with /// `allow_self = true` and are stored in `arena.impl_methods`. fn parseImplDecl(self: *Parser, annotations: AnnotationRange) ParseError!void { - _ = annotations; // inherent impl carries no annotations in this subset const kw_span = self.current.span; _ = try self.advance(); // 'impl' // Optional impl-level generic params `impl …`; in @@ -5001,6 +5005,8 @@ pub const Parser = struct { .methods_len = methods_len, .generics_start = impl_generics.start, .generics_len = impl_generics.len, + .annotations_extra = annotations.start, + .annotations_len = annotations.len, }, .{ .byte_start = kw_span.byte_start, .byte_end = closing.span.byte_end }); } diff --git a/src/etch/types.zig b/src/etch/types.zig index ee769c79..07dfee3e 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -12,6 +12,8 @@ const const_eval = @import("const_eval.zig"); const diag_mod = @import("diagnostics.zig"); const tags_mod = @import("tags.zig"); const token_mod = @import("token.zig"); +const lexer_mod = @import("lexer.zig"); +const descriptor_mod = @import("descriptor.zig"); /// The storage-mode domain, read from the owner of the EFFECT rather than /// re-listed here (`etch-resolver-types.md` §13.3.1). `weld_etch` already /// depends on `weld_core` (`build.zig:50`) and `weld_core` imports only @@ -446,7 +448,23 @@ fn numericLitValue(gpa: std.mem.Allocator, arena: *const AstArena, expr_id: Node /// construct targets arrive with their constructs. `data` / `routine` join with the /// Level-B constructs (no builtin annotation targets them — only `.custom` is accepted, /// like `function`). -const AnnotTarget = enum { component, resource, rule, field, function, event, data, routine, behavior, quest, dialogue, ability, theme, motion, input_mapping, widget, locale, effect, audio_graph, audio_score, sequence, anim_graph, shader, scene, prefab, test_ }; +const AnnotTarget = enum { component, resource, rule, field, function, event, data, routine, behavior, quest, dialogue, ability, theme, motion, input_mapping, widget, locale, effect, audio_graph, audio_score, sequence, anim_graph, shader, scene, prefab, test_, const_, type_alias, import_, impl_ }; + +/// The first import alias in `aliases` that the rendered hook `text` names +/// while spelled like a builtin type or resource: once the cook respells each +/// alias by the name it aliases, that name is no longer told from the builtin. +fn builtinSpelledAlias(gpa: std.mem.Allocator, text: []const u8, aliases: anytype) !?[]const u8 { + var lx = lexer_mod.Lexer.init(text); + defer lx.deinit(gpa); + while (true) { + const tok = try lx.next(gpa); + if (tok.kind == .eof) return null; + if (tok.kind != .type_ident) continue; + const alias = text[tok.span.byte_start..tok.span.byte_end]; + if (!aliases.contains(alias)) continue; + if (BuiltinType.fromName(alias) != null or builtinResourceByName(alias) != null) return alias; + } +} /// Whether a builtin annotation kind is valid on `target` /// (cf. `etch-resolver-types.md` §13.2 + `etch-reference-part3.md` §1-§10). @@ -3665,6 +3683,7 @@ pub const TypeChecker = struct { // trait impl (`impl Trait for Type`, §5.2) → `trait_impls`. // Bodies are checked in pass 2 (`checkImplMethod`). const impl = self.arena.impl_decls.items[data]; + try self.validateAnnotations(impl.annotations_extra, impl.annotations_len, .impl_); if (impl.trait_name == 0) { try self.collectImplMethods(impl, span); } else { @@ -3683,6 +3702,7 @@ pub const TypeChecker = struct { // component/resource/rule (E0101); the target is validated // in `validateTypeAliases` once all symbols are known. const decl = self.arena.type_alias_decls.items[data]; + try self.validateAnnotations(decl.annotations_extra, decl.annotations_len, .type_alias); try self.registerSymbol(.type_alias, decl.name, item_id, span); }, .const_decl => { @@ -3692,6 +3712,7 @@ pub const TypeChecker = struct { // const-evaluable (E1101) and matches its declared type // (E0200) — reusing the field-default const surface. const decl = self.arena.const_decls.items[data]; + try self.validateAnnotations(decl.annotations_extra, decl.annotations_len, .const_); try self.registerSymbol(.const_, decl.name, item_id, span); try self.checkConstValue(decl.value, decl.type_node); }, @@ -3833,6 +3854,10 @@ pub const TypeChecker = struct { try self.validateAnnotations(decl.annotations_extra, decl.annotations_len, .audio_graph); try self.checkParamDefaults(decl.params_start, decl.params_len); }, + .import_decl => { + const decl = self.arena.import_decls.items[data]; + try self.validateAnnotations(decl.annotations_extra, decl.annotations_len, .import_); + }, else => {}, // forward-compatible: unknown items ignored } } diff --git a/tests/etch/annotation_arg_test.zig b/tests/etch/annotation_arg_test.zig index 01eed0c0..ecf6eb5d 100644 --- a/tests/etch/annotation_arg_test.zig +++ b/tests/etch/annotation_arg_test.zig @@ -1,5 +1,6 @@ //! An annotation argument follows `etch-grammar.md` §1.5, and a reader that -//! takes a positional argument refuses a named one. +//! takes a positional argument refuses a named one. An annotation on a +//! declaration no builtin annotation applies to is misapplied. const std = @import("std"); const weld_etch = @import("weld_etch"); @@ -64,3 +65,32 @@ test "a positional structural observer argument is accepted" { \\rule r(entity: Entity, value: Health) {} , "E1209")); } + +test "an annotation on a const is misapplied" { + try std.testing.expect(try reportsCode( + \\@phase(.update) + \\const MAX: int = 3 + , "E0502")); +} + +test "an annotation on a type alias is misapplied" { + try std.testing.expect(try reportsCode( + \\@phase(.update) + \\type Hp = int + , "E0502")); +} + +test "an annotation on an import is misapplied" { + try std.testing.expect(try reportsCode( + \\@phase(.update) + \\import lib { Health } + , "E0502")); +} + +test "an annotation on an impl is misapplied" { + try std.testing.expect(try reportsCode( + \\struct Cnt { v: int = 0 } + \\@phase(.update) + \\impl Cnt { fn bump(mut self) { self.v += 1 } } + , "E0502")); +} diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index a1d4d53b..85f37177 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2743, - else => 2745, + .windows => 2747, + else => 2749, }; } From f427e90156475115265c8b997519470886e204da Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 27 Sep 2026 22:34:58 +0200 Subject: [PATCH 111/141] fix(etch): cook a base prefab column the source does not name A scene instancing a prefab, or a variant extending one, had to declare or import every component of the base, which etch check never asks: the cook failed BaseSchemaMismatch on a project check accepted. A column the source does not name is now registered from the base's on-disk schema, its name, size and alignment being all the loader reads. A resource or a layout disagreeing with the source's own declaration is still refused. Red first, two witnesses; the cooked bytes equal those of the source importing the components. Co-Authored-By: Claude Opus 5.5 --- src/etch/scene_cook.zig | 22 +++++++++- tests/scene/import_cook_test.zig | 73 ++++++++++++++++++++++++++++++++ tools/weld_lint/dead_tests.zig | 4 +- 3 files changed, 95 insertions(+), 4 deletions(-) diff --git a/src/etch/scene_cook.zig b/src/etch/scene_cook.zig index 0a248cf1..01045d22 100644 --- a/src/etch/scene_cook.zig +++ b/src/etch/scene_cook.zig @@ -1306,15 +1306,33 @@ const Builder = struct { } /// Resolve a base prefab's on-disk column to this registry's entity component - /// of the same size and alignment, the predicate the loader applies. + /// of the same size and alignment, the predicate the loader applies. A column + /// the source does not name, which `etch check` does not require, is + /// registered from its schema. fn baseColumnId(self: *Builder, sch: accessor.Accessor.Schema, diag_out: ?*[]const u8) CookError!ComponentId { - const id = self.registry.idOf(sch.name) orelse return fail(diag_out, error.BaseSchemaMismatch, "base prefab uses a component this source does not declare"); + const id = self.registry.idOf(sch.name) orelse return self.registerBaseColumn(sch); if (self.registry.componentKind(id) == .resource) return fail(diag_out, error.BaseSchemaMismatch, "base prefab column is declared a resource here"); if (self.registry.componentSize(id) != sch.size or self.registry.componentAlignment(id) != sch.alignment) return fail(diag_out, error.BaseSchemaMismatch, "base prefab column layout disagrees with this source's declaration"); return id; } + fn registerBaseColumn(self: *Builder, sch: accessor.Accessor.Schema) CookError!ComponentId { + const defaults = try self.a().alloc(u8, sch.size); + @memset(defaults, 0); + return self.registry.registerComponentRaw(self.gpa, .{ + .name = sch.name, + .size = sch.size, + .alignment = sch.alignment, + .default_bytes = defaults, + .fields = &.{}, + }) catch |err| switch (err) { + error.OutOfMemory => error.OutOfMemory, + // The name is absent and the descriptor has no field. + error.DuplicateComponent, error.FieldOutOfBounds, error.CollectionDefaultNotEmpty => unreachable, + }; + } + /// Build one component blob (`componentSize` bytes) from the type defaults /// overridden by the instance's fields. Scalar fields encode in place; an /// `.entity_` field is NOT encoded — its slot keeps the default `EntityId.dead` diff --git a/tests/scene/import_cook_test.zig b/tests/scene/import_cook_test.zig index d05a82a5..488b3d0f 100644 --- a/tests/scene/import_cook_test.zig +++ b/tests/scene/import_cook_test.zig @@ -42,6 +42,7 @@ fn expectChecked(files: []const ProjectFile) !void { diags.deinit(gpa); } try weld_etch.validateProject(gpa, files, &diags); + for (diags.items) |d| std.debug.print("{s}: {s}\n", .{ d.code.code(), d.primary_message }); try std.testing.expectEqual(@as(usize, 0), diags.items.len); } @@ -589,3 +590,75 @@ test "a hook names what the last import of a name binds, as in etch check" { try scene.loader.runtimeActivate(&world, gpa, e, "Mod", ext_res.ext()); try std.testing.expectEqual(@as(i32, 7), std.mem.readInt(i32, world.componentBytes(e, pos).?[0..4], .little)); } + +const goblin_prefab = + \\import combat { Health, Weapon } + \\prefab "Goblin" { + \\ entity "root" { uuid: "00000000-0000-0000-0000-000000000001" Health { current: 5 } Weapon { damage: 3 } } + \\} +; + +test "a scene instancing a prefab cooks without naming the prefab's components, as etch check allows" { + const gpa = std.testing.allocator; + const instance = + \\scene "Level" { + \\ instance of "Goblin" "G" { uuid: "00000000-0000-0000-0000-0000000000a1" } + \\} + ; + const files = [_]ProjectFile{ + .{ .name = "src/combat.etch", .source = combat }, + .{ .name = "src/goblin.prefab.etch", .source = goblin_prefab }, + .{ .name = "src/level.scene.etch", .source = instance }, + }; + try expectChecked(&files); + const goblin = try prefabBytes(&files, 1, null); + defer gpa.free(goblin); + var resolver = OneResolver{ .name = "Goblin", .bytes = goblin }; + + var cooked = try scene_cook.cookSceneInProject(gpa, &files, 2, resolver.base(), null); + defer cooked.deinit(gpa); + const bytes = try written(&cooked); + defer gpa.free(bytes); + const importing = [_]ProjectFile{ + files[0], + files[1], + .{ .name = "src/level.scene.etch", .source = "import combat { Health, Weapon }\n" ++ instance }, + }; + var reference = try scene_cook.cookSceneInProject(gpa, &importing, 2, resolver.base(), null); + defer reference.deinit(gpa); + const reference_bytes = try written(&reference); + defer gpa.free(reference_bytes); + try std.testing.expectEqualSlices(u8, reference_bytes, bytes); +} + +test "a prefab variant cooks without naming its base's components, as etch check allows" { + const gpa = std.testing.allocator; + const variant = + \\import combat { Weapon } + \\prefab "Elite" of "Goblin" { entity "root" { Weapon { damage: 9 } } } + ; + const files = [_]ProjectFile{ + .{ .name = "src/combat.etch", .source = combat }, + .{ .name = "src/goblin.prefab.etch", .source = goblin_prefab }, + .{ .name = "src/elite.prefab.etch", .source = variant }, + }; + try expectChecked(&files); + const goblin = try prefabBytes(&files, 1, null); + defer gpa.free(goblin); + var resolver = OneResolver{ .name = "Goblin", .bytes = goblin }; + + const bytes = try prefabBytes(&files, 2, resolver.base()); + defer gpa.free(bytes); + const importing = [_]ProjectFile{ + files[0], + files[1], + // A base column registers after the file's own imports. + .{ .name = "src/elite.prefab.etch", .source = + \\import combat { Weapon, Health } + \\prefab "Elite" of "Goblin" { entity "root" { Weapon { damage: 9 } } } + }, + }; + const reference_bytes = try prefabBytes(&importing, 2, resolver.base()); + defer gpa.free(reference_bytes); + try std.testing.expectEqualSlices(u8, reference_bytes, bytes); +} diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 85f37177..539c14a4 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2747, - else => 2749, + .windows => 2749, + else => 2751, }; } From ca3f1e45535e1afa49a922aeebdac040c1042672 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 27 Sep 2026 22:35:03 +0200 Subject: [PATCH 112/141] fix(etch): carry the cook's name and alias refusals into etch check Three cases passed etch check and failed the cook: an alias and a local declaration giving two components one name, two modules exporting two components of one name, and an import alias spelled like a builtin in an extension hook. check now reports each as E0101, the code reused with a message of its own. The alias case goes through one predicate both read, on the hook text the cook renders and with the alias set it respells, so the cook refuses it through the prefab half of check and its own copy of the test, now unreachable, is gone. Red first, the three cook tests asserting check's refusal. Co-Authored-By: Claude Opus 5.5 --- src/etch/scene_cook.zig | 10 ++-- src/etch/types.zig | 82 ++++++++++++++++++++++++++++++++ tests/scene/import_cook_test.zig | 21 ++++++-- 3 files changed, 104 insertions(+), 9 deletions(-) diff --git a/src/etch/scene_cook.zig b/src/etch/scene_cook.zig index 01045d22..5168e1b4 100644 --- a/src/etch/scene_cook.zig +++ b/src/etch/scene_cook.zig @@ -955,16 +955,16 @@ const Builder = struct { else => return fail(diag_out, error.HookRenderFailed, "extension hook body could not be rendered to text"), }; defer self.gpa.free(text); - const spelled = try self.withOwnNames(text, diag_out); + const spelled = try self.withOwnNames(text); defer self.gpa.free(spelled); return self.internString(spelled); } /// `text` with each import alias spelled by the name it aliases: the loader /// checks and runs a hook against a program that declares it under that - /// name. An alias spelled like a builtin type or resource refuses the cook, - /// the text no longer telling the two apart. - fn withOwnNames(self: *Builder, text: []const u8, diag_out: ?*[]const u8) CookError![]u8 { + /// name. `checkHooks` has refused an alias spelled like a builtin type or + /// resource, which the respelled text could not tell apart. + fn withOwnNames(self: *Builder, text: []const u8) CookError![]u8 { var out: std.ArrayListUnmanaged(u8) = .empty; errdefer out.deinit(self.gpa); var lx = lexer.Lexer.init(text); @@ -976,8 +976,6 @@ const Builder = struct { if (tok.kind != .type_ident) continue; const alias = text[tok.span.byte_start..tok.span.byte_end]; const own = self.aliases.get(alias) orelse continue; - if (types_mod.BuiltinType.fromName(alias) != null or types_mod.builtinResourceByName(alias) != null) - return fail(diag_out, error.HookRenderFailed, "an import alias spelled like a builtin type or resource names the extension hook's component ambiguously"); try out.appendSlice(self.gpa, text[copied..tok.span.byte_start]); try out.appendSlice(self.gpa, own); copied = tok.span.byte_end; diff --git a/src/etch/types.zig b/src/etch/types.zig index 07dfee3e..57829d82 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -814,9 +814,75 @@ pub const TypeChecker = struct { fn runDeclarationPasses(self: *TypeChecker) !void { try self.collectDeclarations(); try self.bindImports(); + try self.checkComponentIdentities(); try self.validateDeclarations(); } + /// E0101 on two components reaching this file under one name, a component's + /// runtime identity being its name. + fn checkComponentIdentities(self: *TypeChecker) !void { + const project = self.project orelse return; + const Owner = struct { arena: *const AstArena, item: u32 }; + var owners: std.StringHashMapUnmanaged(Owner) = .empty; + defer owners.deinit(self.gpa); + const kinds = self.arena.items.items(.kind); + const datas = self.arena.items.items(.data); + const spans = self.arena.items.items(.span); + var i: u28 = 0; + while (i < self.arena.items.len) : (i += 1) { + if (kinds[i] != .component_decl) continue; + const name = self.arena.strings.slice(self.arena.component_decls.items[datas[i]].name); + try owners.put(self.gpa, name, .{ .arena = self.arena, .item = i }); + } + i = 0; + while (i < self.arena.items.len) : (i += 1) { + if (kinds[i] != .import_decl) continue; + const decl = self.arena.import_decls.items[datas[i]]; + var j: u32 = 0; + while (j < decl.items_len) : (j += 1) { + const item = self.arena.import_items.items[decl.items_start + j]; + const entry = self.importedBinding(importLocalName(item)) orelse continue; + if (entry.kind != .component) continue; + const decl_arena = &project.arenas[entry.arena_index]; + const name = decl_arena.strings.slice(decl_arena.component_decls.items[decl_arena.itemData(entry.item_id)].name); + const owner: Owner = .{ .arena = decl_arena, .item = entry.item_id.index }; + const gop = try owners.getOrPut(self.gpa, name); + if (!gop.found_existing) { + gop.value_ptr.* = owner; + } else if (!std.meta.eql(gop.value_ptr.*, owner)) { + try self.emit(.duplicate_symbol, .error_, spans[i], "two components named '{s}' reach this file, and a component's runtime identity is its name", .{name}); + } + } + } + } + + /// The import aliases of this file the cook respells: those whose item is + /// the one binding the alias. + fn boundAliases(self: *TypeChecker) !std.StringHashMapUnmanaged(void) { + var out: std.StringHashMapUnmanaged(void) = .empty; + errdefer out.deinit(self.gpa); + const project = self.project orelse return out; + const kinds = self.arena.items.items(.kind); + const datas = self.arena.items.items(.data); + 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 path = try importPath(self.gpa, self.arena, decl); + defer self.gpa.free(path); + const target = project.module_index.get(path) orelse continue; + var j: u32 = 0; + while (j < decl.items_len) : (j += 1) { + const item = self.arena.import_items.items[decl.items_start + j]; + if (item.alias == 0) continue; + const bound = self.importedBinding(item.alias) orelse continue; + const own = importedExport(project, self.arena, target, item) orelse continue; + if (std.meta.eql(own, bound)) try out.put(self.gpa, self.arena.strings.slice(item.alias), {}); + } + } + return out; + } + /// The passes before the imports bind: the file's own symbols. fn collectDeclarations(self: *TypeChecker) !void { // E1901 runs FIRST: it decides whether the file is even allowed to @@ -2593,9 +2659,25 @@ pub const TypeChecker = struct { try scope.put(self.gpa, self.arena.component_instances.items[ent.components_start + c].type_name, {}); } } + try self.checkHookAliases(decl, start, len); try self.checkHookBody(&scope, start, len); } + /// E0101 on a hook naming an import alias `builtinSpelledAlias` refuses. + fn checkHookAliases(self: *TypeChecker, decl: ast_mod.PrefabDecl, start: u32, len: u32) !void { + if (self.project == null) return; + const text = descriptor_mod.renderStmtRunAlloc(self.gpa, self.arena, start, len) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.UnsupportedDescriptorExpr => return, + }; + defer self.gpa.free(text); + var aliases = try self.boundAliases(); + defer aliases.deinit(self.gpa); + if (try builtinSpelledAlias(self.gpa, text, &aliases)) |alias| { + try self.emit(.duplicate_symbol, .error_, decl.name_span, "import alias '{s}' is spelled like a builtin type or resource, and an extension hook cannot tell the two apart", .{alias}); + } + } + /// A hook body gated on the components `scope` holds, in a hook's context. fn checkHookBody(self: *TypeChecker, scope: *const std.AutoHashMapUnmanaged(StringId, void), start: u32, len: u32) !void { var ctx: RuleCtx = .{}; diff --git a/tests/scene/import_cook_test.zig b/tests/scene/import_cook_test.zig index 488b3d0f..71c82741 100644 --- a/tests/scene/import_cook_test.zig +++ b/tests/scene/import_cook_test.zig @@ -343,6 +343,7 @@ test "an alias naming a component the file also declares refuses the cook" { \\} }, }; + try expectCheckReports(&files, .duplicate_symbol); try expectPrefabRefused(error.DuplicateType, &files, 1); } @@ -408,10 +409,24 @@ test "two imported components under one name refuse the cook" { \\} }, }; - try expectChecked(&files); + try expectCheckReports(&files, .duplicate_symbol); try expectPrefabRefused(error.DuplicateType, &files, 2); } +fn expectCheckReports(files: []const ProjectFile, code: weld_etch.diagnostics.DiagnosticCode) !void { + const gpa = std.testing.allocator; + var diags: std.ArrayListUnmanaged(weld_etch.Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try weld_etch.validateProject(gpa, files, &diags); + for (diags.items) |d| { + if (d.code == code) return; + } + return error.TestExpectedDiagnostic; +} + fn expectCheckRefused(files: []const ProjectFile) !void { const gpa = std.testing.allocator; var diags: std.ArrayListUnmanaged(weld_etch.Diagnostic) = .empty; @@ -540,8 +555,8 @@ test "an alias spelling a builtin type name refuses a hook's cook" { \\} }, }; - try expectChecked(&files); - try std.testing.expectError(error.HookRenderFailed, scene_cook.cookPrefabInProject(gpa, &files, 2, base_res.base(), null)); + try expectCheckReports(&files, .duplicate_symbol); + try std.testing.expectError(error.HookRefused, scene_cook.cookPrefabInProject(gpa, &files, 2, base_res.base(), null)); } test "a hook names what the last import of a name binds, as in etch check" { From 7b41e8f0b0d876b13cd8a9bb99f97d1c67d4f225 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 27 Sep 2026 22:35:21 +0200 Subject: [PATCH 113/141] feat(etch): report E0858 on a construct in the wrong typed extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit etch-grammar.md §21.2: a .scene.etch or .prefab.etch holds exactly one construct of its type and imports, and a scene or prefab lives only in its own typed file. The arena carries the file's typed extension, as it carries its parse mode; a parse with no path is not judged. A .layer.etch or .manifest.etch holds a construct this parser does not have, so any such file is refused. Red first, four witnesses and a control. Fixtures that broke §21 are rewritten to its form; two cook tests whose scenario needs a declaration in a prefab file now also expect E0858. Co-Authored-By: Claude Opus 5.5 --- src/etch/ast.zig | 6 +++ src/etch/diagnostics.zig | 3 ++ src/etch/parser.zig | 10 ++++ src/etch/project.zig | 3 +- src/etch/types.zig | 39 ++++++++++++++ tests/etch/crossfile_scene_prefab_test.zig | 63 ++++++++++++++++++++-- tests/etch/import_resolve_test.zig | 7 ++- tests/scene/import_cook_test.zig | 6 ++- tools/weld_lint/dead_tests.zig | 4 +- 9 files changed, 130 insertions(+), 11 deletions(-) diff --git a/src/etch/ast.zig b/src/etch/ast.zig index d5627610..65b64ba9 100644 --- a/src/etch/ast.zig +++ b/src/etch/ast.zig @@ -95,6 +95,11 @@ pub const ParseMode = enum { declaration_file, }; +/// The extension of the file an arena was parsed from, for `E0858` +/// (`etch-grammar.md` §21). `unknown` when the parse had no path, which +/// `E0858` does not judge. +pub const TypedExtension = enum { unknown, plain, scene, prefab, layer, manifest }; + /// Compact 32-bit handle into the `AstArena`: 4-bit `NodeCategory` + /// 28-bit index. Used as the universal pointer between AST nodes. pub const NodeId = packed struct(u32) { @@ -2563,6 +2568,7 @@ pub const AstArena = struct { /// The type-checker reads it to decide `E1901`, which is a question about /// the file's IDENTITY and cannot be answered from the node columns alone. mode: ParseMode = .standard, + typed_extension: TypedExtension = .unknown, items: std.MultiArrayList(Item) = .empty, stmts: std.MultiArrayList(Stmt) = .empty, diff --git a/src/etch/diagnostics.zig b/src/etch/diagnostics.zig index 599477b2..f90d6432 100644 --- a/src/etch/diagnostics.zig +++ b/src/etch/diagnostics.zig @@ -408,6 +408,7 @@ pub const DiagnosticCode = enum { // `etch-grammar.md` §20.3 describes keys on a `.etchc`, which does not exist. ── declaration_file_body_not_allowed, // E1900 DeclarationFileBodyNotAllowed (a `fn` carries a body inside a `.d.etch`) construct_not_allowed_in_declaration_file, // E1901 ConstructNotAllowedInDeclarationFile (a behavioural top-level construct appears in a `.d.etch`) + typed_extension_mismatch, // E0858 TypedExtensionMismatch (a construct in the wrong typed extension, `etch-grammar.md` §21.2) declaration_file_implementation_mismatch, // E1902 DeclarationFileImplementationMismatch (a committed `.d.etch` diverges from what the emitter produces on the current Zig `ServiceSpec`) /// Canonical short code, e.g. `"E0001"`. @@ -616,6 +617,7 @@ pub const DiagnosticCode = enum { .measure_outside_test => "E0910", .declaration_file_body_not_allowed => "E1900", .construct_not_allowed_in_declaration_file => "E1901", + .typed_extension_mismatch => "E0858", .declaration_file_implementation_mismatch => "E1902", }; } @@ -826,6 +828,7 @@ pub const DiagnosticCode = enum { .measure_outside_test => "MeasureOutsideTest", .declaration_file_body_not_allowed => "DeclarationFileBodyNotAllowed", .construct_not_allowed_in_declaration_file => "ConstructNotAllowedInDeclarationFile", + .typed_extension_mismatch => "TypedExtensionMismatch", .declaration_file_implementation_mismatch => "DeclarationFileImplementationMismatch", }; } diff --git a/src/etch/parser.zig b/src/etch/parser.zig index 25405e5a..03808035 100644 --- a/src/etch/parser.zig +++ b/src/etch/parser.zig @@ -56,6 +56,16 @@ pub fn modeForPath(path: []const u8) ParseMode { return .standard; } +/// The typed extension `path` names; `unknown` for a path that is no `.etch`. +pub fn typedExtensionForPath(path: []const u8) ast_mod.TypedExtension { + if (std.mem.endsWith(u8, path, ".scene.etch")) return .scene; + if (std.mem.endsWith(u8, path, ".prefab.etch")) return .prefab; + if (std.mem.endsWith(u8, path, ".layer.etch")) return .layer; + if (std.mem.endsWith(u8, path, ".manifest.etch")) return .manifest; + if (std.mem.endsWith(u8, path, ".etch")) return .plain; + return .unknown; +} + /// Container returned by `parse` — the populated arena plus the list of /// diagnostics collected during the parse. With the top-level /// recovery sync-point the parser no longer stops at the first error: diff --git a/src/etch/project.zig b/src/etch/project.zig index 36acae35..0a363679 100644 --- a/src/etch/project.zig +++ b/src/etch/project.zig @@ -57,7 +57,8 @@ pub const Project = struct { @memset(self.parse_failed, false); try self.arenas.ensureTotalCapacity(gpa, n); for (files, 0..) |f, idx| { - const pr = try parser.parseWithMode(gpa, f.source, parser.modeForPath(f.name)); + var pr = try parser.parseWithMode(gpa, f.source, parser.modeForPath(f.name)); + pr.ast.typed_extension = parser.typedExtensionForPath(f.name); // Each parse diagnostic moves into `diags_out` (its message // transfers), then only the vacated slice is freed — never // `pr.deinit`, which would free the arena kept below. diff --git a/src/etch/types.zig b/src/etch/types.zig index 57829d82..02325b64 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -891,6 +891,7 @@ pub const TypeChecker = struct { // no business being parsed. Cheap either way — one walk of the item // column, and an immediate return in `.standard` mode. try self.checkDeclarationFileConstructs(); + try self.checkTypedExtension(); try self.checkLiteralRanges(); try self.collectServices(); try self.collectDeclaredEvents(); @@ -1147,6 +1148,44 @@ pub const TypeChecker = struct { } } + /// E0858 (`etch-grammar.md` §21.2): a typed file holds exactly one construct + /// of its type and imports, and a `scene` or `prefab` lives only in its own + /// typed file. + fn checkTypedExtension(self: *TypeChecker) !void { + const ext = self.arena.typed_extension; + if (ext == .unknown) return; + const main: ?ast_mod.ItemKind = switch (ext) { + .scene => .scene_decl, + .prefab => .prefab_decl, + .layer, .manifest, .plain, .unknown => null, + }; + const kinds = self.arena.items.items(.kind); + var mains: u32 = 0; + for (kinds, 0..) |k, i| { + if (i >= self.arena.builtin_items_from) break; + const item_id = NodeId{ .category = .item, .index = @intCast(i) }; + const span = self.arena.itemSpan(item_id); + if (ext == .plain) { + const own: ?[]const u8 = switch (k) { + .scene_decl => "scene", + .prefab_decl => "prefab", + else => null, + }; + if (own) |o| try self.emit(.typed_extension_mismatch, .error_, span, "a {s} belongs in its own .{s}.etch file", .{ o, o }); + continue; + } + if (k == .import_decl) continue; + if (main != null and k == main.?) { + mains += 1; + if (mains > 1) try self.emit(.typed_extension_mismatch, .error_, span, "a .{s}.etch file holds exactly one {s}", .{ @tagName(ext), @tagName(ext) }); + continue; + } + try self.emit(.typed_extension_mismatch, .error_, span, "'{s}' is not allowed in a .{s}.etch file, which holds one {s} and its imports", .{ @tagName(k), @tagName(ext), @tagName(ext) }); + } + if (ext != .plain and mains == 0) + try self.emit(.typed_extension_mismatch, .error_, .{ .byte_start = 0, .byte_end = 0 }, "a .{s}.etch file holds exactly one {s}", .{ @tagName(ext), @tagName(ext) }); + } + /// Index every `service` this check can see. With a /// `ProjectContext` the index spans the whole project — which is the real /// case, since a service is declared in a `.d.etch` and called from a diff --git a/tests/etch/crossfile_scene_prefab_test.zig b/tests/etch/crossfile_scene_prefab_test.zig index a30ec084..426f718c 100644 --- a/tests/etch/crossfile_scene_prefab_test.zig +++ b/tests/etch/crossfile_scene_prefab_test.zig @@ -116,14 +116,15 @@ test "E1782 cross-scene duplicate uuid" { test "cross-file project green path resolves clean" { const gpa = std.testing.allocator; const files = [_]etch.ProjectFile{ - .{ .name = "prefabs.etch", .source = - \\component Marker { id: int = 0 } + .{ .name = "markers.etch", .source = "component Marker { id: int = 0 }" }, + .{ .name = "wall_torch.prefab.etch", .source = + \\import markers { Marker } \\prefab "WallTorch" { \\ entity "torch" { Marker { id: 1 } } \\} }, - .{ .name = "level.etch", .source = - \\component Marker { id: int = 0 } + .{ .name = "level.scene.etch", .source = + \\import markers { Marker } \\scene "Level" { \\ entity "light" { \\ uuid: "aaaaaaaa-0000-0000-0000-000000000001" @@ -143,3 +144,57 @@ test "cross-file project green path resolves clean" { // diagnostic of any severity. try std.testing.expectEqual(@as(usize, 0), diags.items.len); } + +fn e0858Count(files: []const etch.ProjectFile) !usize { + const gpa = std.testing.allocator; + var diags: std.ArrayListUnmanaged(etch.Diagnostic) = .empty; + defer deinitDiags(gpa, &diags); + try validate(gpa, files, &diags); + return countCode(diags.items, .typed_extension_mismatch); +} + +test "E0858 on a type declared in a scene file" { + try std.testing.expectEqual(@as(usize, 1), try e0858Count(&.{ + .{ .name = "src/level.scene.etch", .source = + \\component Marker { id: int = 0 } + \\scene "Level" { entity "e" { Marker { id: 1 } } } + }, + })); +} + +test "E0858 on a scene in a plain source file" { + try std.testing.expectEqual(@as(usize, 1), try e0858Count(&.{ + .{ .name = "src/combat.etch", .source = + \\component Marker { id: int = 0 } + \\scene "Level" { entity "e" { Marker { id: 1 } } } + }, + })); +} + +test "E0858 on two prefabs in one prefab file" { + try std.testing.expectEqual(@as(usize, 1), try e0858Count(&.{ + .{ .name = "src/marker.etch", .source = "component Marker { id: int = 0 }" }, + .{ .name = "src/two.prefab.etch", .source = + \\import marker { Marker } + \\prefab "A" { entity "e" { Marker { id: 1 } } } + \\prefab "B" { entity "e" { Marker { id: 2 } } } + }, + })); +} + +test "E0858 on a scene file holding no scene" { + try std.testing.expectEqual(@as(usize, 1), try e0858Count(&.{ + .{ .name = "src/marker.etch", .source = "component Marker { id: int = 0 }" }, + .{ .name = "src/empty.scene.etch", .source = "import marker { Marker }" }, + })); +} + +test "no E0858 on a scene file holding one scene and its imports" { + try std.testing.expectEqual(@as(usize, 0), try e0858Count(&.{ + .{ .name = "src/marker.etch", .source = "component Marker { id: int = 0 }" }, + .{ .name = "src/level.scene.etch", .source = + \\import marker { Marker } + \\scene "Level" { entity "e" { Marker { id: 1 } } } + }, + })); +} diff --git a/tests/etch/import_resolve_test.zig b/tests/etch/import_resolve_test.zig index da05a66b..b6639b56 100644 --- a/tests/etch/import_resolve_test.zig +++ b/tests/etch/import_resolve_test.zig @@ -200,9 +200,12 @@ test "a hook reaches imported requires and own components" { \\component Health { current: float = 100.0 } \\component Weapon { damage: float = 1.0 } }, - .{ .name = "main.etch", .source = - \\import lib { Health, Weapon } + .{ .name = "base.prefab.etch", .source = + \\import lib { Health } \\prefab "Base" { entity "r" { Health {} } } + }, + .{ .name = "mod.prefab.etch", .source = + \\import lib { Health, Weapon } \\prefab "Mod" extends "Base" requires Health { \\ entity "m" { Weapon {} } \\ on_attach { entity.get_mut(Health).current += entity.get(Weapon).damage } diff --git a/tests/scene/import_cook_test.zig b/tests/scene/import_cook_test.zig index 71c82741..99c79d9a 100644 --- a/tests/scene/import_cook_test.zig +++ b/tests/scene/import_cook_test.zig @@ -324,7 +324,7 @@ test "a local declaration shadows an import of its name, as in etch check" { .{ .name = "src/combat.etch", .source = combat }, .{ .name = "src/goblin.prefab.etch", .source = "import combat { Health }\n" ++ local }, }; - try expectChecked(&files); + try expectCheckReports(&files, .typed_extension_mismatch); const imported = try prefabBytes(&files, 1, null); defer gpa.free(imported); const declared = try inlinePrefabBytes(local, null); @@ -366,7 +366,9 @@ test "a requisite the file imports rather than declares refuses the cook, as it diags.deinit(gpa); } try weld_etch.validateProject(gpa, &files, &diags); - try std.testing.expectEqual(@as(usize, 1), diags.items.len); + try std.testing.expectEqual(@as(usize, 2), diags.items.len); + try expectCheckReports(&files, .unknown_requisite); + try expectCheckReports(&files, .typed_extension_mismatch); try expectPrefabRefused(error.UndeclaredType, &files, 1); } diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 539c14a4..de270719 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2749, - else => 2751, + .windows => 2754, + else => 2756, }; } From 8e7e52eb50e5288800d359d10072d55496a24b93 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 27 Sep 2026 22:35:50 +0200 Subject: [PATCH 114/141] docs(brief): record points 3 to 6 of the arbitrations Co-Authored-By: Claude Opus 5.5 --- briefs/m1.d-phase-1-debt.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index 57fe7255..288c6e17 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -7139,6 +7139,43 @@ counter-factual measured nothing**: the mutation left `report` unused and forty failed to compile, which I read before believing it; rerun with a mutation that compiles. Floor 2743 → 2745 on macOS, read from the suite; 2743 on windows, derived until CI reads it. +CI on `087ea3a1`, read job by job: all eighteen green, bench included; the four windows +cells collect 2743 (`2713/2743 tests passed (30 skipped)`), the floor derived for them. + +### S5/G9 octodecies — points 3 to 6 of the arbitrations + +Every witness red first at its predicted value; the suite then 2739 / 2756 (17 skipped), +the eleven new witnesses taking the floor 2745 → 2756 on macOS, read from the suite, and +2754 on windows, derived until CI reads it. + +- **3, an annotation on a `const` or an alias** (`447961b7`). The parser discarded the + annotations of four declarations, not two: `import` and an `impl` did the same. The + class is closed whole — the four keep their range and the checker validates it against + a target of its own, `E0502` for any builtin annotation. `.custom` stays accepted + everywhere, as on every target. Four witnesses. +- **4, `instance of` and `of`** (`f427e901`). A base column the source does not name is + registered from the base's on-disk schema — name, size, alignment, all the loader + reads; a resource or a disagreeing layout is still refused. Two witnesses, an instance + and a variant, each cooking to the bytes of the source importing the components. **My + first variant was refused by `check` itself** — `E1790`, a variant needs a component — + a prediction refuted in form and read before the rewrite; and the variant's reference + had to import `Weapon` before `Health`, a base column registering after the file's own + imports, the only difference the bytes showed. +- **5, the two cook-only refusals** (`ca3f1e45`). `check` reports `E0101` on two components + reaching a file under one name — alias against local declaration, or two modules — and + on an import alias spelled like a builtin in a hook, through one predicate the two read + on the hook text the cook renders, with the alias set it respells. The cook refuses the + last through the prefab half of `check`, `HookRefused` where it answered + `HookRenderFailed`, and its own copy of the test, now unreachable, is gone. `E0101` is + reused with a message of its own; no code is minted. +- **6, `E0858`** (`7b41e8f0`). The arena carries the file's typed extension as it carries + its parse mode, and a parse with no path is not judged. A `.layer.etch` or + `.manifest.etch` holds a construct this parser does not have, so any such file is + refused. Four witnesses and a control. Fixtures that broke §21 are rewritten to its + form; **two cook tests whose scenario needs a declaration in a prefab file** now expect + `E0858` beside their cook assertions — whether the cook must refuse what `E0858` refuses + is raised to Guy. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree From cd4b033e41085d07b1c1d349dd7458e2865b745b Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 28 Sep 2026 00:16:53 +0200 Subject: [PATCH 115/141] fix(etch): refuse in the project cook what E0858 refuses The project cook ran E0858 into scratch diagnostics and cooked what etch check refuses: a scene or prefab in a plain .etch file, and a typed file carrying another construct. It now refuses both as error.TypedExtensionMismatch, ahead of the import scope as in check. A source cooked without a path is named with no .etch suffix, so it is judged on no typed extension, as a source checked without a path. Three import-refusal tests declared a component in the prefab file; the component is now imported. Two tests whose property only a component declared in a typed file reached are removed, their valid forms already tested: an alias against a second import of the name, and a requisite named in a module that imports it. Co-Authored-By: Claude Opus 5.5 --- src/etch/scene_cook.zig | 24 +++++++++++- src/etch/types.zig | 12 ++++++ tests/scene/import_cook_test.zig | 66 ++++++++------------------------ tools/weld_lint/dead_tests.zig | 4 +- 4 files changed, 51 insertions(+), 55 deletions(-) diff --git a/src/etch/scene_cook.zig b/src/etch/scene_cook.zig index 5168e1b4..9187b273 100644 --- a/src/etch/scene_cook.zig +++ b/src/etch/scene_cook.zig @@ -130,6 +130,8 @@ pub const CookError = error{ /// An `import` the type checker refuses: a module the project lacks, an /// item its module does not export or keeps private, or an import cycle. ImportRefused, + /// The file holds what its typed extension refuses (E0858). + TypedExtensionMismatch, /// `prefab "Y" of "X"` but the base `X.prefab.bin` could not be resolved /// (no resolver, or the resolver returned null for the base name). BasePrefabMissing, @@ -208,13 +210,17 @@ pub fn cookScene( base_resolver: ?BaseResolver, diag_out: ?*[]const u8, ) CookError!Cooked { - const files = [_]ProjectFile{.{ .name = "cook.scene.etch", .source = source }}; + const files = [_]ProjectFile{.{ .name = source_name, .source = source }}; return cookSceneInProject(gpa, &files, 0, base_resolver, diag_out); } /// One source file of a multi-file project. pub const ProjectFile = project_mod.ProjectFile; +/// The name of a source cooked without a path: no `.etch` suffix, so, like a +/// source checked without a path, it is judged on no typed extension. +const source_name = "cook"; + /// Cook the `.scene.etch` at `index` in `files`, its imports resolved against /// the other files as `etch check` resolves them. pub fn cookSceneInProject(gpa: std.mem.Allocator, files: []const ProjectFile, index: usize, base_resolver: ?BaseResolver, diag_out: ?*[]const u8) CookError!Cooked { @@ -249,6 +255,7 @@ fn cookInProject( if (project.has_cycle) return fail(diag_out, error.ImportRefused, "the project's imports form a cycle"); const ctx = project.context(); const ast = &project.arenas.items[index]; + try checkTypedExtension(gpa, ast, diag_out); var scope = try resolveScope(gpa, ast, &ctx, diag_out); defer scope.deinit(gpa); if (kind == .prefab) try checkHooks(gpa, ast, &ctx, diag_out); @@ -309,6 +316,19 @@ pub const BaseResolver = struct { } }; +/// Refuse a file E0858 refuses. +fn checkTypedExtension(gpa: std.mem.Allocator, ast: *AstArena, diag_out: ?*[]const u8) CookError!void { + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + types_mod.TypeChecker.checkTypedExtensionOf(gpa, ast, &diags) catch |err| return switch (err) { + error.OutOfMemory => error.OutOfMemory, + }; + if (diags.items.len > 0) return fail(diag_out, error.TypedExtensionMismatch, "the file holds a construct its typed extension refuses"); +} + /// The file's names as the type checker resolves them, refusing an `import` it /// refuses. fn resolveScope(gpa: std.mem.Allocator, ast: *AstArena, project: *const ProjectContext, diag_out: ?*[]const u8) CookError!CookScope { @@ -360,7 +380,7 @@ pub fn cookPrefab( base_resolver: ?BaseResolver, diag_out: ?*[]const u8, ) CookError!Cooked { - const files = [_]ProjectFile{.{ .name = "cook.prefab.etch", .source = source }}; + const files = [_]ProjectFile{.{ .name = source_name, .source = source }}; return cookPrefabInProject(gpa, &files, 0, base_resolver, diag_out); } diff --git a/src/etch/types.zig b/src/etch/types.zig index 02325b64..08550f14 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -936,6 +936,18 @@ pub const TypeChecker = struct { } }; + /// What E0858 refuses in `arena`, reported into `diagnostics`. + pub fn checkTypedExtensionOf(gpa: std.mem.Allocator, arena: *AstArena, diagnostics: *std.ArrayListUnmanaged(Diagnostic)) !void { + var tc: TypeChecker = .{ + .gpa = gpa, + .arena = arena, + .diagnostics = diagnostics, + .project = null, + }; + defer tc.deinit(); + try tc.checkTypedExtension(); + } + /// `arena`'s scope resolved against `project`; what `check` refuses on an /// `import` is reported into `diagnostics`. pub fn cookScope(gpa: std.mem.Allocator, arena: *AstArena, project: *const ProjectContext, diagnostics: *std.ArrayListUnmanaged(Diagnostic)) !CookScope { diff --git a/tests/scene/import_cook_test.zig b/tests/scene/import_cook_test.zig index 99c79d9a..1e5eaf24 100644 --- a/tests/scene/import_cook_test.zig +++ b/tests/scene/import_cook_test.zig @@ -273,23 +273,23 @@ test "an instance override of an imported component passes etch check and cooks" test "an import of a module the project lacks refuses the cook" { const files = [_]ProjectFile{ + .{ .name = "src/arms.etch", .source = "component Weapon { damage: i32 = 0 }" }, .{ .name = "src/goblin.prefab.etch", .source = \\import ghost { Health } - \\component Weapon { damage: i32 = 0 } + \\import arms { Weapon } \\prefab "Goblin" { \\ entity "root" { uuid: "00000000-0000-0000-0000-000000000001" Weapon { damage: 3 } } \\} }, }; - try expectPrefabRefused(error.ImportRefused, &files, 0); + try expectPrefabRefused(error.ImportRefused, &files, 1); } test "an import of an item its module does not export refuses the cook" { const files = [_]ProjectFile{ .{ .name = "src/combat.etch", .source = combat }, .{ .name = "src/goblin.prefab.etch", .source = - \\import combat { Armor } - \\component Weapon { damage: i32 = 0 } + \\import combat { Armor, Weapon } \\prefab "Goblin" { \\ entity "root" { uuid: "00000000-0000-0000-0000-000000000001" Weapon { damage: 3 } } \\} @@ -300,10 +300,9 @@ test "an import of an item its module does not export refuses the cook" { test "an import of a private item refuses the cook" { const files = [_]ProjectFile{ - .{ .name = "src/combat.etch", .source = "private component Secret { v: i32 = 0 }" }, + .{ .name = "src/combat.etch", .source = "private component Secret { v: i32 = 0 }\ncomponent Weapon { damage: i32 = 0 }" }, .{ .name = "src/goblin.prefab.etch", .source = - \\import combat { Secret } - \\component Weapon { damage: i32 = 0 } + \\import combat { Secret, Weapon } \\prefab "Goblin" { \\ entity "root" { uuid: "00000000-0000-0000-0000-000000000001" Weapon { damage: 3 } } \\} @@ -312,64 +311,29 @@ test "an import of a private item refuses the cook" { try expectPrefabRefused(error.ImportRefused, &files, 1); } -test "a local declaration shadows an import of its name, as in etch check" { - const gpa = std.testing.allocator; - const local = +test "a .prefab.etch declaring a component refuses the cook, as E0858 refuses it" { + const files = [_]ProjectFile{.{ .name = "src/goblin.prefab.etch", .source = \\component Health { hp: i32 = 7 } \\prefab "Goblin" { \\ entity "root" { uuid: "00000000-0000-0000-0000-000000000001" Health { hp: 3 } } \\} - ; - const files = [_]ProjectFile{ - .{ .name = "src/combat.etch", .source = combat }, - .{ .name = "src/goblin.prefab.etch", .source = "import combat { Health }\n" ++ local }, - }; + }}; try expectCheckReports(&files, .typed_extension_mismatch); - const imported = try prefabBytes(&files, 1, null); - defer gpa.free(imported); - const declared = try inlinePrefabBytes(local, null); - defer gpa.free(declared); - try std.testing.expectEqualSlices(u8, declared, imported); -} - -test "an alias naming a component the file also declares refuses the cook" { - const files = [_]ProjectFile{ - .{ .name = "src/combat.etch", .source = combat }, - .{ .name = "src/goblin.prefab.etch", .source = - \\import combat { Health as HP } - \\component Health { hp: i32 = 7 } - \\prefab "Goblin" { - \\ entity "root" { uuid: "00000000-0000-0000-0000-000000000001" HP { current: 5 } } - \\} - }, - }; - try expectCheckReports(&files, .duplicate_symbol); - try expectPrefabRefused(error.DuplicateType, &files, 1); + try expectPrefabRefused(error.TypedExtensionMismatch, &files, 0); } -test "a requisite the file imports rather than declares refuses the cook, as it fails etch check" { - const gpa = std.testing.allocator; +test "a scene in a plain .etch file refuses the cook, as E0858 refuses it" { const files = [_]ProjectFile{ .{ .name = "src/combat.etch", .source = combat }, - .{ .name = "src/goblin.prefab.etch", .source = + .{ .name = "src/level.etch", .source = \\import combat { Health } - \\@requires(Health) - \\component Tag { t: i32 = 0 } - \\prefab "Goblin" { - \\ entity "root" { uuid: "00000000-0000-0000-0000-000000000001" Tag { t: 1 } } + \\scene "Level" { + \\ entity "npc" { uuid: "00000000-0000-0000-0000-000000000002" Health { max: 40 } } \\} }, }; - var diags: std.ArrayListUnmanaged(weld_etch.Diagnostic) = .empty; - defer { - for (diags.items) |*d| d.deinit(gpa); - diags.deinit(gpa); - } - try weld_etch.validateProject(gpa, &files, &diags); - try std.testing.expectEqual(@as(usize, 2), diags.items.len); - try expectCheckReports(&files, .unknown_requisite); try expectCheckReports(&files, .typed_extension_mismatch); - try expectPrefabRefused(error.UndeclaredType, &files, 1); + try std.testing.expectError(error.TypedExtensionMismatch, scene_cook.cookSceneInProject(std.testing.allocator, &files, 1, null, null)); } test "an import cycle in the project refuses the cook, as it fails etch check" { diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index de270719..8d46f5b1 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2754, - else => 2756, + .windows => 2753, + else => 2755, }; } From b23882e1fb0fddcf250e2cd66c9c9534b3b63075 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 28 Sep 2026 00:17:03 +0200 Subject: [PATCH 116/141] test(etch): move the cross-file scene and prefab fixtures to typed files The E1786, E1791 and E1782 fixtures put their prefabs and scenes in plain .etch files and declared a component beside them, so E0858 refused every one while the oracle counted only its own code. Each file now holds one construct and imports its component, and each oracle asserts that its code is the only diagnostic; the E0858 tests assert the same of E0858. Co-Authored-By: Claude Opus 5.5 --- tests/etch/crossfile_scene_prefab_test.zig | 47 ++++++++++++++-------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/tests/etch/crossfile_scene_prefab_test.zig b/tests/etch/crossfile_scene_prefab_test.zig index 426f718c..4253fad3 100644 --- a/tests/etch/crossfile_scene_prefab_test.zig +++ b/tests/etch/crossfile_scene_prefab_test.zig @@ -32,17 +32,24 @@ fn deinitDiags(gpa: std.mem.Allocator, diags: *std.ArrayListUnmanaged(etch.Diagn diags.deinit(gpa); } +/// `diags` holds `n` diagnostics, every one `code`. +fn expectOnly(diags: []const etch.Diagnostic, code: DiagnosticCode, n: usize) !void { + try std.testing.expectEqual(n, diags.len); + try std.testing.expectEqual(n, countCode(diags, code)); +} + test "E1786 cross-file prefab ref" { const gpa = std.testing.allocator; const files = [_]etch.ProjectFile{ - .{ .name = "prefabs.etch", .source = - \\component Marker { id: int = 0 } + .{ .name = "markers.etch", .source = "component Marker { id: int = 0 }" }, + .{ .name = "wall_torch.prefab.etch", .source = + \\import markers { Marker } \\prefab "WallTorch" { \\ entity "torch" { Marker { id: 1 } } \\} }, - .{ .name = "level.etch", .source = - \\component Marker { id: int = 0 } + .{ .name = "level.scene.etch", .source = + \\import markers { Marker } \\scene "Level" { \\ instance of "WallTorch" "t1" { Marker { id: 2 } } \\ instance of "Ghost" "t2" { Marker { id: 3 } } @@ -54,23 +61,27 @@ test "E1786 cross-file prefab ref" { try validate(gpa, &files, &diags); // `WallTorch` resolves across files (no error); `Ghost` exists nowhere → // exactly one cross-file E1786. - try std.testing.expectEqual(@as(usize, 1), countCode(diags.items, .prefab_ref_not_found)); + try expectOnly(diags.items, .prefab_ref_not_found, 1); } test "E1791 cross-file prefab base" { const gpa = std.testing.allocator; const files = [_]etch.ProjectFile{ - .{ .name = "base.etch", .source = - \\component Marker { id: int = 0 } + .{ .name = "markers.etch", .source = "component Marker { id: int = 0 }" }, + .{ .name = "base.prefab.etch", .source = + \\import markers { Marker } \\prefab "Base" { \\ entity "e" { Marker { id: 0 } } \\} }, - .{ .name = "derived.etch", .source = - \\component Marker { id: int = 0 } + .{ .name = "derived.prefab.etch", .source = + \\import markers { Marker } \\prefab "Derived" of "Base" { \\ entity "e" { Marker { id: 1 } } \\} + }, + .{ .name = "orphan.prefab.etch", .source = + \\import markers { Marker } \\prefab "Orphan" of "MissingBase" { \\ entity "e" { Marker { id: 2 } } \\} @@ -81,14 +92,15 @@ test "E1791 cross-file prefab base" { try validate(gpa, &files, &diags); // `Derived of Base` resolves across files; `Orphan of MissingBase` does not // → exactly one cross-file E1791. - try std.testing.expectEqual(@as(usize, 1), countCode(diags.items, .prefab_base_not_found)); + try expectOnly(diags.items, .prefab_base_not_found, 1); } test "E1782 cross-scene duplicate uuid" { const gpa = std.testing.allocator; const files = [_]etch.ProjectFile{ - .{ .name = "scene_a.etch", .source = - \\component Marker { id: int = 0 } + .{ .name = "markers.etch", .source = "component Marker { id: int = 0 }" }, + .{ .name = "scene_a.scene.etch", .source = + \\import markers { Marker } \\scene "SceneA" { \\ entity "e1" { \\ uuid: "11111111-1111-1111-1111-111111111111" @@ -96,8 +108,8 @@ test "E1782 cross-scene duplicate uuid" { \\ } \\} }, - .{ .name = "scene_b.etch", .source = - \\component Marker { id: int = 0 } + .{ .name = "scene_b.scene.etch", .source = + \\import markers { Marker } \\scene "SceneB" { \\ entity "e2" { \\ uuid: "11111111-1111-1111-1111-111111111111" @@ -110,7 +122,7 @@ test "E1782 cross-scene duplicate uuid" { defer deinitDiags(gpa, &diags); try validate(gpa, &files, &diags); // Same UUID in two scenes across files → exactly one cross-scene E1782. - try std.testing.expectEqual(@as(usize, 1), countCode(diags.items, .duplicate_uuid)); + try expectOnly(diags.items, .duplicate_uuid, 1); } test "cross-file project green path resolves clean" { @@ -145,12 +157,15 @@ test "cross-file project green path resolves clean" { try std.testing.expectEqual(@as(usize, 0), diags.items.len); } +/// The E0858 count of `files`, which carry no other diagnostic. fn e0858Count(files: []const etch.ProjectFile) !usize { const gpa = std.testing.allocator; var diags: std.ArrayListUnmanaged(etch.Diagnostic) = .empty; defer deinitDiags(gpa, &diags); try validate(gpa, files, &diags); - return countCode(diags.items, .typed_extension_mismatch); + const n = countCode(diags.items, .typed_extension_mismatch); + try std.testing.expectEqual(n, diags.items.len); + return n; } test "E0858 on a type declared in a scene file" { From b94a3dc43fa2fccd9b7ff45119cdab6c28be1a5e Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 28 Sep 2026 00:37:44 +0200 Subject: [PATCH 117/141] feat(etch): report E1799 on hooks or requires without extends etch check accepted requires, on_attach and on_detach on a standalone or of prefab, which the grammar makes a compile-time error and only the cook refused. It now reports E1799 PrefabHookNotAllowed on the prefab's name. Co-Authored-By: Claude Opus 5.5 --- src/etch/diagnostics.zig | 5 +++++ src/etch/types.zig | 26 ++++++++++++++++++++++++++ tools/weld_lint/dead_tests.zig | 4 ++-- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/etch/diagnostics.zig b/src/etch/diagnostics.zig index f90d6432..62c52189 100644 --- a/src/etch/diagnostics.zig +++ b/src/etch/diagnostics.zig @@ -378,6 +378,7 @@ pub const DiagnosticCode = enum { prefab_remove_base_component, // W1790 PrefabRemoveBaseComponent (RESERVED: no `remove` syntax in the §24.1 grammar) extension_additive_conflict, // E1797 ExtensionAdditiveConflict (fatal cook error, strictly-additive `extends` → reject: (a) two extensions declare the same component, (b) an extension declares a component already carried by the base/an earlier extension, (c) the same extension is listed twice; guarantees `cooked ⇒ loadable`; runtime backstops `error.ExtensionComponentConflict` (a/b) / `error.ExtensionAlreadyActive` (c)) illegal_return_in_extension_hook, // E1798 IllegalReturnInExtensionHook (`return` in an `on_attach` / `on_detach` body: a hook has no caller) + prefab_hook_not_allowed, // E1799 PrefabHookNotAllowed (`requires`, `on_attach` or `on_detach` on a prefab without `extends`) // ── async / effects (9xx, etch-resolver-types.md §9.2) ── async_call_in_non_async_context, // E0901 AsyncCallInNonAsyncContext (async fn/method call, or `await`, in a non-async fn/rule) @@ -606,6 +607,7 @@ pub const DiagnosticCode = enum { .prefab_remove_base_component => "W1790", .extension_additive_conflict => "E1797", .illegal_return_in_extension_hook => "E1798", + .prefab_hook_not_allowed => "E1799", .async_call_in_non_async_context => "E0901", .unhandled_throws_call => "E0902", .await_not_statement_head => "E0904", @@ -817,6 +819,7 @@ pub const DiagnosticCode = enum { .prefab_remove_base_component => "PrefabRemoveBaseComponent", .extension_additive_conflict => "ExtensionAdditiveConflict", .illegal_return_in_extension_hook => "IllegalReturnInExtensionHook", + .prefab_hook_not_allowed => "PrefabHookNotAllowed", .async_call_in_non_async_context => "AsyncCallInNonAsyncContext", .unhandled_throws_call => "UnhandledThrowsCall", .await_not_statement_head => "AwaitNotStatementHead", @@ -938,4 +941,6 @@ test "DiagnosticCode code and name are stable cross-version" { try std.testing.expectEqualStrings("ExtensionAdditiveConflict", DiagnosticCode.extension_additive_conflict.name()); try std.testing.expectEqualStrings("E1798", DiagnosticCode.illegal_return_in_extension_hook.code()); try std.testing.expectEqualStrings("IllegalReturnInExtensionHook", DiagnosticCode.illegal_return_in_extension_hook.name()); + try std.testing.expectEqualStrings("E1799", DiagnosticCode.prefab_hook_not_allowed.code()); + try std.testing.expectEqualStrings("PrefabHookNotAllowed", DiagnosticCode.prefab_hook_not_allowed.name()); } diff --git a/src/etch/types.zig b/src/etch/types.zig index 08550f14..e559d6f7 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -2638,6 +2638,9 @@ pub const TypeChecker = struct { fn validatePrefab(self: *TypeChecker, decl: ast_mod.PrefabDecl, prefab_names: *const std.AutoHashMapUnmanaged(StringId, usize)) !void { try self.validateAnnotations(decl.annotations_extra, decl.annotations_len, .prefab); + if (decl.relation != .extends and (decl.requires_len != 0 or decl.has_on_attach or decl.has_on_detach)) + try self.emit(.prefab_hook_not_allowed, .error_, decl.name_span, "prefab '{s}': `requires`, `on_attach` and `on_detach` are valid only on an `extends` prefab", .{self.arena.strings.slice(decl.name)}); + // E1790 — a prefab needs at least one component (across its entities). var total_components: u32 = 0; var e: u32 = 0; @@ -9390,6 +9393,29 @@ test "a return in a hook is E1798" { try expectAnyCode(r.diagnostics.items, .illegal_return_in_extension_hook); } +test "a hook on an of prefab is E1799" { + const gpa = std.testing.allocator; + var r = try checkHookSource(gpa, hook_base ++ + \\prefab "Variant" of "Base" { + \\ entity "r" { Health {} } + \\ on_attach { entity.get_mut(Health).max += 1.0 } + \\} + ); + defer r.deinit(gpa); + try std.testing.expectEqual(@as(usize, 1), countMessage(r.diagnostics.items, .prefab_hook_not_allowed, "extends")); +} + +test "a requires on a standalone prefab is E1799" { + const gpa = std.testing.allocator; + var r = try checkHookSource(gpa, hook_base ++ + \\prefab "Loner" requires Health { + \\ entity "r" { Health {} } + \\} + ); + defer r.deinit(gpa); + try std.testing.expectEqual(@as(usize, 1), countMessage(r.diagnostics.items, .prefab_hook_not_allowed, "extends")); +} + test "an await in a hook is E0901" { const gpa = std.testing.allocator; var r = try checkHookSource(gpa, hook_base ++ diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 8d46f5b1..2e481d1e 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2753, - else => 2755, + .windows => 2755, + else => 2757, }; } From 7e2f4ec8d6e1d4f6ad695f8ae99cf919856c024e Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 28 Sep 2026 00:38:05 +0200 Subject: [PATCH 118/141] fix(etch): refuse to cook an extends prefab without exactly one entity An extends prefab holding two entities, or none, cooked and the loader then refused it: an extension activates on one entity. The cook now refuses both, as MultiEntityExtensionUnsupported and EmptyExtension, the loader's own names. Co-Authored-By: Claude Opus 5.5 --- src/etch/scene_cook.zig | 10 ++++++++++ tests/scene/extensions_test.zig | 33 +++++++++++++++++++++++++++++++++ tools/weld_lint/dead_tests.zig | 4 ++-- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/etch/scene_cook.zig b/src/etch/scene_cook.zig index 9187b273..c27b5258 100644 --- a/src/etch/scene_cook.zig +++ b/src/etch/scene_cook.zig @@ -149,6 +149,11 @@ pub const CookError = error{ /// instantiation, and the hierarchy it would need, is not implemented /// anywhere: this error is the whole of the treatment. MultiEntityInstanceUnsupported, + /// An `extends` prefab holding more than one entity: an extension activates + /// on one entity. + MultiEntityExtensionUnsupported, + /// An `extends` prefab holding no entity. + EmptyExtension, /// A `Comp.field = value` per-field override targets a component the flattened /// instance does not carry (neither inherited from the prefab nor added by an /// earlier `Comp { … }` member of the same instance body). @@ -907,6 +912,11 @@ const Builder = struct { return fail(diag_out, error.PrefabHookNotAllowed, "`requires`/`on_attach`/`on_detach` are valid only on an `extends` prefab"); const prefab_entities = self.ast.scene_entities.items[pd.entities_start .. pd.entities_start + pd.entities_len]; + if (pd.relation == .extends) switch (prefab_entities.len) { + 0 => return fail(diag_out, error.EmptyExtension, "an `extends` prefab holds no entity"), + 1 => {}, + else => return fail(diag_out, error.MultiEntityExtensionUnsupported, "an `extends` prefab holds more than one entity"), + }; var entities: std.ArrayListUnmanaged(EntityBuild) = .empty; defer entities.deinit(self.gpa); diff --git a/tests/scene/extensions_test.zig b/tests/scene/extensions_test.zig index 0466b761..8a797cbe 100644 --- a/tests/scene/extensions_test.zig +++ b/tests/scene/extensions_test.zig @@ -293,6 +293,39 @@ const MultiResolver = struct { } }; +test "an extends prefab with two entities does not cook" { + const gpa = std.testing.allocator; + var base = try scene_cook.cookPrefab(gpa, base_character, null, null); + defer base.deinit(gpa); + const base_bytes = try scene.writer.write(gpa, base.model, &base.registry); + defer gpa.free(base_bytes); + var resolver = OneResolver{ .name = "BaseCharacter", .bytes = base_bytes }; + const twin = + \\component Weapon { damage: i32 = 0 } + \\prefab "TwinModule" extends "BaseCharacter" { + \\ entity "a" { uuid: "00000000-0000-0000-0000-0000000000f1" Weapon { damage: 1 } } + \\ entity "b" { uuid: "00000000-0000-0000-0000-0000000000f2" Weapon { damage: 2 } } + \\} + ; + try std.testing.expectError(error.MultiEntityExtensionUnsupported, scene_cook.cookPrefab(gpa, twin, resolver.base(), null)); +} + +test "an extends prefab with no entity does not cook" { + const gpa = std.testing.allocator; + var base = try scene_cook.cookPrefab(gpa, base_character, null, null); + defer base.deinit(gpa); + const base_bytes = try scene.writer.write(gpa, base.model, &base.registry); + defer gpa.free(base_bytes); + var resolver = OneResolver{ .name = "BaseCharacter", .bytes = base_bytes }; + const hollow = + \\component Health { current: i32 = 100, max: i32 = 100 } + \\prefab "HollowModule" extends "BaseCharacter" requires Health { + \\ on_attach { entity.get_mut(Health).max += 1 } + \\} + ; + try std.testing.expectError(error.EmptyExtension, scene_cook.cookPrefab(gpa, hollow, resolver.base(), null)); +} + /// Cook an `extends` prefab source to its `.prefab.bin` bytes (caller frees). No /// `requires` → the base need not exist, cookable with a null resolver. The bytes /// are a self-contained serialized artifact, independent of the (freed) `Cooked`. diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 2e481d1e..9e503edc 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2755, - else => 2757, + .windows => 2757, + else => 2759, }; } From b7bb550dd649a7cad4c7f20e749657594757f492 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 28 Sep 2026 00:38:22 +0200 Subject: [PATCH 119/141] fix(etch): resolve an imported component, resource or event everywhere The when clause, the emit statement and its construct forms, the await event targets, @on_event, the structural observers, entity.remove and receiver-less get looked a type name up in the file's own declarations only, so a name an import binds was refused there, and a field of an imported component or resource was typed unknown and never checked. Each now resolves through the import, reading the declaration from the arena that holds it. A table test runs forty positions twice, the names imported and then declared, and requires the same diagnostics. Co-Authored-By: Claude Opus 5.5 --- src/etch/types.zig | 242 ++++++++++++++--------------- tests/etch/import_resolve_test.zig | 83 ++++++++++ tools/weld_lint/dead_tests.zig | 4 +- 3 files changed, 200 insertions(+), 129 deletions(-) diff --git a/src/etch/types.zig b/src/etch/types.zig index e559d6f7..132f2926 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -2018,8 +2018,7 @@ pub const TypeChecker = struct { } if (kf.kind == .emit) { const em = self.arena.emit_stmts.items[self.arena.stmtData(kf.value)]; - const sym = self.symbols.get(em.event_type); - if (sym == null or sym.?.kind != .event_) { + if (self.eventNamed(em.event_type) == null) { try self.emit(.event_track_event_unknown, .error_, kf.span, "keyframe emits '{s}', which is not a declared event", .{self.arena.strings.slice(em.event_type)}); } } @@ -2333,6 +2332,56 @@ pub const TypeChecker = struct { return .{ .arena = self.arena, .decl = self.arena.component_decls.items[self.arena.itemData(sym.item_id)] }; } + /// A resource declaration and the arena it lives in. + const ResourceRef = struct { arena: *const AstArena, decl: ast_mod.ResourceDecl }; + + /// The resource `name` names; a symbol that is no resource names none. + fn resourceNamed(self: *TypeChecker, name: StringId) ?ResourceRef { + if (self.importedBinding(name)) |entry| { + if (entry.kind != .resource) return null; + const decl_arena = &self.project.?.arenas[entry.arena_index]; + return .{ .arena = decl_arena, .decl = decl_arena.resource_decls.items[decl_arena.itemData(entry.item_id)] }; + } + const sym = self.symbols.get(name) orelse return null; + if (sym.kind != .resource) return null; + return .{ .arena = self.arena, .decl = self.arena.resource_decls.items[self.arena.itemData(sym.item_id)] }; + } + + /// The event `name` names, the file's own or an imported one. + fn eventNamed(self: *TypeChecker, name: StringId) ?ForeignEvent { + if (self.importedBinding(name)) |entry| { + if (entry.kind != .event_) return null; + const decl_arena = &self.project.?.arenas[entry.arena_index]; + return .{ .arena = decl_arena, .decl = decl_arena.event_decls.items[decl_arena.itemData(entry.item_id)] }; + } + const sym = self.symbols.get(name) orelse return null; + if (sym.kind != .event_) return null; + return .{ .arena = self.arena, .decl = self.arena.event_decls.items[self.arena.itemData(sym.item_id)] }; + } + + /// The kind of what `name` names, an import included. + fn kindNamed(self: *TypeChecker, name: StringId) ?SymbolKind { + if (self.importedBinding(name)) |entry| return entry.kind; + const sym = self.symbols.get(name) orelse return null; + return sym.kind; + } + + /// The type of the field named `name` among `a.fields[start .. start + len]`, + /// matched by bytes in a foreign arena; null when there is none. + fn fieldTypeIn(self: *TypeChecker, a: *const AstArena, start: u32, len: u32, name: StringId) ?ResolvedType { + const want = self.arena.strings.slice(name); + var i: u32 = 0; + while (i < len) : (i += 1) { + const f = a.fields.items[start + i]; + if (a == self.arena) { + if (f.name == name) return self.namedTypeToResolved(f.type_node); + } else if (std.mem.eql(u8, a.strings.slice(f.name), want)) { + return self.foreignFieldType(a, f.type_node); + } + } + return null; + } + /// One instance field against `component`'s declared fields, the imported /// ones matched by name across arenas. fn checkComponentField(self: *TypeChecker, component: ComponentRef, owner: []const u8, field: ast_mod.StructLitField, code_field: DiagnosticCode, code_field_type: DiagnosticCode) !void { @@ -3200,8 +3249,8 @@ pub const TypeChecker = struct { // Emit payload: E1550 on an unknown event // reference, else the regular emit checks. const em = self.arena.emit_stmts.items[self.arena.stmtData(h.payload)]; - if (self.symbols.get(em.event_type)) |sym| { - if (sym.kind != .event_) { + if (self.kindNamed(em.event_type)) |k| { + if (k != .event_) { try self.emit(.event_reference_not_found, .error_, h.span, "'{s}' is not an event", .{self.arena.strings.slice(em.event_type)}); } else { try self.checkStmt(ctx, h.payload); @@ -3340,8 +3389,8 @@ pub const TypeChecker = struct { .emit => { const em_elem = self.arena.dialogue_emits.items[elem.index]; const em = self.arena.emit_stmts.items[self.arena.stmtData(em_elem.stmt)]; - if (self.symbols.get(em.event_type)) |sym| { - if (sym.kind != .event_) { + if (self.kindNamed(em.event_type)) |k| { + if (k != .event_) { try self.emit(.dialogue_event_type_unknown, .error_, em_elem.span, "'{s}' is not an event", .{self.arena.strings.slice(em.event_type)}); } else { try self.checkStmt(ctx, em_elem.stmt); @@ -5203,8 +5252,7 @@ pub const TypeChecker = struct { var comp_name: ?StringId = null; if (needs_component) { if (self.arena.observerComponentName(annot)) |cn| { - const sym = self.symbols.get(cn); - if (sym != null and sym.?.kind == .component) { + if (self.componentNamed(cn) != null) { comp_name = cn; } else { try self.emit(.observer_component_invalid, .error_, annot.span, "@{s}(T) requires T to be a declared component; '{s}' is not", .{ aname, self.arena.strings.slice(cn) }); @@ -5266,12 +5314,10 @@ pub const TypeChecker = struct { // binding named `event`, self-style like `self` in a method). if (self.arena.onEventAnnotation(rule)) |annot| { if (self.arena.onEventTypeName(annot)) |event_type| { - const sym = self.symbols.get(event_type); - const local_event = sym != null and sym.?.kind == .event_; // A `.d.etch`-declared event resolves here too — // which is the whole point of admitting `event_decl` into §20.1 // a rule can only observe an event whose type Etch knows. - if (local_event or self.declaredEvent(event_type) != null) { + if (self.eventNamed(event_type) != null or self.declaredEvent(event_type) != null) { const event_id = try self.arena.strings.intern(self.gpa, "event"); try ctx.locals.put(self.gpa, event_id, .{ .type_ = .{ .event_t = event_type }, .is_mut = false }); } else { @@ -5704,12 +5750,10 @@ pub const TypeChecker = struct { // filter on a non-component is just an unknown component); the // resolver/ruling deliberately does NOT mint a new E12xx for it. const tname_slice = self.arena.strings.slice(node.type_name); - if (self.symbols.get(node.type_name)) |sym| { - if (sym.kind != .component) { - try self.emit(.unknown_component_in_when, .error_, node.span, "'has' clause requires a component, '{s}' is a {s}", .{ tname_slice, @tagName(sym.kind) }); - } else { - try ctx.components_in_when.put(self.gpa, node.type_name, {}); - } + if (self.componentNamed(node.type_name) != null) { + try ctx.components_in_when.put(self.gpa, node.type_name, {}); + } else if (self.kindNamed(node.type_name)) |k| { + try self.emit(.unknown_component_in_when, .error_, node.span, "'has' clause requires a component, '{s}' is a {s}", .{ tname_slice, @tagName(k) }); } else { try self.emit(.unknown_component_in_when, .error_, node.span, "unknown component '{s}' in when clause", .{tname_slice}); } @@ -5720,12 +5764,10 @@ pub const TypeChecker = struct { }, .resource, .resource_changed => { const tname_slice = self.arena.strings.slice(node.type_name); - if (self.symbols.get(node.type_name)) |sym| { - if (sym.kind != .resource) { - try self.emit(.resource_expected_in_when, .error_, node.span, "'resource' clause requires a resource, '{s}' is a {s}", .{ tname_slice, @tagName(sym.kind) }); - } else { - try ctx.resources_in_when.put(self.gpa, node.type_name, {}); - } + if (self.resourceNamed(node.type_name) != null) { + try ctx.resources_in_when.put(self.gpa, node.type_name, {}); + } else if (self.kindNamed(node.type_name)) |k| { + try self.emit(.resource_expected_in_when, .error_, node.span, "'resource' clause requires a resource, '{s}' is a {s}", .{ tname_slice, @tagName(k) }); } else { try self.emit(.resource_expected_in_when, .error_, node.span, "unknown resource '{s}' in when clause", .{tname_slice}); } @@ -5748,14 +5790,11 @@ pub const TypeChecker = struct { // fields bound by name) and must be bool (E1211). An unknown // name inside the filter surfaces as the regular E0102. const tname_slice = self.arena.strings.slice(node.type_name); - if (self.symbols.get(node.type_name)) |sym| { - if (sym.kind != .component) { - try self.emit(.unknown_component_in_when, .error_, node.span, "'has' clause requires a component, '{s}' is a {s}", .{ tname_slice, @tagName(sym.kind) }); - } else { - try ctx.components_in_when.put(self.gpa, node.type_name, {}); - const decl = self.arena.component_decls.items[self.arena.itemData(sym.item_id)]; - try self.checkWhenExprFilter(node, decl.fields_start, decl.fields_len); - } + if (self.componentNamed(node.type_name)) |c| { + try ctx.components_in_when.put(self.gpa, node.type_name, {}); + try self.checkWhenExprFilter(node, c.arena, c.decl.fields_start, c.decl.fields_len); + } else if (self.kindNamed(node.type_name)) |k| { + try self.emit(.unknown_component_in_when, .error_, node.span, "'has' clause requires a component, '{s}' is a {s}", .{ tname_slice, @tagName(k) }); } else { try self.emit(.unknown_component_in_when, .error_, node.span, "unknown component '{s}' in when clause", .{tname_slice}); } @@ -5764,14 +5803,11 @@ pub const TypeChecker = struct { // `resource T { expression }` (— §6). Resource check // identical to `.resource`; same fields-only filter typing. const tname_slice = self.arena.strings.slice(node.type_name); - if (self.symbols.get(node.type_name)) |sym| { - if (sym.kind != .resource) { - try self.emit(.resource_expected_in_when, .error_, node.span, "'resource' clause requires a resource, '{s}' is a {s}", .{ tname_slice, @tagName(sym.kind) }); - } else { - try ctx.resources_in_when.put(self.gpa, node.type_name, {}); - const decl = self.arena.resource_decls.items[self.arena.itemData(sym.item_id)]; - try self.checkWhenExprFilter(node, decl.fields_start, decl.fields_len); - } + if (self.resourceNamed(node.type_name)) |r| { + try ctx.resources_in_when.put(self.gpa, node.type_name, {}); + try self.checkWhenExprFilter(node, r.arena, r.decl.fields_start, r.decl.fields_len); + } else if (self.kindNamed(node.type_name)) |k| { + try self.emit(.resource_expected_in_when, .error_, node.span, "'resource' clause requires a resource, '{s}' is a {s}", .{ tname_slice, @tagName(k) }); } else { try self.emit(.resource_expected_in_when, .error_, node.span, "unknown resource '{s}' in when clause", .{tname_slice}); } @@ -5796,13 +5832,18 @@ pub const TypeChecker = struct { /// `resource T`) in a FIELDS-ONLY scope: each field of the /// filtered component/resource is bound by name to its declared type. /// Non-bool filters are E1211 (the field-filter code family). - fn checkWhenExprFilter(self: *TypeChecker, node: ast_mod.WhenNode, fields_start: u32, fields_len: u32) !void { + fn checkWhenExprFilter(self: *TypeChecker, node: ast_mod.WhenNode, a: *const AstArena, fields_start: u32, fields_len: u32) !void { var scratch: RuleCtx = .{}; defer scratch.deinit(self.gpa); var f: u32 = 0; while (f < fields_len) : (f += 1) { - const field = self.arena.fields.items[fields_start + f]; - try scratch.locals.put(self.gpa, field.name, .{ .type_ = self.namedTypeToResolved(field.type_node), .is_mut = false }); + const field = a.fields.items[fields_start + f]; + if (a == self.arena) { + try scratch.locals.put(self.gpa, field.name, .{ .type_ = self.namedTypeToResolved(field.type_node), .is_mut = false }); + } else { + const name = try self.arena.strings.intern(self.gpa, a.strings.slice(field.name)); + try scratch.locals.put(self.gpa, name, .{ .type_ = self.foreignFieldType(a, field.type_node), .is_mut = false }); + } } const t = try self.synthExprE(node.filter_value, &scratch); if (t != .unknown and !(t == .builtin and t.builtin == .bool_)) { @@ -5837,27 +5878,14 @@ pub const TypeChecker = struct { fn checkFieldFilter(self: *TypeChecker, node: ast_mod.WhenNode) !void { // `entity has T { field == value }` — verify field on T and value type. - const comp_sym = self.symbols.get(node.type_name) orelse return; - if (comp_sym.kind != .component) return; - const comp_data = self.arena.itemData(comp_sym.item_id); - const comp_decl = self.arena.component_decls.items[comp_data]; - var f_i: u32 = 0; - var found: ?ast_mod.Field = null; - while (f_i < comp_decl.fields_len) : (f_i += 1) { - const f = self.arena.fields.items[comp_decl.fields_start + f_i]; - if (f.name == node.field_name) { - found = f; - break; - } - } - if (found == null) { + const comp = self.componentNamed(node.type_name) orelse return; + const declared = self.fieldTypeIn(comp.arena, comp.decl.fields_start, comp.decl.fields_len, node.field_name) orelse { const fname = self.arena.strings.slice(node.field_name); const tname = self.arena.strings.slice(node.type_name); try self.emit(.invalid_field_filter, .error_, node.span, "component '{s}' has no field '{s}'", .{ tname, fname }); return; - } + }; if (!try self.foldsAsConstant(node.filter_value, "field filter value must be a constant expression")) return; - const declared = self.namedTypeToResolved(found.?.type_node); const actual = self.synthExpr(node.filter_value, null); if (declared == .builtin and actual == .builtin and !try self.literalTypeFits(declared.builtin, node.filter_value, actual.builtin)) { try self.emit(.invalid_field_filter, .error_, node.span, "field filter type does not match field declared type", .{}); @@ -5869,28 +5897,20 @@ pub const TypeChecker = struct { /// `entity_event` / `global_event` payload filter: each `IDENT : /// expression` must name a field of `T` (`E1211 InvalidFieldFilter` else) /// and its value must fit that field's declared type (`E0200 TypeMismatch` - /// else). `event_decl_index` indexes `arena.event_decls`. - fn checkEventFieldRun(self: *TypeChecker, event_decl_index: u32, start: u32, len: u32, ctx_opt: ?*RuleCtx) !void { - const decl = self.arena.event_decls.items[event_decl_index]; + /// else). + fn checkEventFieldRun(self: *TypeChecker, ev: ForeignEvent, start: u32, len: u32, ctx_opt: ?*RuleCtx) !void { + const decl = ev.decl; var i: u32 = 0; while (i < len) : (i += 1) { const flit = self.arena.struct_lit_fields.items[start + i]; - var declared: ?ResolvedType = null; - var f_i: u32 = 0; - while (f_i < decl.fields_len) : (f_i += 1) { - const f = self.arena.fields.items[decl.fields_start + f_i]; - if (f.name == flit.name) { - declared = self.namedTypeToResolved(f.type_node); - break; - } - } + const declared = self.fieldTypeIn(ev.arena, decl.fields_start, decl.fields_len, flit.name); const actual = self.synthExpr(flit.value, ctx_opt); if (declared) |d| { if (d == .builtin and actual == .builtin and !try self.literalTypeFits(d.builtin, flit.value, actual.builtin)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(flit.value), "event field '{s}' value type does not match its declared type", .{self.arena.strings.slice(flit.name)}); } } else { - try self.emit(.invalid_field_filter, .error_, self.arena.exprSpan(flit.value), "event '{s}' has no field '{s}'", .{ self.arena.strings.slice(decl.name), self.arena.strings.slice(flit.name) }); + try self.emit(.invalid_field_filter, .error_, self.arena.exprSpan(flit.value), "event '{s}' has no field '{s}'", .{ ev.arena.strings.slice(decl.name), self.arena.strings.slice(flit.name) }); } } } @@ -5902,20 +5922,18 @@ pub const TypeChecker = struct { /// `arena.resolveEventEntityTarget`). The optional payload filter is validated for /// both targets. fn checkEventTarget(self: *TypeChecker, await_id: NodeId, aw: ast_mod.AwaitExpr, entity_scoped: bool, ctx_opt: ?*RuleCtx) !void { - const sym = self.symbols.get(aw.event_type); - if (sym == null or sym.?.kind != .event_) { + const ev = self.eventNamed(aw.event_type) orelse { try self.emit(.undefined_symbol, .error_, self.arena.exprSpan(await_id), "'{s}' is not a declared event", .{self.arena.strings.slice(aw.event_type)}); return; - } - const decl_index = self.arena.itemData(sym.?.item_id); + }; if (entity_scoped) { - switch (self.arena.resolveEventEntityTarget(self.arena.event_decls.items[decl_index])) { + switch (ev.arena.resolveEventEntityTarget(ev.decl)) { .field => {}, .none_entity => try self.emit(.event_not_entity_scoped, .error_, self.arena.exprSpan(await_id), "event '{s}' has no Entity field — 'await entity_event' requires a designated entity target", .{self.arena.strings.slice(aw.event_type)}), .ambiguous => try self.emit(.ambiguous_event_entity_target, .error_, self.arena.exprSpan(await_id), "event '{s}' has multiple Entity fields — annotate the target field with '@entity_target'", .{self.arena.strings.slice(aw.event_type)}), } } - try self.checkEventFieldRun(decl_index, aw.filter_start, aw.filter_len, ctx_opt); + try self.checkEventFieldRun(ev, aw.filter_start, aw.filter_len, ctx_opt); } fn checkStmt(self: *TypeChecker, ctx: *RuleCtx, stmt_id: NodeId) !void { @@ -6245,13 +6263,12 @@ pub const TypeChecker = struct { // enqueued at runtime (interp dynamic event store / codegen // `world.event_bus.emit`). const em = self.arena.emit_stmts.items[data]; - const sym = self.symbols.get(em.event_type); - if (sym == null or sym.?.kind != .event_) { - try self.emit(.undefined_symbol, .error_, self.arena.stmtSpan(stmt_id), "'{s}' is not a declared event", .{self.arena.strings.slice(em.event_type)}); - } else { + if (self.eventNamed(em.event_type)) |ev| { // Field-value validation is shared with the `entity_event` / // `global_event` payload filter. - try self.checkEventFieldRun(self.arena.itemData(sym.?.item_id), em.fields_start, em.fields_len, ctx); + try self.checkEventFieldRun(ev, em.fields_start, em.fields_len, ctx); + } else { + try self.emit(.undefined_symbol, .error_, self.arena.stmtSpan(stmt_id), "'{s}' is not a declared event", .{self.arena.strings.slice(em.event_type)}); } }, .tag_mutation_stmt => { @@ -6893,12 +6910,12 @@ pub const TypeChecker = struct { if (builtinResourceByName(tname) != null) { return .{ .resource = mg.type_name }; } - if (self.symbols.get(mg.type_name)) |sym| { - if (sym.kind == .component) { + if (self.kindNamed(mg.type_name)) |k| { + if (k == .component) { try self.emit(.resource_expected_component_given, .error_, self.arena.exprSpan(id), "'{s}' is a component — receiver-less get(...) accesses a resource; use entity.get({s})", .{ tname, tname }); return ResolvedType.unknown; } - if (sym.kind != .resource) { + if (k != .resource) { try self.emit(.undefined_symbol, .error_, self.arena.exprSpan(id), "'{s}' is not a resource", .{tname}); return ResolvedType.unknown; } @@ -8159,23 +8176,21 @@ pub const TypeChecker = struct { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(arg), "emit needs an explicit event type ('T {{ ... }}')", .{}); return; } - const sym = self.symbols.get(sl.type_name); - if (sym == null or sym.?.kind != .event_) { + const ev = self.eventNamed(sl.type_name) orelse { try self.emit(.undefined_symbol, .error_, self.arena.exprSpan(arg), "'{s}' is not a declared event", .{self.arena.strings.slice(sl.type_name)}); return; - } - try self.checkEventFieldRun(self.arena.itemData(sym.?.item_id), sl.fields_start, sl.fields_len, ctx_opt); + }; + try self.checkEventFieldRun(ev, sl.fields_start, sl.fields_len, ctx_opt); } /// validate a type name used in a structural mutation /// (`entity.remove(T)` and the component-literal types of `spawn` / `add`) - /// resolves to a declared `component`. Mirrors the `when has` component - /// lookup (`self.symbols.get` + `SymbolKind.component`). + /// resolves to a declared `component`, as the `when has` lookup does. fn checkStructuralComponentName(self: *TypeChecker, name: StringId, span: SourceSpan) TypeError!void { const tname = self.arena.strings.slice(name); - if (self.symbols.get(name)) |sym| { - if (sym.kind != .component) { - try self.emit(.type_mismatch, .error_, span, "structural mutation requires a component, '{s}' is a {s}", .{ tname, @tagName(sym.kind) }); + if (self.kindNamed(name)) |k| { + if (k != .component) { + try self.emit(.type_mismatch, .error_, span, "structural mutation requires a component, '{s}' is a {s}", .{ tname, @tagName(k) }); } } else { try self.emit(.type_mismatch, .error_, span, "unknown component '{s}' in structural mutation", .{tname}); @@ -8882,13 +8897,8 @@ pub const TypeChecker = struct { fn lookupFieldType(self: *TypeChecker, receiver_type: ResolvedType, field_name: StringId, span: SourceSpan) !ResolvedType { switch (receiver_type) { .component => |name_id| { - const sym = self.symbols.get(name_id) orelse return ResolvedType.unknown; - const decl = self.arena.component_decls.items[self.arena.itemData(sym.item_id)]; - var i: u32 = 0; - while (i < decl.fields_len) : (i += 1) { - const f = self.arena.fields.items[decl.fields_start + i]; - if (f.name == field_name) return self.namedTypeToResolved(f.type_node); - } + const c = self.componentNamed(name_id) orelse return ResolvedType.unknown; + if (self.fieldTypeIn(c.arena, c.decl.fields_start, c.decl.fields_len, field_name)) |t| return t; try self.emit(.invalid_field_filter, .error_, span, "field '{s}' does not exist on component '{s}'", .{ self.arena.strings.slice(field_name), self.arena.strings.slice(name_id) }); return ResolvedType.unknown; }, @@ -8904,13 +8914,8 @@ pub const TypeChecker = struct { try self.emit(.invalid_field_filter, .error_, span, "field '{s}' does not exist on builtin resource '{s}'", .{ fname, br.name }); return ResolvedType.unknown; } - const sym = self.symbols.get(name_id) orelse return ResolvedType.unknown; - const decl = self.arena.resource_decls.items[self.arena.itemData(sym.item_id)]; - var i: u32 = 0; - while (i < decl.fields_len) : (i += 1) { - const f = self.arena.fields.items[decl.fields_start + i]; - if (f.name == field_name) return self.namedTypeToResolved(f.type_node); - } + const r = self.resourceNamed(name_id) orelse return ResolvedType.unknown; + if (self.fieldTypeIn(r.arena, r.decl.fields_start, r.decl.fields_len, field_name)) |t| return t; try self.emit(.invalid_field_filter, .error_, span, "field '{s}' does not exist on resource '{s}'", .{ self.arena.strings.slice(field_name), self.arena.strings.slice(name_id) }); return ResolvedType.unknown; }, @@ -8931,25 +8936,8 @@ pub const TypeChecker = struct { // Field of the implicit `event` binding inside an `@on_event(T)` // observer — e.g. `event.amount`. An event is a POD // struct of fields, resolved against its declaration. - if (self.symbols.get(name_id)) |sym| { - const decl = self.arena.event_decls.items[self.arena.itemData(sym.item_id)]; - var i: u32 = 0; - while (i < decl.fields_len) : (i += 1) { - const f = self.arena.fields.items[decl.fields_start + i]; - if (f.name == field_name) return self.namedTypeToResolved(f.type_node); - } - } else if (self.declaredEvent(name_id)) |fe| { - // Cross-arena field lookup, BY BYTES on both the field name - // and the type — the discipline that holds for - // `checkComponentInstance`, and bounded the same way: - // builtins resolve, a named foreign type yields `unknown`. - const want = self.arena.strings.slice(field_name); - var i: u32 = 0; - while (i < fe.decl.fields_len) : (i += 1) { - const f = fe.arena.fields.items[fe.decl.fields_start + i]; - if (!std.mem.eql(u8, fe.arena.strings.slice(f.name), want)) continue; - return self.foreignFieldType(fe.arena, f.type_node); - } + if (self.eventNamed(name_id) orelse self.declaredEvent(name_id)) |ev| { + if (self.fieldTypeIn(ev.arena, ev.decl.fields_start, ev.decl.fields_len, field_name)) |t| return t; } try self.emit(.invalid_field_filter, .error_, span, "field '{s}' does not exist on event '{s}'", .{ self.arena.strings.slice(field_name), self.arena.strings.slice(name_id) }); return ResolvedType.unknown; diff --git a/tests/etch/import_resolve_test.zig b/tests/etch/import_resolve_test.zig index b6639b56..57ff9ba9 100644 --- a/tests/etch/import_resolve_test.zig +++ b/tests/etch/import_resolve_test.zig @@ -217,3 +217,86 @@ test "a hook reaches imported requires and own components" { try etch.validateProject(gpa, &files, &diags); try std.testing.expectEqual(@as(usize, 0), diags.items.len); } + +const positions_lib = + \\component C { v: int = 0 } + \\component D { v: int = 0 } + \\resource R { v: int = 0 } + \\event P { v: int = 0 } + \\event Hit { who: Entity } + \\ +; + +const Case = struct { name: []const u8, body: []const u8 }; +const position_cases = [_]Case{ + .{ .name = "when has", .body = "rule r(entity: Entity) when entity has C { }" }, + .{ .name = "when has changed", .body = "rule r(entity: Entity) when entity has C changed { }" }, + .{ .name = "when has field filter", .body = "rule r(entity: Entity) when entity has C { v == 1 } { }" }, + .{ .name = "when has expr filter", .body = "rule r(entity: Entity) when entity has C { v > 0 } { }" }, + .{ .name = "when resource", .body = "rule r() when resource R { }" }, + .{ .name = "when resource changed", .body = "rule r() when resource R changed { }" }, + .{ .name = "when resource filter", .body = "rule r() when resource R { v > 0 } { }" }, + .{ .name = "body get", .body = "rule r(entity: Entity) when entity has C { let x = entity.get(C).v }" }, + .{ .name = "body get_mut", .body = "rule r(entity: Entity) when entity has C { entity.get_mut(C).v += 1 }" }, + .{ .name = "body resource get", .body = "rule r() when resource R { let x = get(R).v }" }, + .{ .name = "emit", .body = "rule r() { emit P { v: 1 } }" }, + .{ .name = "emit bad field", .body = "rule r() { emit P { w: 1 } }" }, + .{ .name = "await global_event", .body = "async rule r(e: Entity) when e has C { await global_event(P) }" }, + .{ .name = "await global_event filter", .body = "async rule r(e: Entity) when e has C { await global_event(P { v: 1 }) }" }, + .{ .name = "await entity_event", .body = "async rule r(e: Entity) when e has C { await entity_event(e, Hit) }" }, + .{ .name = "on_event", .body = "@on_event(P)\nrule r() { let x = event.v }" }, + .{ .name = "on_added", .body = "@on_added(C)\nrule r(entity: Entity, value: C) {}" }, + .{ .name = "body add", .body = "rule r(entity: Entity) when entity has C { entity.add(D { v: 1 }) }" }, + .{ .name = "body remove", .body = "rule r(entity: Entity) when entity has C { entity.remove(D) }" }, + .{ .name = "body spawn", .body = "rule r() { spawn(C { v: 1 }) }" }, + .{ .name = "sequence emit", .body = "sequence S {\n track T: EventTrack { 0.0s: emit P { v: 1 } }\n}" }, + .{ .name = "quest emit", .body = "fn check() -> bool { true }\nquest Q {\n stage a {\n objective main: check()\n on_complete: emit P { v: 1 }\n }\n}" }, + .{ .name = "dialogue emit", .body = "dialogue Talk {\n speaker \"npc\" { line: \"x\" }\n emit P { v: 1 }\n -> end\n}" }, + .{ .name = "dialogue has", .body = "dialogue Talk {\n speaker \"npc\" { line: \"x\" when player has C { v < 5 } }\n}" }, + .{ .name = "behavior has", .body = "behavior B {\n selector {\n sequence when self has C { v < 5 } {\n action: emit P { v: 1 }\n }\n }\n}" }, + .{ .name = "behavior get", .body = "behavior B {\n selector {\n condition: self.get(C).v > 0\n }\n}" }, + .{ .name = "malformed when has field filter", .body = "rule r(entity: Entity) when entity has C { w == 1 } { }" }, + .{ .name = "malformed when has expr filter", .body = "rule r(entity: Entity) when entity has C { w > 0 } { }" }, + .{ .name = "malformed when resource filter", .body = "rule r() when resource R { w > 0 } { }" }, + .{ .name = "malformed body get", .body = "rule r(entity: Entity) when entity has C { let x = entity.get(C).w }" }, + .{ .name = "malformed body get type", .body = "rule r(entity: Entity) when entity has C { entity.get_mut(C).v = true }" }, + .{ .name = "malformed body resource get", .body = "rule r() when resource R { let x = get(R).w }" }, + .{ .name = "malformed emit type", .body = "rule r() { emit P { v: true } }" }, + .{ .name = "malformed await filter", .body = "async rule r(e: Entity) when e has C { await global_event(P { w: 1 }) }" }, + .{ .name = "malformed on_event field", .body = "@on_event(P)\nrule r() { let x = event.w }" }, + .{ .name = "malformed body add field", .body = "rule r(entity: Entity) when entity has C { entity.add(D { w: 1 }) }" }, + .{ .name = "malformed body spawn field", .body = "rule r() { spawn(C { w: 1 }) }" }, + .{ .name = "malformed when has resource", .body = "rule r(entity: Entity) when entity has R { }" }, + .{ .name = "malformed when resource comp", .body = "rule r() when resource C { }" }, + .{ .name = "malformed emit component", .body = "rule r() { emit C { v: 1 } }" }, +}; + +fn codesOf(files: []const etch.ProjectFile, out: *std.ArrayListUnmanaged(DiagnosticCode)) !void { + const gpa = std.testing.allocator; + var diags: std.ArrayListUnmanaged(etch.Diagnostic) = .empty; + defer deinitDiags(gpa, &diags); + try etch.validateProject(gpa, files, &diags); + for (diags.items) |d| try out.append(gpa, d.code); +} + +test "a name an import binds is judged as its declaration is, position by position" { + const gpa = std.testing.allocator; + var differing: usize = 0; + for (position_cases) |c| { + const declared_src = try std.mem.concat(gpa, u8, &.{ positions_lib, c.body }); + defer gpa.free(declared_src); + const imported_src = try std.mem.concat(gpa, u8, &.{ "import lib { C, D, R, P, Hit }\n", c.body }); + defer gpa.free(imported_src); + var declared: std.ArrayListUnmanaged(DiagnosticCode) = .empty; + defer declared.deinit(gpa); + var imported: std.ArrayListUnmanaged(DiagnosticCode) = .empty; + defer imported.deinit(gpa); + try codesOf(&.{.{ .name = "main.etch", .source = declared_src }}, &declared); + try codesOf(&.{ .{ .name = "lib.etch", .source = positions_lib }, .{ .name = "main.etch", .source = imported_src } }, &imported); + if (!std.mem.eql(DiagnosticCode, declared.items, imported.items)) { + differing += 1; + std.debug.print("{s}: declared {any}, imported {any}\n", .{ c.name, declared.items, imported.items }); + } + } + try std.testing.expectEqual(@as(usize, 0), differing); +} diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 9e503edc..2740828f 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2757, - else => 2759, + .windows => 2758, + else => 2760, }; } From 8b24eedacd1267eb1bd1d9f7b1ff3346e7ed68bb Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 28 Sep 2026 00:38:42 +0200 Subject: [PATCH 120/141] fix(etch): keep an event emitted at a tick boundary for the next tick The event store was cleared at each tick head, so an event emitted after a tick's last rule, by a hook or a structural observer at the flush, or outside any tick, as at a load, was gone before any observer ran. The head now drops only what the previous tick's rules could observe, and keeps what was emitted after them. The interpreter's observer-log test expected the head to clear the five logs its structural observers emitted outside a tick; it now reads them in the tick, before the one its rules emit. Co-Authored-By: Claude Opus 5.5 --- src/etch/interp.zig | 47 ++++++++++++++++++++++------ tests/scene/extensions_test.zig | 55 +++++++++++++++++++++++++++++++++ tools/weld_lint/dead_tests.zig | 4 +-- 3 files changed, 95 insertions(+), 11 deletions(-) diff --git a/src/etch/interp.zig b/src/etch/interp.zig index f3ccf463..d56a45f8 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -584,8 +584,10 @@ const EventVal = struct { /// cannot be driven by the dynamic tree-walker, so `emit` accumulates events /// here, each tagged by its type name; `@on_event` observers drain them. The /// observer/drain side is the observer path (resolver-types §12, -/// deferred). Cleared at the start of each tick (`stepOnce`), matching the -/// `Lifetime.tick` drain cadence (`src/core/events/lifetime.zig`). +/// deferred). An event lives for one tick of rules, matching the +/// `Lifetime.tick` drain cadence (`src/core/events/lifetime.zig`): one emitted +/// at a boundary, after a tick's last rule or outside any tick, lives for the +/// next tick's. const EventStore = struct { list: std.ArrayListUnmanaged(EventVal) = .empty, /// Store-owned deep copies of NON-AST string event field bytes: @@ -599,6 +601,10 @@ const EventStore = struct { /// queue at the per-tick `clear`. (`.string_id` — the immortal AST table — is /// stable and never copied.) owned_strings: std.ArrayListUnmanaged([]u8) = .empty, + /// The prefixes of `list` and `owned_strings` emitted before the last + /// `endRules`, which the next `startTick` drops. + ruled_events: usize = 0, + ruled_strings: usize = 0, fn deinit(self: *EventStore, gpa: std.mem.Allocator) void { for (self.list.items) |*e| e.fields.deinit(gpa); @@ -612,6 +618,29 @@ const EventStore = struct { self.list.clearRetainingCapacity(); for (self.owned_strings.items) |s| gpa.free(s); self.owned_strings.clearRetainingCapacity(); + self.ruled_events = 0; + self.ruled_strings = 0; + } + + /// Mark the end of a tick's rules. + fn endRules(self: *EventStore) void { + self.ruled_events = self.list.items.len; + self.ruled_strings = self.owned_strings.items.len; + } + + /// Drop what the previous tick's rules could observe, keeping what was + /// emitted after them. + fn startTick(self: *EventStore, gpa: std.mem.Allocator) void { + for (self.list.items[0..self.ruled_events]) |*e| e.fields.deinit(gpa); + const kept = self.list.items.len - self.ruled_events; + std.mem.copyForwards(EventVal, self.list.items[0..kept], self.list.items[self.ruled_events..]); + self.list.shrinkRetainingCapacity(kept); + for (self.owned_strings.items[0..self.ruled_strings]) |s| gpa.free(s); + const kept_strings = self.owned_strings.items.len - self.ruled_strings; + std.mem.copyForwards([]u8, self.owned_strings.items[0..kept_strings], self.owned_strings.items[self.ruled_strings..]); + self.owned_strings.shrinkRetainingCapacity(kept_strings); + self.ruled_events = 0; + self.ruled_strings = 0; } /// Enqueue an event of `type_name`, taking ownership of `fields`. @@ -2217,15 +2246,14 @@ pub const Interpreter = struct { // counter backing `GameTime.frame` — it no longer drives `wait`. self.async_tick += 1; self.advanceTime(world); - // Events have a per-tick lifetime (`Lifetime.tick`): clear the previous - // tick's queue before running this tick's rules. The test-world - // `tick(n)` suppresses exactly this first clear so events + // Events have a per-tick lifetime (`Lifetime.tick`). The test-world + // `tick(n)` suppresses exactly this first drop so events // emitted before the tick (`world.emit`, or a `spawn_with` observer) // survive into it — §32's `emit; tick(1)`. if (self.suppress_event_clear) { self.suppress_event_clear = false; } else { - self.events.clear(self.gpa); + self.events.startTick(self.gpa); } // Drain the external event sources — AFTER the clear and // BEFORE rule dispatch, which is the whole deliverable of the bridge. @@ -2262,6 +2290,7 @@ pub const Interpreter = struct { // the pre-update value, so the filter saw the correct baseline. if (self.has_changed) rd.last_run_tick = world.current_tick; } + self.events.endRules(); // Apply deferred tag mutations at the tick boundary — after every rule // has run, never mid-archetype-walk (`etch-grammar.md` §4.4). try self.flushPendingTags(world); @@ -11688,13 +11717,13 @@ test "observable behaviour: all five observer kinds + emit/@on_event, determinis try std.testing.expectEqualSlices(i64, &[_]i64{ 1, 101, 312, 402, 5 }, log.items); // ── Then: a per-tick `@on_event` drain coexists in the same program ── - // `stepOnce` clears the event store first; produce_ping emits Ping, on_ping - // drains it same-tick → Log 6. + // The five logs were emitted outside a tick, so this tick keeps them; + // produce_ping emits Ping, on_ping drains it same-tick → Log 6. var report: RuntimeReport = .{}; try interp.stepOnce(&world, &report); log.clearRetainingCapacity(); try collectLog(&interp, log_id, code_id, &log, gpa); - try std.testing.expectEqualSlices(i64, &[_]i64{6}, log.items); + try std.testing.expectEqualSlices(i64, &[_]i64{ 1, 101, 312, 402, 5, 6 }, log.items); } test "runProgram add_tag is deferred to the tick boundary; has_tag query gates a counter" { diff --git a/tests/scene/extensions_test.zig b/tests/scene/extensions_test.zig index 8a797cbe..6e39d3de 100644 --- a/tests/scene/extensions_test.zig +++ b/tests/scene/extensions_test.zig @@ -1708,3 +1708,58 @@ test "a load credits a requirement an earlier extension provides" { const a = loaded.uuid_to_entity.get(uuidBytes(0xa1)).?; try std.testing.expect(h.weapon(a) != null); } + +const observer_program = + \\component Health { current: i32 = 100, max: i32 = 100 } + \\component Weapon { damage: i32 = 0 } + \\event Attached { } + \\resource Seen { n: i32 = 0 } + \\rule go(entity: Entity) when entity has Health and not entity has Weapon { + \\ entity.activate_extension("Forged") + \\} + \\@on_event(Attached) + \\rule seen() when resource Seen { get_mut(Seen).n += 1 } +; + +fn seenCount(world: *World) i32 { + const id = world.registry.idOf("Seen").?; + const f = world.registry.findField(id, "n").?; + return std.mem.readInt(i32, world.resources.getResource(id).?[f.offset..][0..4], .little); +} + +test "an event a hook emits at the tick boundary reaches the next tick's observers" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser.parse(gpa, observer_program); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + try interp.bindToWorld(&world); + const bytes = try forgedExtension(gpa, "Weapon", 4, &.{}, "emit Attached { }", null); + defer gpa.free(bytes); + var res = OneResolver{ .name = "Forged", .bytes = bytes }; + interp.setExtensionResolver(res.ext()); + _ = try spawnHealth(&world, gpa, 100, 100); + _ = try interp.runFor(&world, 3); + try std.testing.expectEqual(@as(i32, 1), seenCount(&world)); +} + +test "an event a hook emits outside a tick reaches the next tick's observers" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser.parse(gpa, observer_program); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + try interp.bindToWorld(&world); + const bytes = try forgedExtension(gpa, "Weapon", 4, &.{}, "emit Attached { }", null); + defer gpa.free(bytes); + const e = try spawnHealth(&world, gpa, 100, 100); + try scene.loader.activateExtension(&world, gpa, e, "Forged", bytes); + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i32, 1), seenCount(&world)); +} diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 2740828f..c1f15ed2 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2758, - else => 2760, + .windows => 2760, + else => 2762, }; } From 90bd63a452f6b8b519ea638eb4331de080bb47b6 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 28 Sep 2026 00:39:00 +0200 Subject: [PATCH 121/141] fix(scene): refuse an extension hook that no interpreter would run An activation or deactivation on a world with no interpreter bound found no hook seam and completed without running the hook, so a scene loaded before bindToWorld ran none of its on_attach. The loader now refuses, before any mutation, a hook whose seam is absent, with error.ExtensionHookUnbound. Co-Authored-By: Claude Opus 5.5 --- src/core/scene/loader.zig | 4 ++++ tests/scene/extensions_test.zig | 33 +++++++++++++++++++++++++++++++++ tools/weld_lint/dead_tests.zig | 4 ++-- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/core/scene/loader.zig b/src/core/scene/loader.zig index 5417797e..abac551d 100644 --- a/src/core/scene/loader.zig +++ b/src/core/scene/loader.zig @@ -672,9 +672,13 @@ fn requiresMet(world: *World, entity: EntityId, ext: Accessor, provided: []const /// Fire the world's hook check on the hooks of `ext` — its `on_attach` only /// when `with_attach` — in the scope of its own components and its requires. +/// The hook the activation or deactivation dispatches is refused when no seam +/// would run it. fn checkHooks(world: *World, gpa: std.mem.Allocator, entity: EntityId, name: []const u8, ext: Accessor, with_attach: bool) !void { if (ext.hookCount() == 0) return; const hook = ext.hook(0); + if (with_attach and hook.on_attach != null and world.attach_hook == null) return error.ExtensionHookUnbound; + if (!with_attach and hook.on_detach != null and world.detach_hook == null) return error.ExtensionHookUnbound; const scope = try gpa.alloc([]const u8, ext.schemaCount() + ext.requiresCount()); defer gpa.free(scope); var n: usize = 0; diff --git a/tests/scene/extensions_test.zig b/tests/scene/extensions_test.zig index 6e39d3de..44a9ca5a 100644 --- a/tests/scene/extensions_test.zig +++ b/tests/scene/extensions_test.zig @@ -1763,3 +1763,36 @@ test "an event a hook emits outside a tick reaches the next tick's observers" { _ = try interp.runFor(&world, 1); try std.testing.expectEqual(@as(i32, 1), seenCount(&world)); } + +test "an activation whose on_attach no interpreter would run is refused" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser.parse(gpa, hook_program); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const bytes = try forgedExtension(gpa, "Weapon", 4, &.{}, "emit Attached { }", null); + defer gpa.free(bytes); + const e = try spawnHealth(&world, gpa, 100, 100); + try std.testing.expectError(error.ExtensionHookUnbound, scene.loader.activateExtension(&world, gpa, e, "Forged", bytes)); + try std.testing.expect(world.componentBytes(e, world.componentId("Weapon").?) == null); + try std.testing.expect(!world.hasEntityExtension(e, "Forged")); +} + +test "a deactivation whose on_detach no interpreter would run is refused" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser.parse(gpa, hook_program); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const bytes = try forgedExtension(gpa, "Weapon", 4, &.{}, null, "emit Attached { }"); + defer gpa.free(bytes); + const e = try spawnHealth(&world, gpa, 100, 100); + try scene.loader.activateExtension(&world, gpa, e, "Forged", bytes); + try std.testing.expectError(error.ExtensionHookUnbound, scene.loader.deactivateExtension(&world, gpa, e, "Forged", bytes)); + try std.testing.expect(world.componentBytes(e, world.componentId("Weapon").?) != null); + try std.testing.expect(world.hasEntityExtension(e, "Forged")); +} diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index c1f15ed2..8ec444a2 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2760, - else => 2762, + .windows => 2762, + else => 2764, }; } From 31b63bba7c1f3d2d7f8259d48c49f3f878609b44 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 28 Sep 2026 00:41:07 +0200 Subject: [PATCH 122/141] docs(brief): record arbitrations 3 and 4 and five points of decision 3 Co-Authored-By: Claude Opus 5.5 --- briefs/m1.d-phase-1-debt.md | 73 +++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index 288c6e17..54693170 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -7176,6 +7176,79 @@ the eleven new witnesses taking the floor 2745 → 2756 on macOS, read from the `E0858` beside their cook assertions — whether the cook must refuse what `E0858` refuses is raised to Guy. +### S5/G9 novemdecies — arbitrations 3 and 4, and five of the seven points of decision 3 + +The CI of `8e7e52eb` was green on its 18 jobs, and the Windows floor 2754 was read on the +four cells. Every witness was red first at its predicted value. The suite then ran 2747 / +2764 (17 skipped) on macOS, 329/329 steps. The floor moves 2756 → 2764 through the +session's commits, read from the suite; Windows is 2762, derived until CI reads it. + +- **Arbitration 3, the cook refuses what E0858 refuses** (`cd4b033e`). The project cook + had run E0858 into scratch diagnostics. Two witnesses went red: a `.prefab.etch` + declaring a component, and a scene in a plain `.etch`. The cook now refuses both with + `error.TypedExtensionMismatch`, ahead of the import scope. **The single-source entries + were judged too**, because their synthetic file was named `cook.scene.etch`. With the + refusal on and that name kept, 70 tests failed across five steps: every standalone + cook of a source that declares its components. The name now carries no `.etch` suffix, + so such a source is judged as a source checked without a path is, which is not at + all. + - The two named tests: the shadowing test's property existed only through a + declaration in a typed file, so it became the first witness. The requisite test's + valid form, a requisite named in a module that imports it, is already a test. + - The run showed four more members of the class. Three import-refusal tests declared + a component in the prefab file; that component is now imported. The fourth, an + alias against a local declaration, is removed: its valid form, two imports of one + name, is already a test. +- **Arbitration 4, the census** (`b23882e1`). I measured the class with the instrument: + in a worktree, E0858 was made to fail the check loudly, then the whole suite ran. Nine + tests fired: the six that expect E0858, and exactly the three named. **The instrument's + first version was defective.** A textual substitution had moved the emission outside + its `if`, so E0858 fired on valid files. I caught it by running the probe on a file + known green before trusting its census, then rebuilt it on a diagnostic count. The + three fixtures are now typed files. Each oracle requires its code to be the only + diagnostic; on the old fixtures they went red at 3, 4 and 3 diagnostics, as predicted. + The E0858 tests now require the same of E0858. +- **Arbitration 2, not applied.** No code says a construct is not implemented. Raised + (`E0840`–`E0849` are all free). +- **Point 3, E1799** (`b94a3dc4`). Per the ruling, `PrefabHookNotAllowed` takes the only + free code of the prefab range. It is raised on the prefab name. Two witnesses: a hook + on an `of` prefab, and `requires` on a standalone prefab. The cook keeps its own + refusal. **Its spec row is owed.** +- **Point 4** (`7e2f4ec8`). The sweep found a second member: an `extends` prefab with no + entity also cooked, and the loader refused it as `EmptyExtension`. Both cases went red + first. The cook now refuses both, with the loader's names. +- **Point 5** (`b7bb550d`). Measured before fixing, with a harness that ran each position + twice, names imported then declared: **34 of 40 positions differed, not two.** They + were: + - the seven `when` forms; + - receiver-less `get`; + - `emit`, its three construct forms (sequence keyframe, quest handler, dialogue) and + the test-world `emit`; + - the two `await` targets; + - `@on_event` and its `event` fields; + - the structural observers; + - `entity.remove`; + - every field read of an imported component or resource. These were typed `unknown`, + so a wrong field name passed **silently**. + + The harness is committed as one table test comparing diagnostic sequences. On the + unfixed checker it went red at 34, printing each position. A field of an imported + declaration whose type is a named type still types `unknown`: that is the bound on + foreign field types, not new. +- **Point 6** (`8b24eeda`). An event emitted at a boundary — after a tick's last rule, or + outside any tick — now lives for the next tick's rules. Two witnesses: a hook at the + flush, and a hook outside a tick. **One test changed**: the interpreter's observer-log + test asserted that the head cleared the five logs its structural observers emit outside + a tick, which is the ruled defect. The old expectation was `{6}`; the new one is + `{1, 101, 312, 402, 5, 6}`. +- **Point 7** (`90bd63a4`). I chose refusal over deferral. A hook that an activation or + deactivation would dispatch to no seam is refused before any mutation, with + `error.ExtensionHookUnbound`. Deferring to `bindToWorld` would break the load's order, + which runs `on_attach` before `on_spawned`. Two witnesses, attach and detach, each + leaving the entity unchanged. +- **Points 1 and 2, raised and untouched.** The timer and the bare `throw` in a hook each + need a code, and the prefab range is full after E1799. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree From ddebe3d72ee183318fa7e283f4e672d0243d295c Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 28 Sep 2026 10:16:03 +0200 Subject: [PATCH 123/141] fix(etch): refuse a throw outside a try and a timer in a hook as E1798 E1798 becomes IllegalStatementInExtensionHook and covers, beside return, a throw with no enclosing try and a timer in an on_attach or on_detach body: a hook runs to completion with no caller, no task and no error channel. etch check accepted both; the cook refused them only as a hook it could not render, and a forged hook passed the load check, a throw then failing after the activation had committed and a timer being scheduled on nothing. The check, the load check and the cook now refuse both. A throw inside a try stays legal, and a throw in its catch body does not. Co-Authored-By: Claude Opus 5.5 --- src/etch/diagnostics.zig | 10 ++--- src/etch/types.zig | 79 +++++++++++++++++++++++++++++++-- tests/scene/extensions_test.zig | 51 +++++++++++++++++++++ tools/weld_lint/dead_tests.zig | 4 +- 4 files changed, 133 insertions(+), 11 deletions(-) diff --git a/src/etch/diagnostics.zig b/src/etch/diagnostics.zig index 62c52189..1ed5735c 100644 --- a/src/etch/diagnostics.zig +++ b/src/etch/diagnostics.zig @@ -377,7 +377,7 @@ pub const DiagnosticCode = enum { prefab_component_redefined, // E1796 PrefabComponentRedefined (RESERVED: variant/base component-shape merge is a runtime concern) prefab_remove_base_component, // W1790 PrefabRemoveBaseComponent (RESERVED: no `remove` syntax in the §24.1 grammar) extension_additive_conflict, // E1797 ExtensionAdditiveConflict (fatal cook error, strictly-additive `extends` → reject: (a) two extensions declare the same component, (b) an extension declares a component already carried by the base/an earlier extension, (c) the same extension is listed twice; guarantees `cooked ⇒ loadable`; runtime backstops `error.ExtensionComponentConflict` (a/b) / `error.ExtensionAlreadyActive` (c)) - illegal_return_in_extension_hook, // E1798 IllegalReturnInExtensionHook (`return` in an `on_attach` / `on_detach` body: a hook has no caller) + illegal_statement_in_extension_hook, // E1798 IllegalStatementInExtensionHook (`return`, `throw` outside a `try`, or a timer in an `on_attach` / `on_detach` body: a hook runs to completion with no caller, no task and no error channel) prefab_hook_not_allowed, // E1799 PrefabHookNotAllowed (`requires`, `on_attach` or `on_detach` on a prefab without `extends`) // ── async / effects (9xx, etch-resolver-types.md §9.2) ── @@ -606,7 +606,7 @@ pub const DiagnosticCode = enum { .prefab_component_redefined => "E1796", .prefab_remove_base_component => "W1790", .extension_additive_conflict => "E1797", - .illegal_return_in_extension_hook => "E1798", + .illegal_statement_in_extension_hook => "E1798", .prefab_hook_not_allowed => "E1799", .async_call_in_non_async_context => "E0901", .unhandled_throws_call => "E0902", @@ -818,7 +818,7 @@ pub const DiagnosticCode = enum { .prefab_component_redefined => "PrefabComponentRedefined", .prefab_remove_base_component => "PrefabRemoveBaseComponent", .extension_additive_conflict => "ExtensionAdditiveConflict", - .illegal_return_in_extension_hook => "IllegalReturnInExtensionHook", + .illegal_statement_in_extension_hook => "IllegalStatementInExtensionHook", .prefab_hook_not_allowed => "PrefabHookNotAllowed", .async_call_in_non_async_context => "AsyncCallInNonAsyncContext", .unhandled_throws_call => "UnhandledThrowsCall", @@ -939,8 +939,8 @@ test "DiagnosticCode code and name are stable cross-version" { try std.testing.expectEqualStrings("AmbiguousEventEntityTarget", DiagnosticCode.ambiguous_event_entity_target.name()); try std.testing.expectEqualStrings("E1797", DiagnosticCode.extension_additive_conflict.code()); try std.testing.expectEqualStrings("ExtensionAdditiveConflict", DiagnosticCode.extension_additive_conflict.name()); - try std.testing.expectEqualStrings("E1798", DiagnosticCode.illegal_return_in_extension_hook.code()); - try std.testing.expectEqualStrings("IllegalReturnInExtensionHook", DiagnosticCode.illegal_return_in_extension_hook.name()); + try std.testing.expectEqualStrings("E1798", DiagnosticCode.illegal_statement_in_extension_hook.code()); + try std.testing.expectEqualStrings("IllegalStatementInExtensionHook", DiagnosticCode.illegal_statement_in_extension_hook.name()); try std.testing.expectEqualStrings("E1799", DiagnosticCode.prefab_hook_not_allowed.code()); try std.testing.expectEqualStrings("PrefabHookNotAllowed", DiagnosticCode.prefab_hook_not_allowed.name()); } diff --git a/src/etch/types.zig b/src/etch/types.zig index 132f2926..6209ead2 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -566,8 +566,9 @@ pub const TypeChecker = struct { /// around the body in `checkTest`. in_test_body: bool = false, /// Whether the statements being checked are an `on_attach` / `on_detach` - /// body: a `return` there is E1798, and the E1210 / E1213 messages name the - /// hook's scope. Set/restored in `checkPrefabHook`. + /// body: a `return`, a `throw` outside a `try` or a timer there is E1798, + /// and the E1210 / E1213 messages name the hook's scope. Set/restored in + /// `checkHookBody`. in_hook_body: bool = false, /// The kind of the INNERMOST `race`/`sync` branch or `branch`/`spawn` body /// enclosing the statements being checked, `null` outside any. @@ -6184,6 +6185,9 @@ pub const TypeChecker = struct { // statically an `Error`, so a non-Error operand is rejected // here (no runtime coercion). The interpreter's carrier stays // an arbitrary `Value` — unreachable for checked programs. + if (self.in_hook_body and !self.current_can_throw) { + try self.emit(.illegal_statement_in_extension_hook, .error_, self.arena.stmtSpan(stmt_id), "'throw' outside a 'try' is illegal in an extension hook (on_attach / on_detach): a hook has no error channel", .{}); + } const t = self.arena.throw_stmts.items[data]; const vt = self.synthExpr(t.value, ctx); const is_error = vt == .struct_t and vt.struct_t == self.arena.error_type_name; @@ -6229,7 +6233,7 @@ pub const TypeChecker = struct { // site (`etch-resolver-types.md` §9.2). Asymmetric by design // — do NOT generalize. if (self.in_hook_body) { - try self.emit(.illegal_return_in_extension_hook, .error_, self.arena.stmtSpan(stmt_id), "'return' is illegal in an extension hook (on_attach / on_detach): a hook has no caller to return to", .{}); + try self.emit(.illegal_statement_in_extension_hook, .error_, self.arena.stmtSpan(stmt_id), "'return' is illegal in an extension hook (on_attach / on_detach): a hook has no caller to return to", .{}); } if (self.conc_branch) |ck| { if (ck != .race) { @@ -6363,6 +6367,9 @@ pub const TypeChecker = struct { // carries no `{async}` effect (absent from the §9.4 // builtin-effect table), so there is no E0901 gate on the // statement itself. + if (self.in_hook_body) { + try self.emit(.illegal_statement_in_extension_hook, .error_, self.arena.stmtSpan(stmt_id), "a timer is illegal in an extension hook (on_attach / on_detach): a hook runs to completion and has no task to schedule it on", .{}); + } const ts = self.arena.timer_stmts.items[data]; // The duration argument is a full expression typed `Duration` // (§9.10), evaluated once at scheduling time. @@ -9378,7 +9385,71 @@ test "a return in a hook is E1798" { \\} ); defer r.deinit(gpa); - try expectAnyCode(r.diagnostics.items, .illegal_return_in_extension_hook); + try expectAnyCode(r.diagnostics.items, .illegal_statement_in_extension_hook); +} + +test "a throw outside a try in a hook is E1798" { + const gpa = std.testing.allocator; + var r = try checkHookSource(gpa, hook_base ++ + \\prefab "Mod" extends "Base" requires Health { + \\ entity "m" { Weapon {} } + \\ on_attach { throw Error { message: "boom", code: .io_fail } } + \\} + ); + defer r.deinit(gpa); + try std.testing.expectEqual(@as(usize, 1), countMessage(r.diagnostics.items, .illegal_statement_in_extension_hook, "throw")); +} + +test "a throw in a catch body of a hook is E1798" { + const gpa = std.testing.allocator; + var r = try checkHookSource(gpa, hook_base ++ + \\prefab "Mod" extends "Base" requires Health { + \\ entity "m" { Weapon {} } + \\ on_attach { + \\ try { entity.get_mut(Health).max += 1.0 } catch err { throw err } + \\ } + \\} + ); + defer r.deinit(gpa); + try std.testing.expectEqual(@as(usize, 1), countMessage(r.diagnostics.items, .illegal_statement_in_extension_hook, "throw")); +} + +test "a throw inside a try in a hook is not E1798" { + const gpa = std.testing.allocator; + var r = try checkHookSource(gpa, hook_base ++ + \\prefab "Mod" extends "Base" requires Health { + \\ entity "m" { Weapon {} } + \\ on_attach { + \\ try { throw Error { message: "boom", code: .io_fail } } catch err { entity.get_mut(Health).max += 1.0 } + \\ } + \\} + ); + defer r.deinit(gpa); + try expectNoCode(r.diagnostics.items, .illegal_statement_in_extension_hook); +} + +test "a timer in a hook is E1798" { + const gpa = std.testing.allocator; + var r = try checkHookSource(gpa, hook_base ++ + \\prefab "Mod" extends "Base" requires Health { + \\ entity "m" { Weapon {} } + \\ on_attach { after(1.0s) { } } + \\} + ); + defer r.deinit(gpa); + try std.testing.expectEqual(@as(usize, 1), countMessage(r.diagnostics.items, .illegal_statement_in_extension_hook, "timer")); +} + +test "a bound timer in a hook is E1798" { + const gpa = std.testing.allocator; + var r = try checkHookSource(gpa, hook_base ++ + \\prefab "Mod" extends "Base" requires Health { + \\ entity "m" { Weapon {} } + \\ on_attach { let t = every(1.0s) { } } + \\} + ); + defer r.deinit(gpa); + try std.testing.expectEqual(@as(usize, 1), countMessage(r.diagnostics.items, .illegal_statement_in_extension_hook, "timer")); } test "a hook on an of prefab is E1799" { diff --git a/tests/scene/extensions_test.zig b/tests/scene/extensions_test.zig index 44a9ca5a..101cce52 100644 --- a/tests/scene/extensions_test.zig +++ b/tests/scene/extensions_test.zig @@ -1796,3 +1796,54 @@ test "a deactivation whose on_detach no interpreter would run is refused" { try std.testing.expect(world.componentBytes(e, world.componentId("Weapon").?) != null); try std.testing.expect(world.hasEntityExtension(e, "Forged")); } + +test "a timer in a hook is refused at activation" { + const gpa = std.testing.allocator; + var h: HookWorld = undefined; + try h.init(gpa); + defer h.deinit(gpa); + const bytes = try forgedExtension(gpa, "Weapon", 4, &.{"Health"}, "after(1.0s) { }", null); + defer gpa.free(bytes); + try std.testing.expectError(error.ExtensionHookRefused, scene.loader.activateExtension(&h.world, gpa, h.entity, "Forged", bytes)); + try std.testing.expect(h.weapon(h.entity) == null); +} + +test "a throw in a hook is refused at activation" { + const gpa = std.testing.allocator; + var h: HookWorld = undefined; + try h.init(gpa); + defer h.deinit(gpa); + const bytes = try forgedExtension(gpa, "Weapon", 4, &.{"Health"}, "throw Error { message: \"boom\", code: .io_fail }", null); + defer gpa.free(bytes); + try std.testing.expectError(error.ExtensionHookRefused, scene.loader.activateExtension(&h.world, gpa, h.entity, "Forged", bytes)); + try std.testing.expect(h.weapon(h.entity) == null); +} + +/// The cook's answer for an `extends` prefab whose `on_attach` is `hook`. +fn cookWithHook(gpa: std.mem.Allocator, comptime hook: []const u8) !void { + var base = try scene_cook.cookPrefab(gpa, base_character, null, null); + defer base.deinit(gpa); + const base_bytes = try scene.writer.write(gpa, base.model, &base.registry); + defer gpa.free(base_bytes); + var base_res = OneResolver{ .name = "BaseCharacter", .bytes = base_bytes }; + const source = + \\component Health { current: i32 = 100, max: i32 = 100 } + \\component Weapon { damage: i32 = 10 } + \\prefab "Bad" extends "BaseCharacter" requires Health { + \\ entity "mod" { uuid: "9c4f3a2b-1e7d-4a5c-b8e9-f4d2c3a1b5e6" Weapon { damage: 25 } } + \\ on_attach { + ++ hook ++ + \\ } + \\} + ; + var cooked = try scene_cook.cookPrefab(gpa, source, base_res.base(), null); + cooked.deinit(gpa); +} + +test "an extension whose hook starts a timer does not cook" { + try std.testing.expectError(error.HookRefused, cookWithHook(std.testing.allocator, "after(1.0s) { }")); +} + +test "an extension whose hook throws outside a try does not cook" { + try std.testing.expectError(error.HookRefused, cookWithHook(std.testing.allocator, "throw Error { message: \"boom\", code: .io_fail }")); +} diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 8ec444a2..2fceaafe 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2762, - else => 2764, + .windows => 2771, + else => 2773, }; } From e9e9e8a7983d3179c4360f6c2dfcfa1870af1299 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 28 Sep 2026 10:21:59 +0200 Subject: [PATCH 124/141] feat(etch): report E0840 on a layer or manifest file The parser implements neither the layer nor the world construct, so a .layer.etch file holding its layer got a syntax error and E0858, and one holding anything else got E0858 alone. Such a file is no longer parsed: E0840 ConstructNotImplemented is the one diagnostic it gets, E0858 no longer judges it, and the cook refuses it as ConstructNotImplemented. A scene beside it now cooks, where its parse failure refused every cook of the project. Co-Authored-By: Claude Opus 5.5 --- src/etch/ast.zig | 25 ++++++++++++++++++---- src/etch/diagnostics.zig | 5 +++++ src/etch/project.zig | 15 ++++++++++++- src/etch/scene_cook.zig | 6 +++++- src/etch/types.zig | 2 +- tests/etch/crossfile_scene_prefab_test.zig | 25 ++++++++++++++++++++++ tests/scene/import_cook_test.zig | 22 +++++++++++++++++++ tools/weld_lint/dead_tests.zig | 4 ++-- 8 files changed, 95 insertions(+), 9 deletions(-) diff --git a/src/etch/ast.zig b/src/etch/ast.zig index 65b64ba9..a0afc42c 100644 --- a/src/etch/ast.zig +++ b/src/etch/ast.zig @@ -95,10 +95,27 @@ pub const ParseMode = enum { declaration_file, }; -/// The extension of the file an arena was parsed from, for `E0858` -/// (`etch-grammar.md` §21). `unknown` when the parse had no path, which -/// `E0858` does not judge. -pub const TypedExtension = enum { unknown, plain, scene, prefab, layer, manifest }; +/// The extension of the file an arena stands for, for `E0858` and +/// `E0840` (`etch-grammar.md` §21). `unknown` when the parse had no path, +/// which neither judges. +pub const TypedExtension = enum { + unknown, + plain, + scene, + prefab, + layer, + manifest, + + /// The construct a file of this extension holds, when the parser does not + /// implement it (`E0840`). + pub fn unimplementedConstruct(self: TypedExtension) ?[]const u8 { + return switch (self) { + .layer => "layer", + .manifest => "world", + .unknown, .plain, .scene, .prefab => null, + }; + } +}; /// Compact 32-bit handle into the `AstArena`: 4-bit `NodeCategory` + /// 28-bit index. Used as the universal pointer between AST nodes. diff --git a/src/etch/diagnostics.zig b/src/etch/diagnostics.zig index 1ed5735c..a01ade1a 100644 --- a/src/etch/diagnostics.zig +++ b/src/etch/diagnostics.zig @@ -410,6 +410,7 @@ pub const DiagnosticCode = enum { declaration_file_body_not_allowed, // E1900 DeclarationFileBodyNotAllowed (a `fn` carries a body inside a `.d.etch`) construct_not_allowed_in_declaration_file, // E1901 ConstructNotAllowedInDeclarationFile (a behavioural top-level construct appears in a `.d.etch`) typed_extension_mismatch, // E0858 TypedExtensionMismatch (a construct in the wrong typed extension, `etch-grammar.md` §21.2) + construct_not_implemented, // E0840 ConstructNotImplemented (a `.layer.etch` or `.manifest.etch` file, whose `layer` / `world` construct the parser does not implement) declaration_file_implementation_mismatch, // E1902 DeclarationFileImplementationMismatch (a committed `.d.etch` diverges from what the emitter produces on the current Zig `ServiceSpec`) /// Canonical short code, e.g. `"E0001"`. @@ -620,6 +621,7 @@ pub const DiagnosticCode = enum { .declaration_file_body_not_allowed => "E1900", .construct_not_allowed_in_declaration_file => "E1901", .typed_extension_mismatch => "E0858", + .construct_not_implemented => "E0840", .declaration_file_implementation_mismatch => "E1902", }; } @@ -832,6 +834,7 @@ pub const DiagnosticCode = enum { .declaration_file_body_not_allowed => "DeclarationFileBodyNotAllowed", .construct_not_allowed_in_declaration_file => "ConstructNotAllowedInDeclarationFile", .typed_extension_mismatch => "TypedExtensionMismatch", + .construct_not_implemented => "ConstructNotImplemented", .declaration_file_implementation_mismatch => "DeclarationFileImplementationMismatch", }; } @@ -943,4 +946,6 @@ test "DiagnosticCode code and name are stable cross-version" { try std.testing.expectEqualStrings("IllegalStatementInExtensionHook", DiagnosticCode.illegal_statement_in_extension_hook.name()); try std.testing.expectEqualStrings("E1799", DiagnosticCode.prefab_hook_not_allowed.code()); try std.testing.expectEqualStrings("PrefabHookNotAllowed", DiagnosticCode.prefab_hook_not_allowed.name()); + try std.testing.expectEqualStrings("E0840", DiagnosticCode.construct_not_implemented.code()); + try std.testing.expectEqualStrings("ConstructNotImplemented", DiagnosticCode.construct_not_implemented.name()); } diff --git a/src/etch/project.zig b/src/etch/project.zig index 0a363679..26bd269c 100644 --- a/src/etch/project.zig +++ b/src/etch/project.zig @@ -57,8 +57,21 @@ pub const Project = struct { @memset(self.parse_failed, false); try self.arenas.ensureTotalCapacity(gpa, n); for (files, 0..) |f, idx| { + const ext = parser.typedExtensionForPath(f.name); + // The parser does not implement this file's construct, so its + // source is not parsed and E0840 is the one diagnostic it gets. + if (ext.unimplementedConstruct()) |construct| { + var empty = try parser.parseWithMode(gpa, "", parser.modeForPath(f.name)); + gpa.free(empty.diagnostics); + empty.ast.typed_extension = ext; + self.arenas.appendAssumeCapacity(empty.ast); + const msg = try std.fmt.allocPrint(gpa, "a .{s}.etch file holds a '{s}', which is not implemented", .{ @tagName(ext), construct }); + errdefer gpa.free(msg); + try diags_out.append(gpa, .{ .code = .construct_not_implemented, .severity = .error_, .primary_span = .{ .byte_start = 0, .byte_end = 0 }, .primary_message = msg }); + continue; + } var pr = try parser.parseWithMode(gpa, f.source, parser.modeForPath(f.name)); - pr.ast.typed_extension = parser.typedExtensionForPath(f.name); + pr.ast.typed_extension = ext; // Each parse diagnostic moves into `diags_out` (its message // transfers), then only the vacated slice is freed — never // `pr.deinit`, which would free the arena kept below. diff --git a/src/etch/scene_cook.zig b/src/etch/scene_cook.zig index c27b5258..0774b827 100644 --- a/src/etch/scene_cook.zig +++ b/src/etch/scene_cook.zig @@ -132,6 +132,9 @@ pub const CookError = error{ ImportRefused, /// The file holds what its typed extension refuses (E0858). TypedExtensionMismatch, + /// The file's typed extension holds a construct the parser does not + /// implement (E0840). + ConstructNotImplemented, /// `prefab "Y" of "X"` but the base `X.prefab.bin` could not be resolved /// (no resolver, or the resolver returned null for the base name). BasePrefabMissing, @@ -321,8 +324,9 @@ pub const BaseResolver = struct { } }; -/// Refuse a file E0858 refuses. +/// Refuse a file E0858 or E0840 refuses. fn checkTypedExtension(gpa: std.mem.Allocator, ast: *AstArena, diag_out: ?*[]const u8) CookError!void { + if (ast.typed_extension.unimplementedConstruct() != null) return fail(diag_out, error.ConstructNotImplemented, "the file holds a construct the parser does not implement"); var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; defer { for (diags.items) |*d| d.deinit(gpa); diff --git a/src/etch/types.zig b/src/etch/types.zig index 6209ead2..c31844ee 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -1166,7 +1166,7 @@ pub const TypeChecker = struct { /// typed file. fn checkTypedExtension(self: *TypeChecker) !void { const ext = self.arena.typed_extension; - if (ext == .unknown) return; + if (ext == .unknown or ext.unimplementedConstruct() != null) return; const main: ?ast_mod.ItemKind = switch (ext) { .scene => .scene_decl, .prefab => .prefab_decl, diff --git a/tests/etch/crossfile_scene_prefab_test.zig b/tests/etch/crossfile_scene_prefab_test.zig index 4253fad3..5c7d66d9 100644 --- a/tests/etch/crossfile_scene_prefab_test.zig +++ b/tests/etch/crossfile_scene_prefab_test.zig @@ -213,3 +213,28 @@ test "no E0858 on a scene file holding one scene and its imports" { }, })); } + +/// Check `files`, requiring exactly one diagnostic: E0840. +fn expectOnlyE0840(files: []const etch.ProjectFile) !void { + const gpa = std.testing.allocator; + var diags: std.ArrayListUnmanaged(etch.Diagnostic) = .empty; + defer deinitDiags(gpa, &diags); + try validate(gpa, files, &diags); + try expectOnly(diags.items, .construct_not_implemented, 1); +} + +test "E0840 on a layer file holding its layer" { + try expectOnlyE0840(&.{.{ .name = "src/gameplay.layer.etch", .source = "layer \"Gameplay\" { }" }}); +} + +test "E0840 on a manifest file holding its world" { + try expectOnlyE0840(&.{.{ .name = "src/village.manifest.etch", .source = "world \"Village\" { }" }}); +} + +test "E0840 on a layer file holding a component" { + try expectOnlyE0840(&.{.{ .name = "src/gameplay.layer.etch", .source = "component Marker { id: int = 0 }" }}); +} + +test "E0840 on an empty manifest file" { + try expectOnlyE0840(&.{.{ .name = "src/village.manifest.etch", .source = "" }}); +} diff --git a/tests/scene/import_cook_test.zig b/tests/scene/import_cook_test.zig index 1e5eaf24..d4450e7e 100644 --- a/tests/scene/import_cook_test.zig +++ b/tests/scene/import_cook_test.zig @@ -643,3 +643,25 @@ test "a prefab variant cooks without naming its base's components, as etch check defer gpa.free(reference_bytes); try std.testing.expectEqualSlices(u8, reference_bytes, bytes); } + +test "a layer file refuses the cook, as E0840 refuses it" { + const files = [_]ProjectFile{.{ .name = "src/gameplay.layer.etch", .source = "layer \"Gameplay\" { }" }}; + try expectCheckReports(&files, .construct_not_implemented); + try std.testing.expectError(error.ConstructNotImplemented, scene_cook.cookSceneInProject(std.testing.allocator, &files, 0, null, null)); +} + +test "a layer file beside a scene does not refuse the scene's cook" { + const gpa = std.testing.allocator; + const files = [_]ProjectFile{ + .{ .name = "src/combat.etch", .source = combat }, + .{ .name = "src/gameplay.layer.etch", .source = "layer \"Gameplay\" { }" }, + .{ .name = "src/level.scene.etch", .source = + \\import combat { Health } + \\scene "Level" { + \\ entity "npc" { uuid: "00000000-0000-0000-0000-000000000002" Health { max: 40 } } + \\} + }, + }; + var cooked = try scene_cook.cookSceneInProject(gpa, &files, 2, null, null); + cooked.deinit(gpa); +} diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 2fceaafe..0764539c 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2771, - else => 2773, + .windows => 2777, + else => 2779, }; } From c86ff40289741826ea0a3afdb981937dffaff762 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 28 Sep 2026 10:22:23 +0200 Subject: [PATCH 125/141] docs(brief): record points 1 and 2 of decision 3 and E0840 Co-Authored-By: Claude Opus 5.5 --- briefs/m1.d-phase-1-debt.md | 46 +++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index 54693170..94e35454 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -7249,6 +7249,52 @@ session's commits, read from the suite; Windows is 2762, derived until CI reads - **Points 1 and 2, raised and untouched.** The timer and the bare `throw` in a hook each need a code, and the prefab range is full after E1799. +### S5/G9 vicies — points 1 and 2 of decision 3, and E0840 + +The CI of `31b63bba` was green on its 18 jobs, and the Windows floor 2762 was read on the +four cells. Guy attributed the three codes: E1798 becomes +`IllegalStatementInExtensionHook`, E1799's row is added, and E0840 +`ConstructNotImplemented` covers `.layer.etch` and `.manifest.etch` +(`etch-diagnostics.md` `sha256 2e9a5870…`, `etch-style-guide.md` `sha256 b8e48f98…`). +Every witness was red first, and every counter-factual ran alone in the worktree and fell +as predicted. The suite then ran 2762 / 2779 (17 skipped) on macOS, 329/329 steps. The +floor moves 2764 → 2773 → 2779 through the two commits, read from the suite; Windows is +2777, derived until CI reads it. + +- **Points 1 and 2, E1798** (`ddebe3d7`). The identifier is renamed with the code. + `check` now refuses a `throw` with no enclosing `try` — its `catch` body included — and + a timer, bound or not. The load check and the cook refuse both through `check`. There + are five check witnesses, among them a control (a `throw` inside a `try` stays legal), + two at the load and two at the cook. + - **One prediction was refuted, and the cause was my fixture.** The two `throw` + witnesses at the load and at the cook passed on their first run. Measured, the + `Error` literal lacked `code`, so E0200 had refused it: a fixture passing because its + oracle looked elsewhere, the class of arbitration 4, made by me. With `code: .io_fail` + they went red. At the load, the `throw` failed after the activation had committed + (`ExtensionHookFailed`); at the cook, the renderer refused it (`HookRenderFailed`), + where I had predicted it would cook. + - **The `sed` that fixed them also rewrote a pre-existing test**, one that pins the + missing `code`. I caught it in the diff and restored it. + - Counter-factuals: without the `throw` emission, the four `throw` witnesses go red; + without the timer emission, the four timer witnesses; with the `throw` refused even + inside a `try`, the control goes red, so it bites. +- **E0840** (`e9e9e8a7`). A `.layer.etch` or `.manifest.etch` file is no longer parsed: + its construct is not implemented, so E0840 is the one diagnostic it gets, whatever it + holds. E0858 no longer judges it, and the cook refuses it with + `ConstructNotImplemented`. A scene beside it now cooks, where the layer file's parse + failure used to refuse every cook of its project. The predicate is one method on the + extension, read by the project, by E0858 and by the cook. + - Four check witnesses: each extension's own construct, a component, and an empty + file. They went red as predicted: two diagnostics where one was expected for the + three files with content, and no E0840 for the empty one. Two cook witnesses. + - Counter-factuals: parsing the file anyway turns all six red; letting E0858 judge it + again turns the four check witnesses red at 2 diagnostics; removing the cook refusal + turns the layer cook red (`NoSceneConstruct`). +- **Two spec rows, raised and untouched.** E1799's row names hooks only, while the + checker also refuses `requires` without `extends`, as the grammar does. E0840's row + says the file carries a `layer` or a `world`, while the diagnostic goes to any such + file. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree From b147695e2a14ef548bb2ed4f9d57babfb6999674 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 28 Sep 2026 11:35:45 +0200 Subject: [PATCH 126/141] fix(ci): raise the Release cell budget to 120 minutes A pull request runs cold, the cache being saved on push only, so the Release budget must cover the worst cold run. Over every run since 2026-09-01, the 75-minute budget cut one cold ReleaseFast job on a slow runner, 0.2 s short of its step's end; its cold re-run at the same sha took 44.5 minutes, a x1.69 runner ratio. The Windows cold median since 09-26 is 59.9 minutes and rising by about 0.4 to 0.6 minutes a day, so a runner that slow on it gives 101 minutes. 120 covers that with 19 %. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- CLAUDE.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be63f7a5..93d4a916 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,7 +74,7 @@ jobs: mode: ReleaseFast precision: false runs-on: ${{ matrix.os }} - timeout-minutes: ${{ (matrix.mode == 'ReleaseSafe' || matrix.mode == 'ReleaseFast') && 75 || 35 }} + timeout-minutes: ${{ (matrix.mode == 'ReleaseSafe' || matrix.mode == 'ReleaseFast') && 120 || 35 }} steps: - uses: actions/checkout@v6 diff --git a/CLAUDE.md b/CLAUDE.md index 8c5d46ef..ccb29f31 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,7 +125,7 @@ commit, so a milestone still in review has no row here. - **`M1.D.29` — CLOSED at M1.D/S5: `win32_thread_safety_test`'s 30 s wall-clock bound is gone**; it only detected a hang, and `--test-timeout`, passed by every CI matrix cell, names a hung test. Without that flag a running test has no deadline at all (Zig 0.16, measured). **The class was ruled and swept at M1.D/S5**: `engine-zig-conventions.md` §13 was rewritten, `--test-timeout` now runs wherever the suite does, pure hang detectors are gone, child-process waits are wide and kill on the bound, and duration gates left the tests for `bench/etch_reference.zig` and `bench/shader_hot_reload.zig`. **The seven raised cases were ruled and applied**: the scheduler watchdog fires at 150 s, under the runner's 180 s; `sleepPrecise` keeps its lower bound in one test; the loader, thread and kill tests synchronise on events; `waitNonblock` answers minus the signal number for a signalled child. Detail in the brief, S5/G9 terdecies to sexdecies. - **The sparse-driven disjunctive path's first-use allocation is BOUNDED but not ISOLATED (opened at M1.1.15-era, measured at M1.B/G10)**. The entity-keyed disjunctive path allocates an `AutoHashMapUnmanaged` the FIRST time a sparse-driven term appears and reuses it after, so steady state is allocation-free like `merge_cursors` — the same shape as `contact_constraint.zig`'s `deferred` residual. `bench/ecs_hybrid_crossover.zig` now bounds it: the sparse arm's first-tick allocation count is **constant at 3** across all 28 cells of every configuration, where the table arm's grows **2 → 183** with churn. **What the bench does NOT do is isolate the map from the other two allocations on that tick** — it establishes that the quantity is a small constant and does not grow with fraction or churn, which is what the debt needed, and not which of the three is the map. Stated rather than implied. - **`D-M0.2.1-c01-baseline-investigation` is CLOSED, and it was tracking a phantom (closed at M1.B/G11)**. It followed the divergence between C0.1's 3.74 ms and a "14.2 ms M0.1 baseline" that **no M0.1 artifact carries**: the squash commit body, the annotated tag and `briefs/M0.1-ecs-full.md:316` all read **3.84 ms in ReleaseFast**, and `git log --all --grep=14.2` returns two commits of which the earlier is `df67e1c` (M0.2.1 itself). The real delta is **2.6 %**. Closed by naming it here and in the M1.B brief plus a head note on the dated report — `engine-audit-checklist.md` carries zero occurrences of the identifier (measured), so the debt lived only in a brief, and a brief is a document of its commit and is not edited. -- **`build-and-test (windows-2025, ReleaseSafe)` HAD NO HEADROOM AT ITS 55-MINUTE BUDGET, and a comment-only commit was enough to spend it (opened at M1.E/G11, measured, mechanism named; the Release budget is 75 minutes since `c3e6d316`, Debug 35)**. **NOT the hang class**, whose count stays at eight: there, tests are LOST and the signature is `test runner failed to respond`. Here the job's conclusion is `cancelled` and **EVERY step succeeded**, `zig build test` and `Complete job` included — the job ran to its end and the wall arrived as it finished. Duration **55.0 min against `timeout-minutes: 55`** (`ci.yml:216`), and `fail-fast: false` means the other fourteen jobs ran green; `ci-gate` failed only because a required cell was not `success`. **Located step by step**: `zig build` 11.0 min, **`zig build test` 38.0 min**, the three cache steps 4.8 min together — so `M1.D.10`'s cache purging is NOT the cause here. The same cell on the two preceding commits of the same branch: `fb3d912` **12.5 min**, `7a7fc1b` **37.6 min**. A factor of 4.4 on a diff that is comments only. **The mechanism is the commit's own shape**, and it is `M1.D.8`'s transposed to another cell: the changed files are the tree's most-depended-upon Tier 0 headers — `world.zig`, `interp.zig`, `query.zig`, `observers.zig`, `archetype.zig`, `hybrid_query.zig`, `registry.zig`, `sparse_storage.zig`, both schedulers — and a comment edit changes a file's hash, so every compile step whose closure reaches them recompiles. A documentation pass over Tier 0 therefore costs the same cache as a refactor of it. **THE AMPLITUDE IS ATTRIBUTED, by a re-run at the SAME SHA whose only difference is that the first attempt's cache save had run**: attempt 2 took **10.3 min** where attempt 1 took 55.0, with `zig build test` at **3.2 min against 38.0** — a factor of **11.9** — and `zig build` at 4.0 against 11.0. So 34.8 of those 38 minutes were COLD-CACHE COMPILATION and not test execution, measured on one commit with the apparatus held fixed, which is `M1.D.8`'s finding with a clean before/after instead of a comparison across runs. The runner's intrinsic variance is NOT needed to explain it. The first of the two options was taken at `c3e6d316`: the Release budget went from 55 to 75 minutes. **AND A WARM CACHE IS NOT GUARANTEED BY A RE-RUN**: at M1.E on `387ab97`, another comment-only Tier 0 commit, the f64 leg of the same cell hung (the hang class above), and its re-run — which therefore started from whatever the FAILED attempt had left — took **53.1 min**, passing with **1.9 minutes of headroom**. The two classes appeared on one cell in one run, the hang on attempt 1 and the cold-cache cost on attempt 2, which does not merge them — one loses tests and prints `failed to respond`, the other has every step green and pays in minutes — but it is the second commit in two milestones where a comment-only edit to Tier 0 headers put this cell within two minutes of its wall. Owner: unassigned. +- **`build-and-test (windows-2025, ReleaseSafe)` HAD NO HEADROOM AT ITS 55-MINUTE BUDGET, and a comment-only commit was enough to spend it (opened at M1.E/G11, measured, mechanism named; the Release budget is 120 minutes since M1.D/S5, sized on the slowest runner measured over the cold windows median, Debug 35)**. **NOT the hang class**, whose count stays at eight: there, tests are LOST and the signature is `test runner failed to respond`. Here the job's conclusion is `cancelled` and **EVERY step succeeded**, `zig build test` and `Complete job` included — the job ran to its end and the wall arrived as it finished. Duration **55.0 min against `timeout-minutes: 55`** (`ci.yml:216`), and `fail-fast: false` means the other fourteen jobs ran green; `ci-gate` failed only because a required cell was not `success`. **Located step by step**: `zig build` 11.0 min, **`zig build test` 38.0 min**, the three cache steps 4.8 min together — so `M1.D.10`'s cache purging is NOT the cause here. The same cell on the two preceding commits of the same branch: `fb3d912` **12.5 min**, `7a7fc1b` **37.6 min**. A factor of 4.4 on a diff that is comments only. **The mechanism is the commit's own shape**, and it is `M1.D.8`'s transposed to another cell: the changed files are the tree's most-depended-upon Tier 0 headers — `world.zig`, `interp.zig`, `query.zig`, `observers.zig`, `archetype.zig`, `hybrid_query.zig`, `registry.zig`, `sparse_storage.zig`, both schedulers — and a comment edit changes a file's hash, so every compile step whose closure reaches them recompiles. A documentation pass over Tier 0 therefore costs the same cache as a refactor of it. **THE AMPLITUDE IS ATTRIBUTED, by a re-run at the SAME SHA whose only difference is that the first attempt's cache save had run**: attempt 2 took **10.3 min** where attempt 1 took 55.0, with `zig build test` at **3.2 min against 38.0** — a factor of **11.9** — and `zig build` at 4.0 against 11.0. So 34.8 of those 38 minutes were COLD-CACHE COMPILATION and not test execution, measured on one commit with the apparatus held fixed, which is `M1.D.8`'s finding with a clean before/after instead of a comparison across runs. The runner's intrinsic variance is NOT needed to explain it. The first of the two options was taken at `c3e6d316`: the Release budget went from 55 to 75 minutes. **AND A WARM CACHE IS NOT GUARANTEED BY A RE-RUN**: at M1.E on `387ab97`, another comment-only Tier 0 commit, the f64 leg of the same cell hung (the hang class above), and its re-run — which therefore started from whatever the FAILED attempt had left — took **53.1 min**, passing with **1.9 minutes of headroom**. The two classes appeared on one cell in one run, the hang on attempt 1 and the cold-cache cost on attempt 2, which does not merge them — one loses tests and prints `failed to respond`, the other has every step green and pays in minutes — but it is the second commit in two milestones where a comment-only edit to Tier 0 headers put this cell within two minutes of its wall. Owner: unassigned. - **The singleton-archetype exclusion is implemented at ITERATION time only, and the test written for it cannot see the gap (opened at M1.E/G11, measured, needs a number)**. `Archetype.is_singleton` promises that "user queries never see resource entities". The skip exists in `Query.maybeRescan`'s TAIL rescan (`query.zig:542`) and in `ComptimeQuery.next` (`comptime_query.zig:107`), both per-iteration and therefore order-independent — and NOT in the initial scan `World.queryFiltered` runs when a typed `Query` is CONSTRUCTED (`world.zig:2309-2318`, no `is_singleton` test). So a resource already flagged at construction enters that query's match list, while one flagged afterwards is skipped: the exclusion depends on the order between `setResource` and the query's construction. **`tests/core/resources/query_exclusion_test.zig` cannot catch it**: its own header says the exclusion is read by both the typed and the dynamic path, and it exercises only `comptime_query`, whose skip runs per iteration. Not fixed here — M1.E's object is comments, and a one-line `continue` in the typed query's initial scan changes Tier 0 query behaviour — so the flag's doc now states what is implemented and names the gap instead of promising the invariant. Owner: the ECS query owner. - **`job_bound.reasonOf` was not widened with the walk, so the guard refuses correctly and explains nothing in the one case it exists for (opened at M1.E/G11, needs a number)**. `carriesMarkedIn` enters every composite — pointer, array, vector, optional, error union, and each field of a struct or union — while `reasonOf` follows only `.pointer` and `.optional` and answers `"no reason declared"` for everything else (`job_bound.zig:168-172`). `refuseMarkedArgs` reports `reasonOf(f.type)` on the OUTER field type, so for `SystemContext` carrying `cmd: *CommandBuffer` — **the exact shape the widening was written for** — the compile error names the type and gives no reason. The file's own doctrine, written at `carriesMarkedIn`, is that "widening only to struct fields would have repeated the class this reprise exists to close — a rule applied to a subset of what it must cover"; its sibling function IS that subset. The asymmetry is now stated at the marker's doc. The symmetric widening changes a compile-error message and is therefore behaviour. Owner: whoever owns the bound. - **The `tests/` subtree is OUTSIDE the comment rules by decision, and its size is now measured (M1.E/G11)**. `comment_scan.inPerimeter` returns false for any path whose FIRST segment is `tests`, with the reason written at the function: `src/`, `tools/` and `bench/` carry a conservation pass that gives a reworded comment somewhere to go, and `tests/` does not, so firing there would make the rule green only by leaving those files permanently red. **MEASURED WITH THE BINARY ITSELF, the perimeter exclusion lifted in a throwaway build: 732 diagnostics on 519 distinct comment lines in 146 files** — 494 lines carrying a milestone/gate/step/spike/review identifier and 25 more carrying a phase mention and no identifier. That is 2.6× `build.zig`'s entire diagnostic count, and the largest single unswept perimeter the comment work leaves. Seven of the twelve remaining word-provenance rows live there too. **An earlier figure of 492 lines was published here and is WRONG TWICE**: it was the `comment_identifiers` count alone where the honest quantity is all-rule, and it came from a Python transcription of the rule rather than from the rule — a transcription that tests only lines whose `strip()` starts with `//`, so it misses a comment trailing a line of code, and that counts the `tests/lint/bad/` fixtures the walker deliberately skips. **Measure with the instrument, not with a replica of it, when the instrument is one build away.** Owner: the pass that reads `tests/`. From 06e4a5673c25261b452e16e3ae7c6fbefc8942d8 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 28 Sep 2026 11:35:48 +0200 Subject: [PATCH 127/141] docs(brief): record the Release budget measurement Co-Authored-By: Claude Opus 5.5 --- briefs/m1.d-phase-1-debt.md | 38 +++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index 94e35454..f61c0f4f 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -7295,6 +7295,44 @@ floor moves 2764 → 2773 → 2779 through the two commits, read from the suite; says the file carries a `layer` or a `world`, while the diagnostic goes to any such file. +### S5/G9 unvicies — the Release budget + +The CI of `c86ff402` was green on its 18 jobs, and the Windows floor 2777 was read on the +four cells. Ruled: a budget that separates SLOW from HUNG must cover the worst +cold run, and pull requests always run cold, the cache being saved on `push` only. + +**Measured over every CI run since 2026-09-01**: 204 runs, and 1401 Release jobs once the +copies a `--failed` re-run re-lists under the new attempt are removed. A run is cold when +its cache-restore step lasts at most 1 s; that rule was checked on 42 logs, 17 by me and 25 +by the review. + +- **Cold medians and maxima since 09-26**: Windows f32 59.9 / 64.6 min, Windows f64 + 58.9 / 62.5 min (63.5 min on `c86ff402` itself), `ubuntu / ReleaseFast` 48.0 / 52.2 min. + **Cold times are rising**: the Windows f32 weekly maximum ran 48.8, 52.0, 55.3, then + 64.6 min, and a least-squares fit on cold successes gives +0.39 and +0.58 min per day + on Windows and +0.43 on `ReleaseFast`. +- **The one run the 75-minute budget cut**: `ubuntu / ReleaseFast`, cold, on a slow runner, + 0.2 s short of the end of its step. Its cold re-run at the same sha took 44.5 min, so the + runner cost ×1.69 on the whole job (×1.74 on the test step). No larger ratio was measured + on identical code. +- **A restored cache is not a warm run**: the 13 Windows jobs cut at the former 55 minutes + had restored caches from other shas and built as slowly as cold ones. +- **100 minutes covers every run measured**, with 33 % margin over the worst (75.1 min). + **It does not cover a runner as slow as the worst measured on today's Windows cold + median**, which gives 101 min. The budget is therefore **120 minutes**, 19 % over that + projection. At the measured slope the projection reaches 120 in about three weeks, while + the measured maxima would take months. +- Debug stays at 35 minutes: its worst run is 20.4 min. The smoke jobs peak at 13.4 of 20 + minutes. +- **The review was three read-only skeptics** (data pipeline, cold classification and + censoring, coverage). They confirmed the pipeline and the classification, and refuted my + first margin: I had taken medians over the whole month with failures counted, and left a + censored cold run out. They also found the rising trend and the 63.5-minute run. My claim + of 100 minutes with a 12 % margin rested on figures that the trend had overtaken. +- **Raised, untouched**: `ci.yml` says its dimensioning lives in `engine-platform.md`, whose + CI section carries no budget. As long as pull requests run cold, the budget grows with the + suite, and that regime is `engine-platform.md`'s design. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree From ddbdc85cebe4c468b33a8d5bed713ca6da29304d Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 28 Sep 2026 15:02:25 +0200 Subject: [PATCH 128/141] fix(etch): refuse a composite value where a builtin is declared Every gate that compares a value with its destination compared two builtins and let anything else through, so `let v: int = [1, 2, 3]`, a struct passed to an int parameter or assigned to an int local, and a struct branch beside an int one checked clean and failed at every tick. One predicate, valueFits, now judges them all: two builtins by the literal rule, a Vec3 from an array of three numbers, a builtin and a composite never, two kinds only when both are arrays, a nominal type only as the same declaration, so an alias names the component it imports. The places that need a builtin refuse a composite, a map literal refuses a non-builtin key or value, and a return inside a closure is no longer judged against the enclosing fn. A table of 42 mistyped cases and a control of the well-typed forms through the same gates pin it; an optional of a composite stays unknown. Floor 2779 -> 2784 / 2782, read from the suite. Co-Authored-By: Claude Opus 5.5 --- src/etch/types.zig | 350 +++++++++++++++++++++++------ tests/etch/import_resolve_test.zig | 34 +++ tools/weld_lint/dead_tests.zig | 4 +- 3 files changed, 319 insertions(+), 69 deletions(-) diff --git a/src/etch/types.zig b/src/etch/types.zig index c31844ee..5c734f43 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -2281,16 +2281,7 @@ pub const TypeChecker = struct { } break :blk try self.synthExprE(field.value, null); }; - const mismatch = switch (d) { - .builtin => |db| actual == .builtin and !try self.literalTypeFits(db, field.value, actual.builtin), - .struct_t => |dn| actual == .struct_t and actual.struct_t != dn, - .enum_t => |dn| switch (actual) { - .enum_t => |an| an != dn, - .builtin => true, - else => false, - }, - else => false, - }; + const mismatch = !try self.valueFits(d, field.value, actual); if (mismatch) { try self.emit(tcode, .error_, self.arena.exprSpan(field.value), "field '{s}' value type does not match its declared type", .{self.arena.strings.slice(field.name)}); } @@ -2426,7 +2417,7 @@ pub const TypeChecker = struct { // 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 !try self.literalTypeFits(declared_builtin, field.value, actual.builtin)) { + if (!try self.valueFits(.{ .builtin = declared_builtin }, field.value, actual)) { try self.emit(code_type, .error_, self.arena.exprSpan(field.value), "field '{s}' value type does not match its declared type", .{field_name_bytes}); } } @@ -2919,16 +2910,7 @@ pub const TypeChecker = struct { } break :blk try self.synthExprE(field.value, null); }; - const mismatch = switch (d) { - .builtin => |db| actual == .builtin and !try self.literalTypeFits(db, field.value, actual.builtin), - .struct_t => |dn| actual == .struct_t and actual.struct_t != dn, - .enum_t => |dn| switch (actual) { - .enum_t => |an| an != dn, - .builtin => true, - else => false, // `.unknown` already reported upstream - }, - else => false, - }; + const mismatch = !try self.valueFits(d, field.value, actual); if (mismatch) { try self.emit(.entry_field_type_invalid, .error_, self.arena.exprSpan(field.value), "data entry field '{s}' value type does not match its declared type", .{self.arena.strings.slice(field.name)}); } @@ -4655,10 +4637,8 @@ pub const TypeChecker = struct { if (!try self.foldsAsConstant(value, "const value must be a constant expression (literal, arithmetic on literals, or parenthesized)")) return; const declared = self.namedTypeToResolved(type_node); const actual = self.synthExpr(value, null); - if (declared == .builtin and actual == .builtin) { - if (!try self.literalTypeFits(declared.builtin, value, actual.builtin)) { - try self.emit(.type_mismatch, .error_, self.arena.exprSpan(value), "const value type does not match the declared type", .{}); - } + if (!try self.valueFits(declared, value, actual)) { + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(value), "const value type does not match the declared type", .{}); } } @@ -4672,13 +4652,9 @@ pub const TypeChecker = struct { return; } const actual = self.synthExpr(value, null); - if (declared == .builtin and actual == .builtin) { - if (!try self.literalTypeFits(declared.builtin, value, actual.builtin)) { - try self.emit(.type_mismatch, .error_, self.arena.exprSpan(value), "default value type does not match declared field type", .{}); - } + if (!try self.valueFits(declared, value, actual)) { + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(value), "default value type does not match declared field type", .{}); } - // If declared isn't builtin (e.g. unknown), we already emitted a - // diagnostic during field-type resolution — skip cascade. } /// A collection literal against the element types a `let` annotation @@ -4841,6 +4817,78 @@ pub const TypeChecker = struct { } } + /// Whether a value of type `actual` fits a slot of type `declared`. Two + /// composites of one kind are left to the checks of their own construct. A + /// generic fits anything: a struct's own type parameters resolve against the + /// caller's. + fn valueFits(self: *TypeChecker, declared: ResolvedType, value: NodeId, actual: ResolvedType) !bool { + if (declared == .unknown or actual == .unknown or declared == .generic or actual == .generic) return true; + if (declared == .builtin and actual == .builtin) return self.literalTypeFits(declared.builtin, value, actual.builtin); + if (declared == .builtin and declared.builtin == .vec3 and actual == .array_fixed) + return actual.array_fixed.len == 3 and actual.array_fixed.elem.isNumeric(); + if ((declared == .builtin) != (actual == .builtin)) return false; + if (std.meta.activeTag(declared) != std.meta.activeTag(actual)) return isArray(declared) and isArray(actual); + if (isNominal(declared)) return self.sameDeclaration(declared, actual); + return true; + } + + fn isArray(t: ResolvedType) bool { + return t == .array_fixed or t == .array_dyn; + } + + fn builtinWhere(t: ResolvedType, comptime pred: fn (BuiltinType) bool) bool { + return switch (t) { + .unknown, .generic => true, + .builtin => |bt| pred(bt), + else => false, + }; + } + + fn isBool(bt: BuiltinType) bool { + return bt == .bool_; + } + + fn isDuration(bt: BuiltinType) bool { + return bt == .duration; + } + + fn isString(bt: BuiltinType) bool { + return bt == .string_; + } + + /// Whether two nominal types of one kind name one declaration, whatever + /// local name each carries. + fn sameDeclaration(self: *TypeChecker, a: ResolvedType, b: ResolvedType) bool { + const x = self.declarationOf(a) orelse return ResolvedType.eql(a, b); + const y = self.declarationOf(b) orelse return ResolvedType.eql(a, b); + return x.arena == y.arena and x.name == y.name; + } + + const Declaration = struct { arena: *const AstArena, name: StringId }; + + fn declarationOf(self: *TypeChecker, t: ResolvedType) ?Declaration { + return switch (t) { + .component => |n| if (self.componentNamed(n)) |c| .{ .arena = c.arena, .name = c.decl.name } else null, + .resource => |n| if (self.resourceNamed(n)) |r| .{ .arena = r.arena, .name = r.decl.name } else null, + .event_t => |n| if (self.eventNamed(n) orelse self.declaredEvent(n)) |e| .{ .arena = e.arena, .name = e.decl.name } else null, + else => null, + }; + } + + /// Whether a match arm's type agrees with the earlier arms': two builtins + /// only when equal, an arm body being a bare literal as often as not. + fn armsAgree(self: *TypeChecker, earlier: ResolvedType, body: NodeId, body_t: ResolvedType) !bool { + if (earlier == .builtin and body_t == .builtin) return ResolvedType.eql(earlier, body_t); + return self.valueFits(earlier, body, body_t); + } + + fn isNominal(t: ResolvedType) bool { + return switch (t) { + .struct_t, .enum_t, .component, .resource, .event_t => true, + else => false, + }; + } + /// Polymorphic int / float literal rule (`etch-resolver-types.md` §4.3). /// When the declared context type is given and the value is a literal of /// the same numeric family (int family → any integer builtin, float family @@ -5168,7 +5216,7 @@ pub const TypeChecker = struct { if (!decl.value.isNone()) { const vt = self.synthExpr(decl.value, &ctx); - if (!decl.return_type.isNone() and ret_t == .builtin and vt == .builtin and !try self.literalTypeFits(ret_t.builtin, decl.value, vt.builtin)) { + if (!decl.return_type.isNone() and !try self.valueFits(ret_t, decl.value, vt)) { try self.emit(.return_type_mismatch, .error_, self.arena.exprSpan(decl.value), "method '{s}' body value type does not match its declared return type", .{self.arena.strings.slice(decl.name)}); } } @@ -5728,7 +5776,7 @@ pub const TypeChecker = struct { // declared return type (E0200, consistent with the closure-call path). if (!decl.value.isNone()) { const vt = self.synthExpr(decl.value, &ctx); - if (!decl.return_type.isNone() and ret_t == .builtin and vt == .builtin and !try self.literalTypeFits(ret_t.builtin, decl.value, vt.builtin)) { + if (!decl.return_type.isNone() and !try self.valueFits(ret_t, decl.value, vt)) { try self.emit(.return_type_mismatch, .error_, self.arena.exprSpan(decl.value), "function '{s}' body value type does not match its declared return type", .{self.arena.strings.slice(decl.name)}); } } @@ -5888,7 +5936,7 @@ pub const TypeChecker = struct { }; if (!try self.foldsAsConstant(node.filter_value, "field filter value must be a constant expression")) return; const actual = self.synthExpr(node.filter_value, null); - if (declared == .builtin and actual == .builtin and !try self.literalTypeFits(declared.builtin, node.filter_value, actual.builtin)) { + if (!try self.valueFits(declared, node.filter_value, actual)) { try self.emit(.invalid_field_filter, .error_, node.span, "field filter type does not match field declared type", .{}); } } @@ -5907,7 +5955,7 @@ pub const TypeChecker = struct { const declared = self.fieldTypeIn(ev.arena, decl.fields_start, decl.fields_len, flit.name); const actual = self.synthExpr(flit.value, ctx_opt); if (declared) |d| { - if (d == .builtin and actual == .builtin and !try self.literalTypeFits(d.builtin, flit.value, actual.builtin)) { + if (!try self.valueFits(d, flit.value, actual)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(flit.value), "event field '{s}' value type does not match its declared type", .{self.arena.strings.slice(flit.name)}); } } else { @@ -5994,7 +6042,7 @@ pub const TypeChecker = struct { break :blk self.synthHeadValue(let.value, ctx); }; const final = if (declared) |d| blk: { - if (d == .builtin and inferred == .builtin and !try self.literalTypeFits(d.builtin, let.value, inferred.builtin)) { + if (!try self.valueFits(d, let.value, inferred)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(let.value), "let initializer type does not match declared type", .{}); } try self.checkCollectionLitAgainst(let.value, d, inferred); @@ -6031,7 +6079,7 @@ pub const TypeChecker = struct { try self.emit(.type_mismatch, .error_, span, "cannot assign to immutable binding (use 'let mut')", .{}); } const rhs_type = self.synthHeadValue(assign.value, ctx); - if (local.type_ == .builtin and rhs_type == .builtin and !try self.literalTypeFits(local.type_.builtin, assign.value, rhs_type.builtin)) { + if (!try self.valueFits(local.type_, assign.value, rhs_type)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(assign.value), "assignment value type does not match binding type", .{}); } // Compound assignment on strings (`s += t`) is not in @@ -6058,7 +6106,7 @@ pub const TypeChecker = struct { // Synthesize the field type and check the value matches it. const lhs_type = self.synthExpr(assign.target, ctx); const rhs_type = self.synthExpr(assign.value, ctx); - if (lhs_type == .builtin and rhs_type == .builtin and !try self.literalTypeFits(lhs_type.builtin, assign.value, rhs_type.builtin)) { + if (!try self.valueFits(lhs_type, assign.value, rhs_type)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(assign.value), "assignment value type does not match field type", .{}); } } else { @@ -6151,7 +6199,7 @@ pub const TypeChecker = struct { if (wh.let_binding != 0) { const payload = try self.optionalPayload(cond_t, self.arena.exprSpan(wh.cond), "while let"); try ctx.locals.put(self.gpa, wh.let_binding, .{ .type_ = payload, .is_mut = false }); - } else if (cond_t == .builtin and cond_t.builtin != .bool_) { + } else if (!builtinWhere(cond_t, isBool)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(wh.cond), "while condition must be a bool expression", .{}); } // in-branch loop, mirror of the `for` arm. @@ -6253,7 +6301,7 @@ pub const TypeChecker = struct { } } if (self.current_fn_return) |ret| { - if (ret == .builtin and vt == .builtin and !try self.literalTypeFits(ret.builtin, value, vt.builtin)) { + if (!try self.valueFits(ret, value, vt)) { try self.emit(.return_type_mismatch, .error_, self.arena.exprSpan(value), "return value type does not match the declared return type", .{}); } } @@ -6326,7 +6374,7 @@ pub const TypeChecker = struct { const br = self.arena.concurrency_branches.items[range.branches_start + i]; if (!br.cond.isNone()) { const cond_t = self.synthExpr(br.cond, ctx); - if (cond_t == .builtin and cond_t.builtin != .bool_) { + if (!builtinWhere(cond_t, isBool)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(br.cond), "conditional branch guard must be a bool expression", .{}); } } @@ -7012,7 +7060,7 @@ pub const TypeChecker = struct { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(id), "cast target must be a numeric primitive type ('as' converts between numbers in the S3 subset)", .{}); return ResolvedType.unknown; } - if (operand_t == .builtin and !operand_t.builtin.isNumeric()) { + if (!builtinWhere(operand_t, BuiltinType.isNumeric)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(id), "cast operand must be a numeric primitive", .{}); return ResolvedType.unknown; } @@ -7191,6 +7239,8 @@ pub const TypeChecker = struct { const kt = try self.synthExprE(entry.key, ctx_opt); const vt = try self.synthExprE(entry.value, ctx_opt); if (kt != .builtin or vt != .builtin) { + if (kt != .builtin and kt != .unknown) try self.emit(.type_mismatch, .error_, self.arena.exprSpan(entry.key), "map keys must be a builtin primitive in E1", .{}); + if (vt != .builtin and vt != .unknown) try self.emit(.type_mismatch, .error_, self.arena.exprSpan(entry.value), "map values must be a builtin primitive in E1", .{}); all_builtin = false; continue; } @@ -7300,18 +7350,18 @@ pub const TypeChecker = struct { if (ctx_opt) |ctx| _ = ctx.locals.remove(ife.let_binding); if (ife.else_branch.isNone()) return ResolvedType.unknown; const else_t = try self.synthExprE(ife.else_branch, ctx_opt); - if (then_t == .builtin and else_t == .builtin and !ResolvedType.eql(then_t, else_t)) { + if (!try self.valueFits(then_t, ife.else_branch, else_t)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(id), "if branches must yield the same type", .{}); } return then_t; } - if (cond_t == .builtin and cond_t.builtin != .bool_) { + if (!builtinWhere(cond_t, isBool)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(ife.cond), "if condition must be a bool expression", .{}); } const then_t = try self.synthExprE(ife.then_block, ctx_opt); if (ife.else_branch.isNone()) return ResolvedType.unknown; const else_t = try self.synthExprE(ife.else_branch, ctx_opt); - if (then_t == .builtin and else_t == .builtin and !ResolvedType.eql(then_t, else_t)) { + if (!try self.valueFits(then_t, ife.else_branch, else_t)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(id), "if branches must yield the same type", .{}); } return then_t; @@ -7380,7 +7430,7 @@ pub const TypeChecker = struct { } else { _ = try self.synthArg(call, 0, ctx_opt); const t1 = try self.synthArg(call, 1, ctx_opt); - if (t1 == .builtin and t1.builtin != .duration) { + if (!builtinWhere(t1, isDuration)) { try self.emit(.type_mismatch, .error_, span, "tick_until timeout must be a Duration", .{}); } } @@ -7402,7 +7452,7 @@ pub const TypeChecker = struct { // fail-loud rather than mis-compare. if (!assertComparable(ta) or !assertComparable(tb)) { try self.emit(.type_mismatch, .error_, span, "{s} compares scalar / string / enum values (structs, arrays, maps, sets, optionals are not comparable in v0.6)", .{name}); - } else if (ta == .builtin and tb == .builtin and ta.builtin != tb.builtin) { + } else if (ta != .unknown and tb != .unknown and !ResolvedType.eql(ta, tb)) { try self.emit(.type_mismatch, .error_, span, "{s} compares two values of the same type", .{name}); } if (call.args_len == 3) _ = try self.synthArg(call, 2, ctx_opt); @@ -7526,7 +7576,7 @@ pub const TypeChecker = struct { var ptype = arg_t; if (!p.type_node.isNone()) { ptype = self.namedTypeToResolved(p.type_node); - if (ptype == .builtin and arg_t == .builtin and !try self.literalTypeFits(ptype.builtin, arg, arg_t.builtin)) { + if (!try self.valueFits(ptype, arg, arg_t)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(arg), "closure argument type does not match the parameter type", .{}); } } @@ -7536,6 +7586,10 @@ pub const TypeChecker = struct { const saved_captures = self.closure_captures; self.closure_captures = &captures; defer self.closure_captures = saved_captures; + // A `return` in a closure leaves the closure, which declares no type. + const saved_ret = self.current_fn_return; + self.current_fn_return = null; + defer self.current_fn_return = saved_ret; const ret = try self.synthExprE(ce.body, ctx_opt); // Remove the parameter bindings (a closure's params do not collide // with outer locals in practice; a save/restore is a later refinement). @@ -7643,7 +7697,7 @@ pub const TypeChecker = struct { const ptype = self.namedTypeToResolved(p.type_node); const arg = self.arena.callArgForParam(call.args_start, call.args_len, call.names_start, i, p.name) orelse continue; const arg_t = try self.synthExprE(arg, ctx_opt); - if (ptype == .builtin and arg_t == .builtin and !try self.literalTypeFits(ptype.builtin, arg, arg_t.builtin)) { + if (!try self.valueFits(ptype, arg, arg_t)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(arg), "argument type does not match the parameter type of function '{s}'", .{self.arena.strings.slice(decl.name)}); } } @@ -7671,6 +7725,9 @@ pub const TypeChecker = struct { const arg = self.arena.callArgForParam(call.args_start, call.args_len, call.names_start, i, p.name) orelse continue; const arg_t = try self.synthExprE(arg, ctx_opt); try self.unifyGeneric(decl, p.type_node, arg_t, &subst, self.arena.exprSpan(arg)); + if (!try self.valueFits(self.namedTypeToResolved(p.type_node), arg, arg_t)) { + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(arg), "argument type does not match the parameter type of function '{s}'", .{self.arena.strings.slice(decl.name)}); + } } var gi: u32 = 0; @@ -7860,10 +7917,7 @@ pub const TypeChecker = struct { break :blk try self.synthExprE(flit.value, ctx_opt); }; if (declared) |d| { - if (d == .builtin and actual == .builtin and !try self.literalTypeFits(d.builtin, flit.value, actual.builtin)) { - try self.emit(.type_mismatch, .error_, self.arena.exprSpan(flit.value), "struct-literal field '{s}' value type does not match its declared type", .{self.arena.strings.slice(flit.name)}); - } - if (d == .struct_t and actual == .struct_t and d.struct_t != actual.struct_t) { + if (!try self.valueFits(d, flit.value, actual)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(flit.value), "struct-literal field '{s}' value type does not match its declared type", .{self.arena.strings.slice(flit.name)}); } } else { @@ -8075,7 +8129,7 @@ pub const TypeChecker = struct { } const arg: NodeId = @bitCast(self.arena.extra.items[mc.args_start]); const arg_t = try self.synthExprE(arg, ctx_opt); - if (arg_t == .builtin and arg_t.builtin != .string_) { + if (!builtinWhere(arg_t, isString)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(arg), "Entity method '{s}' expects a string extension name", .{method_slice}); } } @@ -8252,7 +8306,7 @@ pub const TypeChecker = struct { } else { const arg: NodeId = @bitCast(self.arena.extra.items[mc.args_start]); const arg_t = try self.synthExprE(arg, ctx_opt); - if (arg_t == .builtin and !try self.literalTypeFits(recv_t.array_dyn, arg, arg_t.builtin)) { + if (!try self.valueFits(.{ .builtin = recv_t.array_dyn }, arg, arg_t)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(arg), "pushed value type does not match the array element type", .{}); } } @@ -8292,10 +8346,10 @@ pub const TypeChecker = struct { const varg: NodeId = @bitCast(self.arena.extra.items[mc.args_start + 1]); const k_t = try self.synthExprE(karg, ctx_opt); const v_t = try self.synthExprE(varg, ctx_opt); - if (k_t == .builtin and !try self.literalTypeFits(recv_t.map_t.key, karg, k_t.builtin)) { + if (!try self.valueFits(.{ .builtin = recv_t.map_t.key }, karg, k_t)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(karg), "inserted key type does not match the map key type", .{}); } - if (v_t == .builtin and !try self.literalTypeFits(recv_t.map_t.value, varg, v_t.builtin)) { + if (!try self.valueFits(.{ .builtin = recv_t.map_t.value }, varg, v_t)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(varg), "inserted value type does not match the map value type", .{}); } } @@ -8325,7 +8379,7 @@ pub const TypeChecker = struct { } else { const arg: NodeId = @bitCast(self.arena.extra.items[mc.args_start]); const arg_t = try self.synthExprE(arg, ctx_opt); - if (arg_t == .builtin and !try self.literalTypeFits(recv_t.set_t, arg, arg_t.builtin)) { + if (!try self.valueFits(.{ .builtin = recv_t.set_t }, arg, arg_t)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(arg), "inserted item type does not match the set element type", .{}); } } @@ -8338,7 +8392,7 @@ pub const TypeChecker = struct { } else { const arg: NodeId = @bitCast(self.arena.extra.items[mc.args_start]); const arg_t = try self.synthExprE(arg, ctx_opt); - if (arg_t == .builtin and !try self.literalTypeFits(recv_t.set_t, arg, arg_t.builtin)) { + if (!try self.valueFits(.{ .builtin = recv_t.set_t }, arg, arg_t)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(arg), "item type does not match the set element type", .{}); } } @@ -8404,7 +8458,7 @@ pub const TypeChecker = struct { } else { const arg: NodeId = @bitCast(self.arena.extra.items[mc.args_start]); const t = self.synthExpr(arg, ctx_opt); - if (t == .builtin and !t.builtin.isInteger()) { + if (!builtinWhere(t, BuiltinType.isInteger)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(arg), "tick(n) requires an integer tick count", .{}); } } @@ -8631,7 +8685,7 @@ pub const TypeChecker = struct { const ptype = self.namedTypeToResolved(p.type_node); const arg = self.arena.callArgForParam(mc.args_start, mc.args_len, mc.names_start, i, p.name) orelse continue; const arg_t = try self.synthExprE(arg, ctx_opt); - if (ptype == .builtin and arg_t == .builtin and !try self.literalTypeFits(ptype.builtin, arg, arg_t.builtin)) { + if (!try self.valueFits(ptype, arg, arg_t)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(arg), "argument type does not match the parameter type of method '{s}'", .{self.arena.strings.slice(mc.method_name)}); } } @@ -8655,11 +8709,11 @@ pub const TypeChecker = struct { const idx_t = try self.synthExprE(ix.index, ctx_opt); switch (recv_t) { .array_fixed => |info| { - if (idx_t == .builtin and !idx_t.builtin.isInteger()) try self.emit(.type_mismatch, .error_, self.arena.exprSpan(ix.index), "array index must be an integer", .{}); + if (!builtinWhere(idx_t, BuiltinType.isInteger)) try self.emit(.type_mismatch, .error_, self.arena.exprSpan(ix.index), "array index must be an integer", .{}); return .{ .builtin = info.elem }; }, .array_dyn => |elem| { - if (idx_t == .builtin and !idx_t.builtin.isInteger()) try self.emit(.type_mismatch, .error_, self.arena.exprSpan(ix.index), "array index must be an integer", .{}); + if (!builtinWhere(idx_t, BuiltinType.isInteger)) try self.emit(.type_mismatch, .error_, self.arena.exprSpan(ix.index), "array index must be an integer", .{}); return .{ .builtin = elem }; }, .map_t => |mi| { @@ -8667,7 +8721,7 @@ pub const TypeChecker = struct { // the optional-returning accessor unlocked by the Optional // ops — lifts the earlier rejection. The key must fit the // map's key type. - if (idx_t == .builtin and !try self.literalTypeFits(mi.key, ix.index, idx_t.builtin)) { + if (!try self.valueFits(.{ .builtin = mi.key }, ix.index, idx_t)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(ix.index), "map index key type does not match the map key type", .{}); } return .{ .optional = mi.value }; @@ -8725,7 +8779,7 @@ pub const TypeChecker = struct { .literal => { const lit: NodeId = @bitCast(arm.pattern_payload); const lit_t = try self.synthExprE(lit, ctx_opt); - if (scrut_t == .builtin and lit_t == .builtin and !try self.literalTypeFits(scrut_t.builtin, lit, lit_t.builtin)) { + if (!try self.valueFits(scrut_t, lit, lit_t)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(lit), "match pattern literal type does not match the scrutinee type", .{}); } if (scrut_t == .builtin and scrut_t.builtin == .bool_ and self.arena.exprKind(lit) == .bool_lit) { @@ -8775,7 +8829,7 @@ pub const TypeChecker = struct { const body_t = try self.synthExprE(arm.body, ctx_opt); if (result_t == null) { result_t = body_t; - } else if (result_t.? == .builtin and body_t == .builtin and !ResolvedType.eql(result_t.?, body_t)) { + } else if (!try self.armsAgree(result_t.?, arm.body, body_t)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(arm.body), "match arms must all yield the same type", .{}); } } @@ -8860,7 +8914,7 @@ pub const TypeChecker = struct { // the default must fit the payload type; the result is the // unwrapped payload. if (lhs_t == .optional) { - if (rhs_t == .builtin and !try self.literalTypeFits(lhs_t.optional, bin.rhs, rhs_t.builtin)) { + if (!try self.valueFits(.{ .builtin = lhs_t.optional }, bin.rhs, rhs_t)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(bin.rhs), "'??' default type does not match the optional payload type", .{}); } return .{ .builtin = lhs_t.optional }; @@ -14384,3 +14438,165 @@ test "the reservation is DERIVED from the builtin table, not a second list" { try std.testing.expect(!isReservedEngineTypeName("GameTimer")); try std.testing.expect(!isReservedEngineTypeName("")); } + +const mistyped_base = + \\struct P { x: int = 0 } + \\struct R { y: int = 0 } + \\struct S { p: int = 0 } + \\resource Out { n: int = 0 } + \\event E { n: int = 0 } + \\fn id(x: int) -> int { x } + \\impl P { fn add(self, k: int) -> int { self.x + k } } + \\ +; + +const MistypedCase = struct { name: []const u8, src: []const u8, code: DiagnosticCode }; + +const mistyped_cases = [_]MistypedCase{ + .{ .name = "let, array as int", .src = "rule r() { let v: int = [1, 2, 3] }", .code = .type_mismatch }, + .{ .name = "let, int as struct", .src = "rule r() { let v: P = 1 }", .code = .type_mismatch }, + .{ .name = "let, one struct as another", .src = "rule r() { let v: P = R { y: 1 } }", .code = .type_mismatch }, + .{ .name = "if branches", .src = "rule r() {\n let c = true\n let v = if c { 0 } else { P { x: 1 } }\n}", .code = .type_mismatch }, + .{ .name = "assignment to a local", .src = "rule r() {\n let mut v = 0\n v = P { x: 1 }\n}", .code = .type_mismatch }, + .{ .name = "assignment to a field", .src = "rule r() when resource Out { get_mut(Out).n = P { x: 1 } }", .code = .type_mismatch }, + .{ .name = "fn argument", .src = "rule r() { let v = id(P { x: 1 }) }", .code = .type_mismatch }, + .{ .name = "fn body value", .src = "fn mk() -> int { P { x: 1 } }", .code = .return_type_mismatch }, + .{ .name = "return statement", .src = "fn mk() -> int { return P { x: 1 } }", .code = .return_type_mismatch }, + .{ .name = "method argument", .src = "rule r() {\n let p = P { x: 1 }\n let v = p.add(P { x: 2 })\n}", .code = .type_mismatch }, + .{ .name = "method body value", .src = "impl R { fn value(self) -> int { P { x: 1 } } }", .code = .return_type_mismatch }, + .{ .name = "closure argument", .src = "rule r() {\n let f = |k: int| k + 1\n let v = f(P { x: 1 })\n}", .code = .type_mismatch }, + .{ .name = "struct-literal field", .src = "rule r() { let s = S { p: P { x: 1 } } }", .code = .type_mismatch }, + .{ .name = "emit field", .src = "rule r() { emit E { n: P { x: 1 } } }", .code = .type_mismatch }, + .{ .name = "array push", .src = "rule r() {\n let mut a: int[] = [1]\n a.push(P { x: 1 })\n}", .code = .type_mismatch }, + .{ .name = "map insert key", .src = "rule r() {\n let mut m: [int: int] = [1: 2]\n m.insert(P { x: 1 }, 3)\n}", .code = .type_mismatch }, + .{ .name = "map insert value", .src = "rule r() {\n let mut m: [int: int] = [1: 2]\n m.insert(1, P { x: 1 })\n}", .code = .type_mismatch }, + .{ .name = "set insert", .src = "rule r() {\n let mut s = Set.from([1, 2])\n s.insert(P { x: 1 })\n}", .code = .type_mismatch }, + .{ .name = "set contains", .src = "rule r() {\n let s = Set.from([1, 2])\n let b = s.contains(P { x: 1 })\n}", .code = .type_mismatch }, + .{ .name = "map index", .src = "rule r() {\n let m = [1: 2]\n let v = m[P { x: 1 }]\n}", .code = .type_mismatch }, + .{ .name = "match literal against a struct", .src = "rule r() {\n let p = P { x: 1 }\n match p { 1 => { }, _ => { } }\n}", .code = .type_mismatch }, + .{ .name = "coalesce default", .src = "rule r() {\n let o: int? = some(1)\n let v = o ?? P { x: 1 }\n}", .code = .type_mismatch }, + .{ .name = "match arms", .src = "rule r() {\n let c = 1\n let v = match c { 0 => 1, _ => P { x: 1 } }\n}", .code = .type_mismatch }, + .{ .name = "cast operand", .src = "rule r() {\n let p = P { x: 1 }\n let v = p as int\n}", .code = .type_mismatch }, + .{ .name = "if condition", .src = "rule r() {\n let c = P { x: 1 }\n if c { let z = 1 }\n}", .code = .type_mismatch }, + .{ .name = "while condition", .src = "rule r() {\n let c = P { x: 1 }\n while c { let z = 2 }\n}", .code = .type_mismatch }, + .{ .name = "array index", .src = "rule r() {\n let a = [10, 20, 30]\n let v = a[P { x: 1 }]\n}", .code = .type_mismatch }, + .{ .name = "map literal value", .src = "rule r() { let m = [1: P { x: 1 }] }", .code = .type_mismatch }, + .{ .name = "map literal key", .src = "rule r() { let m = [P { x: 1 }: 2] }", .code = .type_mismatch }, + .{ .name = "concrete parameter of a generic fn", .src = "fn pick(a: T, n: int) -> T { a }\nrule r() { let v = pick(1, P { x: 1 }) }", .code = .type_mismatch }, + .{ .name = "assert_eq, int against an enum", .src = "enum Dir { up, down }\ntest \"t\" { assert_eq(1, Dir.up) }", .code = .type_mismatch }, + .{ .name = "extension name", .src = "component C { n: int = 0 }\nrule r(entity: Entity) when entity has C { entity.activate_extension(P { x: 1 }) }", .code = .type_mismatch }, + .{ .name = "tick count", .src = "test \"t\" {\n let w = test_world()\n w.tick(P { x: 1 })\n}", .code = .type_mismatch }, + .{ .name = "let, array as optional", .src = "rule r() { let o: int? = [1, 2, 3] }", .code = .type_mismatch }, + .{ .name = "let, map as array", .src = "rule r() { let a: int[] = [1: 2] }", .code = .type_mismatch }, + .{ .name = "data entry field, struct as enum", .src = "enum Dir { up, down }\nstruct Item { d: Dir }\ndata Db: Item { a: { d: P { x: 1 } } }", .code = .entry_field_type_invalid }, + .{ .name = "scene component field", .src = "component C { n: int = 0 }\nscene \"L\" { entity \"e\" { uuid: \"00000000-0000-0000-0000-000000000001\" C { n: P { x: 1 } } } }", .code = .scene_component_field_type_invalid }, + .{ .name = "field default, int as struct", .src = "struct T { q: P = 1 }", .code = .type_mismatch }, + .{ .name = "const, int as struct", .src = "const K: P = 1", .code = .type_mismatch }, + .{ .name = "data entry field, int as struct", .src = "struct Item { q: P }\ndata Db: Item { a: { q: 1 } }", .code = .entry_field_type_invalid }, +}; + +test "a composite where a builtin is declared, or the reverse, is refused at every gate" { + const gpa = std.testing.allocator; + var missed: usize = 0; + for (mistyped_cases) |c| { + const src = try std.mem.concat(gpa, u8, &.{ mistyped_base, c.src }); + defer gpa.free(src); + var r = try parseAndCheck(gpa, src); + defer r.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), r.parse_diags.len); + if (countMessage(r.diagnostics.items, c.code, "") == 0) { + missed += 1; + std.debug.print("not refused: {s}\n", .{c.name}); + } + } + try std.testing.expectEqual(@as(usize, 0), missed); +} + +test "well-typed values at the same gates are accepted" { + const gpa = std.testing.allocator; + var r = try parseAndCheck(gpa, mistyped_base ++ + \\fn ident(x: T) -> T { x } + \\fn mk() -> P { P { x: 1 } } + \\fn mk2() -> int { return 1 } + \\rule r() when resource Out { + \\ let a: int = 1 + \\ let b: P = P { x: 1 } + \\ let q: P = .{ x: 2 } + \\ let c = true + \\ let d = if c { 0 } else { 1 } + \\ let mut e = 0 + \\ e = 2 + \\ get_mut(Out).n = 3 + \\ let f = id(1) + \\ let g = b.add(1) + \\ let h = |k: int| k + 1 + \\ let i = h(1) + \\ let s = S { p: 1 } + \\ emit E { n: 1 } + \\ let mut arr: int[] = [1, 2] + \\ arr.push(3) + \\ let mut m: [int: int] = [1: 2] + \\ m.insert(2, 3) + \\ let mv = m[1] + \\ let mut st = Set.from([1, 2]) + \\ st.insert(3) + \\ let hit = st.contains(1) + \\ match b.x { 1 => { }, _ => { } } + \\ let o: int? = some(1) + \\ let o2: int? = none + \\ let co = o ?? 2 + \\ let gp = ident(P { x: 1 }) + \\ let gi: int = ident(1) + \\ let vc: Vec3 = [1, 2, 3] + \\ let vo: Vec3? = none + \\ let vd = vo ?? [0, 0, 0] + \\ let pick = |k: int| { + \\ if k > 3 { + \\ return 40 + \\ } + \\ k + \\ } + \\} + \\struct Range { + \\ min: T + \\ max: T + \\} + \\fn keep(tag: T) -> T { + \\ let rg = Range { min: 0, max: 10 } + \\ let digits = [1, 2] + \\ let at = digits[rg.min] + \\ tag + \\} + \\fn pick_or_none(x: int) -> int? { + \\ let pick = |k: int| { + \\ if k > 3 { + \\ return 40 + \\ } + \\ k + \\ } + \\ some(pick(x)) + \\} + \\component T3 { pos: Vec3 } + \\scene "Village" { entity "npc" { uuid: "7b3e2f1a-42a3-4f2b-8c9d-a3f2b1c98d4e" T3 { pos: [11, 0, 6] } } } + \\struct Spawn { at: Vec3 } + \\data Points: Spawn { origin: { at: [0, 0, 0] } } + ); + defer r.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), r.parse_diags.len); + for (r.diagnostics.items) |d| std.debug.print("{s} {s}\n", .{ d.code.code(), d.primary_message }); + try std.testing.expectEqual(@as(usize, 0), r.diagnostics.items.len); +} + +test "a bare literal match arm must have the earlier arm's builtin type" { + const gpa = std.testing.allocator; + var r = try parseAndCheck(gpa, + \\rule r() { + \\ let a: i32 = 1 + \\ let c = true + \\ let m = match c { true => a, false => 2 } + \\} + ); + defer r.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), r.parse_diags.len); + try std.testing.expectEqual(@as(usize, 1), countMessage(r.diagnostics.items, .type_mismatch, "match arms must all yield the same type")); +} diff --git a/tests/etch/import_resolve_test.zig b/tests/etch/import_resolve_test.zig index 57ff9ba9..c5a38222 100644 --- a/tests/etch/import_resolve_test.zig +++ b/tests/etch/import_resolve_test.zig @@ -300,3 +300,37 @@ test "a name an import binds is judged as its declaration is, position by positi } try std.testing.expectEqual(@as(usize, 0), differing); } + +test "a composite value for a field of an imported component is refused" { + const gpa = std.testing.allocator; + const files = [_]etch.ProjectFile{ + .{ .name = "lib.etch", .source = "component C { n: int = 0 }" }, + .{ .name = "level.scene.etch", .source = + \\import lib { C } + \\scene "L" { entity "e" { uuid: "00000000-0000-0000-0000-000000000001" C { n: [1, 2] } } } + }, + }; + 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, .scene_component_field_type_invalid)); +} + +test "one component under two local names is one type" { + const gpa = std.testing.allocator; + const files = [_]etch.ProjectFile{ + .{ .name = "lib.etch", .source = "component Health { current: float = 100.0 }" }, + .{ .name = "main.etch", .source = + \\import lib { Health } + \\import lib { Health as HP } + \\fn cur(h: Health) -> float { h.current } + \\rule r(entity: Entity) when entity has HP { + \\ let c = cur(entity.get(HP)) + \\} + }, + }; + var diags: std.ArrayListUnmanaged(etch.Diagnostic) = .empty; + defer deinitDiags(gpa, &diags); + try etch.validateProject(gpa, &files, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); +} diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 0764539c..b0b016c0 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2777, - else => 2779, + .windows => 2782, + else => 2784, }; } From 6b40f229dc26cad9f759cf1c06a08ffe958f73ca Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 28 Sep 2026 15:04:16 +0200 Subject: [PATCH 129/141] fix(etch): type every value breaking out of a loop A loop took the type of its first top-level break, so a break nested in an if was never compared: `let v: int = loop { if c { break P { x: 1 } } break 1 }` checked clean. The checker now keeps the loops a break can target: an unlabeled break leaves the innermost loop of any kind, a labeled one the loop carrying its label, and a task or timer body opens a new window. Every value breaking out of one loop must agree with the first, and the loop takes that type. A closure body is checked at its call, inside the caller's loops, which a break in it leaves at runtime. Floor 2784 -> 2787 / 2785, read from the suite. Co-Authored-By: Claude Opus 5.5 --- src/etch/types.zig | 134 ++++++++++++++++++++++++++++++--- tools/weld_lint/dead_tests.zig | 4 +- 2 files changed, 126 insertions(+), 12 deletions(-) diff --git a/src/etch/types.zig b/src/etch/types.zig index 5c734f43..e8663288 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -596,6 +596,12 @@ pub const TypeChecker = struct { /// Start of the innermost branch's label window in `conc_labels`. Saved/restored at /// branch entry. conc_labels_base: usize = 0, + /// The loops a `break` can target, innermost last. Only the window past + /// `break_base` belongs to the innermost task or timer body; a closure body + /// is checked at its call, inside the caller's loops, which a `break` in it + /// leaves. + break_frames: std.ArrayListUnmanaged(BreakFrame) = .empty, + break_base: usize = 0, /// Names visible at the entry of the innermost scope-snapshot body, in /// `escape_names[escape_base..]`: a name referenced there and not declared /// there is a capture (`etch-resolver-types.md` §8.2, E0223). @@ -759,6 +765,7 @@ pub const TypeChecker = struct { self.methods.deinit(self.gpa); self.trait_impls.deinit(self.gpa); self.conc_labels.deinit(self.gpa); + self.break_frames.deinit(self.gpa); self.escape_names.deinit(self.gpa); self.generic_scope.deinit(self.gpa); self.imported_symbols.deinit(self.gpa); @@ -4864,6 +4871,14 @@ pub const TypeChecker = struct { return x.arena == y.arena and x.name == y.name; } + /// A loop a `break` can target; `yields` for a `loop`, whose value is its + /// breaks'. + const BreakFrame = struct { + label: StringId, + yields: bool, + value_t: ?ResolvedType = null, + }; + const Declaration = struct { arena: *const AstArena, name: StringId }; fn declarationOf(self: *TypeChecker, t: ResolvedType) ?Declaration { @@ -4875,8 +4890,21 @@ pub const TypeChecker = struct { }; } - /// Whether a match arm's type agrees with the earlier arms': two builtins - /// only when equal, an arm body being a bare literal as often as not. + /// The loop an unlabeled `break` leaves is the innermost one of any kind, + /// a labeled one the `loop` carrying its label. + fn breakTarget(self: *TypeChecker, label: StringId) ?*BreakFrame { + const frames = self.break_frames.items[self.break_base..]; + var i = frames.len; + while (i > 0) { + i -= 1; + if (label == 0 or frames[i].label == label) return &frames[i]; + } + return null; + } + + /// Whether a match arm's or a break value's type agrees with the earlier + /// ones': two builtins only when equal, such a value being a bare literal + /// as often as not. fn armsAgree(self: *TypeChecker, earlier: ResolvedType, body: NodeId, body_t: ResolvedType) !bool { if (earlier == .builtin and body_t == .builtin) return ResolvedType.eql(earlier, body_t); return self.valueFits(earlier, body, body_t); @@ -6177,6 +6205,8 @@ pub const TypeChecker = struct { // fires only on the boundary-crossing ones). self.conc_loop_depth += 1; defer self.conc_loop_depth -= 1; + try self.break_frames.append(self.gpa, .{ .label = 0, .yields = false }); + defer _ = self.break_frames.pop(); // The ITERATOR is what survives a suspension here, and it is // nobody's local — `x` is the element, typically an `int`. const iter_retained = iteratorRetainedAcrossSuspension(iter_t); @@ -6205,6 +6235,8 @@ pub const TypeChecker = struct { // in-branch loop, mirror of the `for` arm. self.conc_loop_depth += 1; defer self.conc_loop_depth -= 1; + try self.break_frames.append(self.gpa, .{ .label = 0, .yields = false }); + defer _ = self.break_frames.pop(); var i: u32 = 0; while (i < wh.body_len) : (i += 1) { try self.checkStmt(ctx, @bitCast(self.arena.extra.items[wh.body_start + i])); @@ -6212,10 +6244,19 @@ pub const TypeChecker = struct { if (wh.let_binding != 0) _ = ctx.locals.remove(wh.let_binding); }, .break_stmt => { - // `break [label] [value]`. Type the value if - // present; loop-membership / label validity is permissive. + // `break [label] [value]`. Loop membership and label validity + // are permissive. const b = self.arena.break_stmts.items[data]; - if (!b.value.isNone()) _ = self.synthExpr(b.value, ctx); + if (!b.value.isNone()) { + const t = self.synthExpr(b.value, ctx); + if (self.breakTarget(b.label)) |frame| { + if (frame.yields) { + if (frame.value_t) |earlier| { + if (!try self.armsAgree(earlier, b.value, t)) try self.emit(.type_mismatch, .error_, self.arena.exprSpan(b.value), "break values of a loop must all have the same type", .{}); + } else frame.value_t = t; + } + } + } // E0907: the break must not cross the enclosing // concurrency-branch task boundary (§9.2). try self.checkConcControlFlow(stmt_id, b.label, "break"); @@ -6461,6 +6502,8 @@ pub const TypeChecker = struct { self.conc_branch = null; self.conc_loop_depth = 0; self.conc_labels_base = self.conc_labels.items.len; + const saved_break_base = self.break_base; + self.break_base = self.break_frames.items.len; const esc = try self.openEscapeWindow(ctx, .timer); defer { self.closeEscapeWindow(esc); @@ -6469,6 +6512,7 @@ pub const TypeChecker = struct { self.conc_loop_depth = saved_depth; self.arena_iter_depth = saved_iter; self.conc_labels_base = saved_base; + self.break_base = saved_break_base; } var i: u32 = 0; while (i < len) : (i += 1) { @@ -6705,6 +6749,8 @@ pub const TypeChecker = struct { self.conc_branch = kind; self.conc_loop_depth = 0; self.conc_labels_base = self.conc_labels.items.len; + const saved_break_base = self.break_base; + self.break_base = self.break_frames.items.len; const esc = try self.openEscapeWindow(ctx, switch (kind) { .race => .race_branch, .sync => .sync_branch, @@ -6717,6 +6763,7 @@ pub const TypeChecker = struct { self.conc_loop_depth = saved_depth; self.arena_iter_depth = saved_iter; self.conc_labels_base = saved_base; + self.break_base = saved_break_base; } try self.checkStmt(ctx, stmt); } @@ -6737,6 +6784,8 @@ pub const TypeChecker = struct { self.conc_branch = kind; self.conc_loop_depth = 0; self.conc_labels_base = self.conc_labels.items.len; + const saved_break_base = self.break_base; + self.break_base = self.break_frames.items.len; const esc = try self.openEscapeWindow(ctx, switch (kind) { .race => .race_branch, .sync => .sync_branch, @@ -6749,6 +6798,7 @@ pub const TypeChecker = struct { self.conc_loop_depth = saved_depth; self.arena_iter_depth = saved_iter; self.conc_labels_base = saved_base; + self.break_base = saved_break_base; } var i: u32 = 0; while (i < len) : (i += 1) { @@ -7258,11 +7308,9 @@ pub const TypeChecker = struct { return .{ .map_t = .{ .key = key_bt.?, .value = val_bt.? } }; } - /// Type a `loop { body }` expression. The body statements - /// are checked, and the loop's value is the type of a top-level `break` - /// value (permissive: `unknown` when none — a labeled break out of a nested - /// loop is typed only through execution, the interpreter being the - /// reference; the assignment site treats `unknown` as a wildcard). + /// The loop's value is the type of the first value breaking out of it, + /// `unknown` when none; without a rule context only top-level breaks are + /// read. fn synthLoop(self: *TypeChecker, data: u32, ctx_opt: ?*RuleCtx) TypeError!ResolvedType { const lp = self.arena.loop_exprs.items[data]; if (ctx_opt) |ctx| { @@ -7276,11 +7324,14 @@ pub const TypeChecker = struct { defer if (lp.label != 0) { _ = self.conc_labels.pop(); }; + try self.break_frames.append(self.gpa, .{ .label = lp.label, .yields = true }); + defer _ = self.break_frames.pop(); var i: u32 = 0; while (i < lp.body_len) : (i += 1) { const stmt: NodeId = @bitCast(self.arena.extra.items[lp.body_start + i]); try self.checkStmt(ctx, stmt); } + return self.break_frames.getLast().value_t orelse ResolvedType.unknown; } var i: u32 = 0; while (i < lp.body_len) : (i += 1) { @@ -14457,6 +14508,8 @@ const mistyped_cases = [_]MistypedCase{ .{ .name = "let, int as struct", .src = "rule r() { let v: P = 1 }", .code = .type_mismatch }, .{ .name = "let, one struct as another", .src = "rule r() { let v: P = R { y: 1 } }", .code = .type_mismatch }, .{ .name = "if branches", .src = "rule r() {\n let c = true\n let v = if c { 0 } else { P { x: 1 } }\n}", .code = .type_mismatch }, + .{ .name = "loop broken inside an if", .src = "rule r() {\n let c = true\n let v: int = loop {\n if c { break P { x: 1 } }\n }\n}", .code = .type_mismatch }, + .{ .name = "loop broken from a closure", .src = "rule r() {\n let v: int = loop {\n let f = |k: int| {\n if k > 0 {\n break P { x: 1 }\n }\n k\n }\n let z = f(1)\n break 1\n }\n}", .code = .type_mismatch }, .{ .name = "assignment to a local", .src = "rule r() {\n let mut v = 0\n v = P { x: 1 }\n}", .code = .type_mismatch }, .{ .name = "assignment to a field", .src = "rule r() when resource Out { get_mut(Out).n = P { x: 1 } }", .code = .type_mismatch }, .{ .name = "fn argument", .src = "rule r() { let v = id(P { x: 1 }) }", .code = .type_mismatch }, @@ -14600,3 +14653,64 @@ test "a bare literal match arm must have the earlier arm's builtin type" { try std.testing.expectEqual(@as(usize, 0), r.parse_diags.len); try std.testing.expectEqual(@as(usize, 1), countMessage(r.diagnostics.items, .type_mismatch, "match arms must all yield the same type")); } + +test "two values breaking out of one loop must have one builtin type" { + const gpa = std.testing.allocator; + var r = try parseAndCheck(gpa, + \\rule r() { + \\ let a: i32 = 1 + \\ let c = true + \\ loop { + \\ if c { break a } + \\ break 2 + \\ } + \\} + ); + defer r.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), r.parse_diags.len); + try std.testing.expectEqual(@as(usize, 1), countMessage(r.diagnostics.items, .type_mismatch, "break values of a loop must all have the same type")); +} + +test "a value breaking out of a while, an inner loop or to a label does not type the enclosing loop" { + const gpa = std.testing.allocator; + var r = try parseAndCheck(gpa, + \\struct P { x: int = 0 } + \\rule r() { + \\ let c = true + \\ let v: int = loop { + \\ while c { break P { x: 1 } } + \\ let w = loop { break P { x: 1 } } + \\ break 1 + \\ } + \\ outer: loop { + \\ let lv: int = loop { + \\ if c { break outer 2.5 } + \\ break 1 + \\ } + \\ break + \\ } + \\} + ); + defer r.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), r.parse_diags.len); + try std.testing.expectEqual(@as(usize, 0), r.diagnostics.items.len); +} + +test "a break refused at a task boundary types no loop beyond it" { + const gpa = std.testing.allocator; + var r = try parseAndCheck(gpa, + \\struct P { x: int = 0 } + \\async rule r() { + \\ let v: int = loop { + \\ branch { + \\ break P { x: 1 } + \\ } + \\ break 1 + \\ } + \\} + ); + defer r.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), r.parse_diags.len); + try std.testing.expectEqual(@as(usize, 1), r.diagnostics.items.len); + try std.testing.expectEqual(DiagnosticCode.control_flow_escapes_task_branch, r.diagnostics.items[0].code); +} diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index b0b016c0..04f033af 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2782, - else => 2784, + .windows => 2785, + else => 2787, }; } From d82633a075ceef61de2843cf22fcc494bb79838d Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 28 Sep 2026 15:04:19 +0200 Subject: [PATCH 130/141] docs(brief): record the third axis Co-Authored-By: Claude Opus 5.5 --- briefs/m1.d-phase-1-debt.md | 101 ++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index f61c0f4f..babc8d54 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -7333,6 +7333,107 @@ by the review. CI section carries no budget. As long as pull requests run cold, the budget grows with the suite, and that regime is `engine-platform.md`'s design. +### S5/G9 duovicies — the third axis, a composite where a builtin is declared + +The CI of `06e4a567` was green on its 18 jobs, and the Windows floor 2777 was read on the +four cells. `engine-platform.md` is corrected (`sha256 11574acb…`): §8 now carries the +rule that sizes a budget and describes `ci_verdict` on the fast path, which closes the +item raised at unvicies. + +**Run before it was fixed.** The five forms of the axis — `let v: int = [1, 2, 3]`, +`if c { 0 } else { P { x: 1 } }`, `some(P { x: 1 }) ?? 0`, a struct passed to an `int` +parameter, `v = P { x: 1 }` on `let mut v = 0` — each gave 0 diagnostics at `check`, a +runtime error at every tick, and no crash. The cause: every gate compared two builtins +and let anything else through. + +- **One predicate, `valueFits`, read at every gate that compares a value with its + destination.** These are the `let` annotation, both assignments, a fn or method body + value and `return`, and the fn, method, closure and generic arguments. Then struct + literal fields, event field runs, filters, consts, field defaults, component instance + fields (local and foreign), `push`, `insert`, `contains`, a map index, match literals, + the `??` default, the `if` branches and the match arms. The rules: + - two builtins by the literal rule; + - a `Vec3` from an array of three numbers; + - a builtin and a composite never; + - two composites of different kinds only when both are arrays; + - a struct, enum, component, resource or event only as the same declaration, so an + alias names the component it imports; + - `unknown` and a generic fit anything. +- **The places that require a builtin** refuse a composite through `builtinWhere`: the + `while` and `if` conditions, the `race`/`sync` guards, a cast operand, the `tick_until` + timeout, `tick(n)`, an extension name, an array index. A map literal refuses a + non-builtin key or value, and `assert_eq` compares two composites by type. +- **Witnesses.** A table of 42 mistyped cases, each with the code it must raise, and a + control that runs the well-typed forms through the same gates and expects 0 diagnostics. + On the base, 40 of the 40 cases then in the table were not refused and the control was + green. The first version turned the table green and the control red on five false + refusals: a `Vec3` from an array (`E1764`, `E1785`), a generic struct's fields (`E0200` + ×2), and a `return` inside a closure judged against the enclosing fn (`E0204`). The + review then found that an aliased import made two types of one component. Each fault was + fixed with a control line of its own, and the alias with a test in + `import_resolve_test.zig`. +- **Final counter-factuals, alone in the worktree, predictions first.** Each of these + turned red exactly as predicted: + - without the generic pass, the control falls on the generic struct; + - without the `Vec3` rule, on the four `Vec3` forms; + - without the closure's `return` reset, on `E0204`; + - with any two composites fitting, on two table cases; + - with no two-array exception, on the control and nine inline tests; + - with name equality in place of declaration identity, on the alias test; + - with a generic that fails `builtinWhere`, on the generic index; + - without the map-literal refusal, on its two cases. +- **One counter-factual stayed green.** Match arms compared by `valueFits` alone left the + suite green, so the equality `armsAgree` keeps for two builtins had no witness. The base + compared arms by equality. `if` branches are blocks, never literals, so `valueFits` + reduces to equality there. A match arm can be a bare literal, and `valueFits` would let + `2` through after an `i32` arm. A witness now pins it, and it goes red without the line. +- **A sixth path, found while working: break values.** A `loop` took the type of + its first top-level `break`, so a `break` nested in an `if` was never compared. Six + probes gave 0 diagnostics on the base. The checker now keeps the loops a `break` can + target: + - an unlabeled `break` leaves the innermost loop of any kind; + - a labeled one leaves the `loop` carrying its label; + - a task or timer body starts a new window; + - every value breaking out of one `loop` must agree with the first. + - **I first put a boundary at the closure call too, and the interpreter refuted it.** A + `break` in a closure body leaves the caller's loop, measured: a loop breaking `1` + after a closure that breaks `7` yields 7. The closure body is checked at its call, + inside those loops, so the boundary contradicted execution. It is removed, and the + fact is written at `break_frames`. + - Seven counter-factuals turned red one test each, as predicted: + - the old top-level scan; + - the agreement check removed; + - the closure boundary put back; + - the task boundaries removed; + - no frame for `for` and `while`; + - labels ignored; + - agreement by `valueFits`. + - On the base, the new tests fail where predicted: the two loop cases of the table + and the agreement test. +- **Raised, untouched**, each measured: + - **Scoping.** The checker and the interpreter both keep one flat map per body, where + `etch-reference-part1.md` §4.2–4.3 scopes by block. A block's `let` outlives its + block, a same-scope re-`let` is accepted, and a `let` in a timer or branch body + re-types the parent's local. `if let` removes its binding without restoring the one it + shadowed, and a closure's parameters are not restored. + - `some(P { x: 1 }) ?? 0` still passes. The checker's optional carries only a builtin + payload, so an optional of a composite is `unknown`. + - A scene's resource field values have no type diagnostic. + - A service call's arguments are checked by name and arity only. + - Imported items: + - a struct literal of an imported struct reads `E0102`; + - a call to an imported fn fails; + - a match on an imported enum is judged non-exhaustive; + - a wrong variant of an imported enum passes. + - Consts: a lowercase const reads `E0102`, and an uppercase name in expression position + is `unknown`, defined or not. + - The checker has no unit type. So `let v: int = loop { if c { break } break 1 }` + passes and fails at runtime. + - A `break` in a closure body leaving the caller's loop is a semantics to rule. + +The floor moves 2779 → 2787, read from the suite: 2770 / 2787 (17 skipped), 329/329 +steps, and the closure agrees. Windows is 2785, derived until CI reads it. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree From c636164a902df9154e018f9838666b9c2bc73b91 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 29 Sep 2026 01:40:56 +0200 Subject: [PATCH 131/141] fix(etch): refuse a break or continue that leaves its closure A closure body was checked only at its calls, against the caller's loops, and the interpreter passed its break or continue on to the caller's loop, so a closure could break a loop it was never written in, and one never called was never checked at all. The parser now records every break or continue whose target lies outside its closure body, and the checker refuses each once as E0911 ControlFlowEscapesClosure, called or not. At a call the body is no longer judged against the caller's loops or task branch, which also lifts a false E0906 on a closure's own return in a sync branch. At run time a jump that reaches the end of a closure fails with its own error kind. Floor 2787 -> 2791 / 2789, read from the suite. Co-Authored-By: Claude Opus 5.5 --- src/etch/ast.zig | 4 + src/etch/diagnostics.zig | 3 + src/etch/interp.zig | 105 ++++++++++--- src/etch/parser.zig | 43 ++++- src/etch/types.zig | 278 ++++++++++++++++++++++++++++++++- src/etch/value.zig | 3 + tools/weld_lint/dead_tests.zig | 4 +- 7 files changed, 409 insertions(+), 31 deletions(-) diff --git a/src/etch/ast.zig b/src/etch/ast.zig index a0afc42c..e36445bf 100644 --- a/src/etch/ast.zig +++ b/src/etch/ast.zig @@ -2756,6 +2756,9 @@ pub const AstArena = struct { measure_exprs: std.ArrayListUnmanaged(MeasureExpr) = .empty, if_exprs: std.ArrayListUnmanaged(IfExpr) = .empty, break_stmts: std.ArrayListUnmanaged(BreakStmt) = .empty, + /// The `break` and `continue` statements that target a loop outside their + /// closure body (`etch-reference-part1.md` §7.7). + closure_escapes: std.ArrayListUnmanaged(NodeId) = .empty, throw_stmts: std.ArrayListUnmanaged(ThrowStmt) = .empty, try_catch_stmts: std.ArrayListUnmanaged(TryCatchStmt) = .empty, emit_stmts: std.ArrayListUnmanaged(EmitStmt) = .empty, @@ -2977,6 +2980,7 @@ pub const AstArena = struct { self.measure_exprs.deinit(gpa); self.if_exprs.deinit(gpa); self.break_stmts.deinit(gpa); + self.closure_escapes.deinit(gpa); self.throw_stmts.deinit(gpa); self.try_catch_stmts.deinit(gpa); self.emit_stmts.deinit(gpa); diff --git a/src/etch/diagnostics.zig b/src/etch/diagnostics.zig index a01ade1a..e33a94a5 100644 --- a/src/etch/diagnostics.zig +++ b/src/etch/diagnostics.zig @@ -390,6 +390,7 @@ pub const DiagnosticCode = enum { event_not_entity_scoped, // E0908 EventNotEntityScoped (`await entity_event(e, T)` where T has no `Entity` field) ambiguous_event_entity_target, // E0909 AmbiguousEventEntityTarget (T has multiple `Entity` fields with no `@entity_target`) measure_outside_test, // E0910 MeasureOutsideTest (`measure { … }` outside a test body; wall-clock stays out of deterministic gameplay) + control_flow_escapes_closure, // E0911 ControlFlowEscapesClosure (`break`/`continue` in a closure body targeting a loop outside it) // ── Declaration files `.d.etch` (900-E1919, `etch-validation-ecs.md` §28, // `etch-grammar.md` §20). The E19xx block was empty before this milestone. The two @@ -618,6 +619,7 @@ pub const DiagnosticCode = enum { .event_not_entity_scoped => "E0908", .ambiguous_event_entity_target => "E0909", .measure_outside_test => "E0910", + .control_flow_escapes_closure => "E0911", .declaration_file_body_not_allowed => "E1900", .construct_not_allowed_in_declaration_file => "E1901", .typed_extension_mismatch => "E0858", @@ -831,6 +833,7 @@ pub const DiagnosticCode = enum { .event_not_entity_scoped => "EventNotEntityScoped", .ambiguous_event_entity_target => "AmbiguousEventEntityTarget", .measure_outside_test => "MeasureOutsideTest", + .control_flow_escapes_closure => "ControlFlowEscapesClosure", .declaration_file_body_not_allowed => "DeclarationFileBodyNotAllowed", .construct_not_allowed_in_declaration_file => "ConstructNotAllowedInDeclarationFile", .typed_extension_mismatch => "TypedExtensionMismatch", diff --git a/src/etch/interp.zig b/src/etch/interp.zig index d56a45f8..d503dce7 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -5472,13 +5472,26 @@ pub const Interpreter = struct { var cap_it = self.closures.list.items[handle].captured.iterator(); while (cap_it.next()) |e| try frame.put(self.gpa, e.key_ptr.*, e.value_ptr.*, false); const result = try self.evalExpr(world, &frame, ce.body); + return self.leaveClosure(result, node); + } + + /// A closure body ends at its call, never in the enclosing fn: a `return` + /// becomes the call's value, a throw keeps propagating as unit, and a + /// `break` or `continue` still set has no loop to reach + /// (`etch-reference-part1.md` §7.7). + fn leaveClosure(self: *Interpreter, result: Value, node: NodeId) StmtError!Value { if (self.returning) { self.returning = false; const rv = self.return_value; self.return_value = .{ .unit = {} }; return rv; } - if (self.thrown or self.control != .none) return Value{ .unit = {} }; + if (self.thrown) return Value{ .unit = {} }; + if (self.control != .none) { + self.control = .none; + self.control_label = 0; + return self.fail(.ControlFlowEscapesClosure, self.ast.exprSpan(node)); + } return result; } @@ -5503,17 +5516,14 @@ pub const Interpreter = struct { }; world.tickBoundary(); const r = try self.callZeroArgClosure(world, pred); - // A predicate `throw` / control signal stops the loop and surfaces as - // the test failure — do NOT keep ticking on a - // latched throw. `callZeroArgClosure` consumes `return`; only a throw - // or a stray control signal can remain set here. + // A predicate `throw` stops the loop and surfaces as the test + // failure — do NOT keep ticking on a latched throw. if (self.thrown) { self.thrown = false; self.pending_error = .{ .kind = .UncaughtThrow, .span = self.thrown_span }; self.pending_message = null; return error.RuntimeFailure; } - if (self.control != .none) return error.RuntimeFailure; if (r == .bool_ and r.bool_) return Value{ .bool_ = true }; } return Value{ .bool_ = false }; @@ -6774,22 +6784,8 @@ pub const Interpreter = struct { const av = try self.evalExpr(world, locals, arg); try frame.put(self.gpa, p.name, av, false); } - // The closure call boundary consumes `returning`: a `return` - // inside the - // body exits the CLOSURE — it becomes the call's value — never - // the enclosing fn. Same boundary-consume as `callFn` / - // `callMethod`; `thrown` and `break`/`continue` keep - // propagating (the enclosing try / loop interprets them) and - // the call yields unit. const result = try self.evalExpr(world, &frame, ce.body); - if (self.returning) { - self.returning = false; - const rv = self.return_value; - self.return_value = .{ .unit = {} }; - return rv; - } - if (self.thrown or self.control != .none) return Value{ .unit = {} }; - return result; + return self.leaveClosure(result, node); }, .struct_lit => { const sl = self.ast.struct_lits.items[data]; @@ -7114,6 +7110,7 @@ fn defaultFailureMessage(kind: RuntimeErrorKind) []const u8 { .AssertFailed => "assertion failed", .StaleComponentRef => "component ref outlived its entity", .ExtensionOpFailed => "extension activation or deactivation failed", + .ControlFlowEscapesClosure => "a break or continue left its closure", }; } @@ -18125,3 +18122,69 @@ test "newRunString frees the bytes it takes on an allocation failure" { failing.fail_index = failing.alloc_index; try std.testing.expectError(error.OutOfMemory, interp.newRunString(bytes)); } + +test "a break that leaves its closure fails the rule and does not break the caller's loop" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + const source = + \\resource Out { n: int = 0 } + \\rule r() when resource Out { + \\ let v = loop { + \\ let f = |k: int| { + \\ if k > 0 { + \\ break 7 + \\ } + \\ k + \\ } + \\ let z = f(1) + \\ break 1 + \\ } + \\ get_mut(Out).n = v + \\} + ; + var pr = try parser_mod.parse(gpa, source); + defer pr.deinit(gpa); + try std.testing.expect(pr.diagnostics.len == 0); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const report = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 1), report.runtime_errors); + const le = report.last_error orelse return error.TestExpectedTypedError; + try std.testing.expectEqual(RuntimeErrorKind.ControlFlowEscapesClosure, le.kind); + try std.testing.expect(std.mem.startsWith(u8, source[le.span.byte_start..le.span.byte_end], "|k: int|")); + try std.testing.expectEqual(@as(i64, 0), readResourceIntNamed(&world, "Out", "n")); +} + +test "a continue that leaves its closure fails the rule and does not continue the caller's loop" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + const source = + \\resource Out { n: int = 0 } + \\rule r() when resource Out { + \\ let mut total = 0 + \\ for i in 0..3 { + \\ let f = |k: int| { + \\ if k > 0 { + \\ continue + \\ } + \\ k + \\ } + \\ let z = f(i) + \\ total = total + 1 + \\ } + \\ get_mut(Out).n = total + 10 + \\} + ; + var pr = try parser_mod.parse(gpa, source); + defer pr.deinit(gpa); + try std.testing.expect(pr.diagnostics.len == 0); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const report = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 1), report.runtime_errors); + const le = report.last_error orelse return error.TestExpectedTypedError; + try std.testing.expectEqual(RuntimeErrorKind.ControlFlowEscapesClosure, le.kind); + try std.testing.expectEqual(@as(i64, 0), readResourceIntNamed(&world, "Out", "n")); +} diff --git a/src/etch/parser.zig b/src/etch/parser.zig index 03808035..ca919033 100644 --- a/src/etch/parser.zig +++ b/src/etch/parser.zig @@ -315,6 +315,12 @@ pub const Parser = struct { /// resolves the `break [IDENT] [expression]` ambiguity without a statement /// separator (an IDENT that is not an active label starts the break value). active_labels: std.ArrayListUnmanaged(StringId) = .empty, + /// Loops open since the innermost closure body began, of any kind. + loop_depth: u32 = 0, + closure_depth: u32 = 0, + /// Start, in `active_labels`, of the labels the innermost closure body + /// opened itself. + closure_label_base: usize = 0, /// When true, a bare `TYPE_IDENT {` is NOT parsed as a struct literal. Set /// while parsing the head expression of `if` / `while` / /// `for` / `match` (where the `{` opens the body / arms, not a struct @@ -342,6 +348,19 @@ pub const Parser = struct { return false; } + /// Whether a jump to `label`, `0` for the innermost loop, leaves the closure + /// body it is written in. + fn jumpLeavesClosure(self: *const Parser, label: StringId) bool { + if (self.closure_depth == 0) return false; + if (label == 0) return self.loop_depth == 0; + var i = self.active_labels.items.len; + while (i > 0) { + i -= 1; + if (self.active_labels.items[i] == label) return i < self.closure_label_base; + } + return false; + } + /// Whether `kind` can begin an expression — used to decide if an optional /// break value follows. fn canStartExpr(kind: TokenKind) bool { @@ -5603,6 +5622,8 @@ pub const Parser = struct { _ = try self.expect(.kw_in, "expected 'in' in for loop"); const iterable = try self.parseExprNoStruct(0); _ = try self.expect(.lbrace, "expected '{' to open for body"); + self.loop_depth += 1; + defer self.loop_depth -= 1; const body = try self.parseStmtRun(); const closing = try self.expect(.rbrace, "expected '}' to close for body"); return try self.arena.addForStmt(self.gpa, .{ @@ -5633,6 +5654,8 @@ pub const Parser = struct { } const cond = try self.parseExprNoStruct(0); _ = try self.expect(.lbrace, "expected '{' to start the while body"); + self.loop_depth += 1; + defer self.loop_depth -= 1; const body = try self.parseStmtRun(); const closing = try self.expect(.rbrace, "expected '}' to close the while body"); return try self.arena.addWhileStmt(self.gpa, .{ @@ -5650,6 +5673,8 @@ pub const Parser = struct { const kw_span = (try self.advance()).span; // 'loop' _ = try self.expect(.lbrace, "expected '{' to start loop body"); if (label != 0) try self.active_labels.append(self.gpa, label); + self.loop_depth += 1; + defer self.loop_depth -= 1; const body = try self.parseStmtRun(); if (label != 0) _ = self.active_labels.pop(); const closing = try self.expect(.rbrace, "expected '}' to close loop body"); @@ -5695,7 +5720,9 @@ pub const Parser = struct { value = try self.parseExpr(0); end_byte = self.arena.exprSpan(value).byte_end; } - return try self.arena.addBreakStmt(self.gpa, label, value, .{ .byte_start = kw.span.byte_start, .byte_end = end_byte }); + const stmt = try self.arena.addBreakStmt(self.gpa, label, value, .{ .byte_start = kw.span.byte_start, .byte_end = end_byte }); + if (self.jumpLeavesClosure(label)) try self.arena.closure_escapes.append(self.gpa, stmt); + return stmt; } /// Parse `continue [label]` (loop/break, `etch-grammar.md` §633). @@ -5711,7 +5738,9 @@ pub const Parser = struct { end_byte = lt.span.byte_end; } } - return try self.arena.addContinueStmt(self.gpa, label, .{ .byte_start = kw.span.byte_start, .byte_end = end_byte }); + const stmt = try self.arena.addContinueStmt(self.gpa, label, .{ .byte_start = kw.span.byte_start, .byte_end = end_byte }); + if (self.jumpLeavesClosure(label)) try self.arena.closure_escapes.append(self.gpa, stmt); + return stmt; } /// Parse `throw expression` (error handling, `etch-grammar.md` §641). @@ -6693,6 +6722,16 @@ pub const Parser = struct { } } _ = try self.expect(.pipe, "expected '|' to close closure parameters"); + const saved_depth = self.loop_depth; + const saved_base = self.closure_label_base; + self.loop_depth = 0; + self.closure_label_base = self.active_labels.items.len; + self.closure_depth += 1; + defer { + self.loop_depth = saved_depth; + self.closure_label_base = saved_base; + self.closure_depth -= 1; + } const body = try self.parseExpr(0); const body_span = self.arena.exprSpan(body); return try self.arena.addClosure(self.gpa, params.items, body, .{ diff --git a/src/etch/types.zig b/src/etch/types.zig index e8663288..44a7332c 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -597,9 +597,7 @@ pub const TypeChecker = struct { /// branch entry. conc_labels_base: usize = 0, /// The loops a `break` can target, innermost last. Only the window past - /// `break_base` belongs to the innermost task or timer body; a closure body - /// is checked at its call, inside the caller's loops, which a `break` in it - /// leaves. + /// `break_base` belongs to the innermost task, timer or closure body. break_frames: std.ArrayListUnmanaged(BreakFrame) = .empty, break_base: usize = 0, /// Names visible at the entry of the innermost scope-snapshot body, in @@ -901,6 +899,7 @@ pub const TypeChecker = struct { try self.checkDeclarationFileConstructs(); try self.checkTypedExtension(); try self.checkLiteralRanges(); + try self.checkClosureEscapes(); try self.collectServices(); try self.collectDeclaredEvents(); try self.pass1Collect(); @@ -4754,6 +4753,15 @@ pub const TypeChecker = struct { return true; } + /// A closure body is checked where it is called, and some never are, so its + /// jumps are judged where the parser found them. + fn checkClosureEscapes(self: *TypeChecker) !void { + for (self.arena.closure_escapes.items) |stmt| { + const what: []const u8 = if (self.arena.stmtKind(stmt) == .break_stmt) "break" else "continue"; + try self.emit(.control_flow_escapes_closure, .error_, self.arena.stmtSpan(stmt), "'{s}' targets a loop outside its closure — control flow cannot cross a closure boundary", .{what}); + } + } + /// Every integer literal fits `int` and every float or duration literal is /// finite, whatever context it sits in (`etch-resolver-types.md` §4.3). The /// lexer never includes the sign, so a literal under a unary minus is judged @@ -7637,10 +7645,25 @@ pub const TypeChecker = struct { const saved_captures = self.closure_captures; self.closure_captures = &captures; defer self.closure_captures = saved_captures; - // A `return` in a closure leaves the closure, which declares no type. + // A `return` in a closure leaves the closure, which declares no type, + // and no jump leaves it for the caller's loops or task branch. const saved_ret = self.current_fn_return; + const saved_branch = self.conc_branch; + const saved_depth = self.conc_loop_depth; + const saved_labels = self.conc_labels_base; + const saved_break_base = self.break_base; self.current_fn_return = null; - defer self.current_fn_return = saved_ret; + self.conc_branch = null; + self.conc_loop_depth = 0; + self.conc_labels_base = self.conc_labels.items.len; + self.break_base = self.break_frames.items.len; + defer { + self.current_fn_return = saved_ret; + self.conc_branch = saved_branch; + self.conc_loop_depth = saved_depth; + self.conc_labels_base = saved_labels; + self.break_base = saved_break_base; + } const ret = try self.synthExprE(ce.body, ctx_opt); // Remove the parameter bindings (a closure's params do not collide // with outer locals in practice; a save/restore is a later refinement). @@ -14509,7 +14532,6 @@ const mistyped_cases = [_]MistypedCase{ .{ .name = "let, one struct as another", .src = "rule r() { let v: P = R { y: 1 } }", .code = .type_mismatch }, .{ .name = "if branches", .src = "rule r() {\n let c = true\n let v = if c { 0 } else { P { x: 1 } }\n}", .code = .type_mismatch }, .{ .name = "loop broken inside an if", .src = "rule r() {\n let c = true\n let v: int = loop {\n if c { break P { x: 1 } }\n }\n}", .code = .type_mismatch }, - .{ .name = "loop broken from a closure", .src = "rule r() {\n let v: int = loop {\n let f = |k: int| {\n if k > 0 {\n break P { x: 1 }\n }\n k\n }\n let z = f(1)\n break 1\n }\n}", .code = .type_mismatch }, .{ .name = "assignment to a local", .src = "rule r() {\n let mut v = 0\n v = P { x: 1 }\n}", .code = .type_mismatch }, .{ .name = "assignment to a field", .src = "rule r() when resource Out { get_mut(Out).n = P { x: 1 } }", .code = .type_mismatch }, .{ .name = "fn argument", .src = "rule r() { let v = id(P { x: 1 }) }", .code = .type_mismatch }, @@ -14714,3 +14736,247 @@ test "a break refused at a task boundary types no loop beyond it" { try std.testing.expectEqual(@as(usize, 1), r.diagnostics.items.len); try std.testing.expectEqual(DiagnosticCode.control_flow_escapes_task_branch, r.diagnostics.items[0].code); } + +const ClosureJumpCase = struct { name: []const u8, src: []const u8 }; + +const escaping_jumps = [_]ClosureJumpCase{ + .{ .name = "break of a struct in a closure called in an int loop", .src = + \\struct P { x: int = 0 } + \\rule r() { + \\ let v: int = loop { + \\ let f = |k: int| { + \\ if k > 0 { + \\ break P { x: 1 } + \\ } + \\ k + \\ } + \\ let z = f(1) + \\ break 1 + \\ } + \\} + }, + .{ .name = "break in a closure called in a loop", .src = + \\resource Out { n: int = 0 } + \\rule r() when resource Out { + \\ let v = loop { + \\ let f = |k: int| { + \\ if k > 0 { + \\ break 7 + \\ } + \\ k + \\ } + \\ let z = f(1) + \\ break 1 + \\ } + \\ get_mut(Out).n = v + \\} + }, + .{ .name = "continue in a closure called in a for", .src = + \\resource Out { n: int = 0 } + \\rule r() when resource Out { + \\ let mut total = 0 + \\ for i in 0..3 { + \\ let f = |k: int| { + \\ if k > 0 { + \\ continue + \\ } + \\ k + \\ } + \\ let z = f(i) + \\ total = total + 1 + \\ } + \\ get_mut(Out).n = total + \\} + }, + .{ .name = "labeled break to an enclosing loop", .src = + \\resource Out { n: int = 0 } + \\rule r() when resource Out { + \\ let mut hits = 0 + \\ outer: loop { + \\ let f = |k: int| { + \\ if k > 0 { + \\ break outer + \\ } + \\ k + \\ } + \\ let z = f(1) + \\ hits = hits + 1 + \\ break + \\ } + \\ get_mut(Out).n = hits + \\} + }, + .{ .name = "break in a closure never called", .src = + \\resource Out { n: int = 0 } + \\rule r() when resource Out { + \\ let v = loop { + \\ let f = |k: int| { + \\ if k > 0 { + \\ break 7 + \\ } + \\ k + \\ } + \\ break 1 + \\ } + \\ get_mut(Out).n = v + \\} + }, + .{ .name = "break in a closure with no loop anywhere", .src = + \\resource Out { n: int = 0 } + \\rule r() when resource Out { + \\ let f = |k: int| { + \\ if k > 0 { + \\ break + \\ } + \\ k + \\ } + \\ get_mut(Out).n = f(1) + \\} + }, + .{ .name = "break in a closure called twice", .src = + \\resource Out { n: int = 0 } + \\rule r() when resource Out { + \\ let f = |k: int| { + \\ if k > 0 { + \\ break + \\ } + \\ k + \\ } + \\ for i in 0..3 { + \\ let a = f(0) + \\ let b = f(0) + \\ } + \\ get_mut(Out).n = 1 + \\} + }, + .{ .name = "break in a tick_until predicate", .src = + \\component A { v: int = 0 } + \\test "pred" { + \\ let w = test_world() + \\ let e = w.spawn_with([A { v: 1 }]) + \\ let ok = tick_until(|| { + \\ if true { + \\ break + \\ } + \\ true + \\ }, 1.0s) + \\} + }, + .{ .name = "break in a closure nested in a loop of another closure", .src = + \\resource Out { n: int = 0 } + \\rule r() when resource Out { + \\ let outer_f = |k: int| { + \\ let w = loop { + \\ let inner = |j: int| { + \\ if j > 0 { + \\ break 9 + \\ } + \\ j + \\ } + \\ let q = inner(1) + \\ break 2 + \\ } + \\ w + \\ } + \\ get_mut(Out).n = outer_f(1) + \\} + }, +}; + +const jumps_inside_closures = [_]ClosureJumpCase{ + .{ .name = "loop inside the closure", .src = + \\resource Out { n: int = 0 } + \\rule r() when resource Out { + \\ let f = |k: int| { + \\ let w = loop { + \\ if k > 0 { + \\ break 5 + \\ } + \\ break 6 + \\ } + \\ w + \\ } + \\ get_mut(Out).n = f(1) + \\} + }, + .{ .name = "continue in a for inside the closure", .src = + \\resource Out { n: int = 0 } + \\rule r() when resource Out { + \\ let f = |k: int| { + \\ for i in 0..3 { + \\ if i > k { + \\ continue + \\ } + \\ } + \\ k + \\ } + \\ get_mut(Out).n = f(1) + \\} + }, + .{ .name = "label shadowed inside the closure", .src = + \\resource Out { n: int = 0 } + \\rule r() when resource Out { + \\ let mut hits = 0 + \\ outer: loop { + \\ let f = |k: int| { + \\ outer: loop { + \\ if k > 0 { + \\ break outer + \\ } + \\ break + \\ } + \\ k + \\ } + \\ hits = f(4) + \\ break + \\ } + \\ get_mut(Out).n = hits + \\} + }, + .{ .name = "return from a closure in a sync branch", .src = + \\resource Out { n: int = 0 } + \\async rule r() when resource Out { + \\ sync { + \\ { let f = |k: int| { + \\ if k > 0 { + \\ return 3 + \\ } + \\ k + \\ } + \\ let z = f(1) + \\ get_mut(Out).n = z } + \\ } + \\} + }, +}; + +test "a break or continue that leaves its closure is refused once, called or not" { + const gpa = std.testing.allocator; + var wrong: usize = 0; + for (escaping_jumps) |c| { + var r = try parseAndCheck(gpa, c.src); + defer r.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), r.parse_diags.len); + const n = countMessage(r.diagnostics.items, .control_flow_escapes_closure, ""); + if (n != 1 or r.diagnostics.items.len != 1) { + wrong += 1; + std.debug.print("{s}: {d} E0911 among {d} diagnostics\n", .{ c.name, n, r.diagnostics.items.len }); + } + } + try std.testing.expectEqual(@as(usize, 0), wrong); +} + +test "a jump or return that stays in its closure is accepted" { + const gpa = std.testing.allocator; + var wrong: usize = 0; + for (jumps_inside_closures) |c| { + var r = try parseAndCheck(gpa, c.src); + defer r.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), r.parse_diags.len); + if (r.diagnostics.items.len != 0) { + wrong += 1; + for (r.diagnostics.items) |d| std.debug.print("{s}: {s} {s}\n", .{ c.name, d.code.code(), d.primary_message }); + } + } + try std.testing.expectEqual(@as(usize, 0), wrong); +} diff --git a/src/etch/value.zig b/src/etch/value.zig index ab26862b..d4c1d0bb 100644 --- a/src/etch/value.zig +++ b/src/etch/value.zig @@ -295,6 +295,9 @@ pub const RuntimeErrorKind = enum { /// boundary refused, or whose hook failed. The span covers the extension name /// at the call, and is empty when a cooked hook made the call. ExtensionOpFailed, + /// A `break` or `continue` reached the end of a closure body, with no loop + /// of that body to target. The span covers the closure. + ControlFlowEscapesClosure, }; /// Whether integer overflow wraps rather than panics: `ReleaseFast` and diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 04f033af..f65f60b5 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2785, - else => 2787, + .windows => 2789, + else => 2791, }; } From 5a3f2423d32f3b0dce624ca707f8d09534d41b3c Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 29 Sep 2026 01:58:03 +0200 Subject: [PATCH 132/141] fix(etch): type a value-less expression as unit The checker had no unit type and typed every value-less expression unknown, which fits any destination: `let v: int = loop { break }`, an if with no else, an empty block, `arr.push(1)` or `await wait(..)` checked clean and bound unit at run time. Unit is now a type that fits only itself. A loop left by a break with no value, a block with no tail value, an if with no else, the builtin effect methods and assertions, and the four wake targets and a TaskHandle await are unit. A block whose last statement returns, throws, breaks or continues keeps no type, so a branch that leaves still fits. A call of a fn with no `-> type` stays unknown, as ruled. One test changed: the labeled-loop routing test mixed `break outer 2.5` with a valueless break on the same loop, float against unit; its last break is now `break 0.5`. Floor 2791 -> 2793 / 2791, read from the suite. Co-Authored-By: Claude Opus 5.5 --- src/etch/types.zig | 385 +++++++++++++++++++++++++++++---- tools/weld_lint/dead_tests.zig | 4 +- 2 files changed, 339 insertions(+), 50 deletions(-) diff --git a/src/etch/types.zig b/src/etch/types.zig index 44a7332c..30d8bc19 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -328,6 +328,10 @@ pub const ResolvedType = union(enum) { /// `test_world()` (test bodies only). Receiver of `spawn_with`/`emit`/`tick` /// in `dispatchMethodOnType`. No payload (mono-world). Not field-storable. test_world, + /// The type of an expression that yields no value: a block with no tail + /// value, an `if` with no `else`, a loop left by a `break` with no value, a + /// builtin call run for its effect. Unlike `unknown`, it fits only itself. + unit, /// Type unknown / unresolved. Used as the fallback after a diagnostic /// has been emitted; subsequent checks treat `unknown` as wildcard to /// avoid cascade errors. @@ -351,6 +355,7 @@ pub const ResolvedType = union(enum) { .generic => |id| id == b.generic, .optional => |bt| bt == b.optional, .test_world => true, + .unit => true, .unknown => true, }; } @@ -528,7 +533,7 @@ pub const TypeChecker = struct { generic_scope: std.AutoHashMapUnmanaged(StringId, void) = .empty, /// Declared return type of the `fn` / method currently being checked, used /// to type a `return expr` body statement against. `null` outside - /// a body; `.unit` for a void fn (no `-> type`). + /// a body; `unknown` for a fn with no `-> type`. current_fn_return: ?ResolvedType = null, /// Literals already reported out of range, so a literal checked in two /// contexts is reported once. @@ -1372,9 +1377,8 @@ pub const TypeChecker = struct { fn foreignReturnType(self: *TypeChecker, a: *const AstArena, method: ast_mod.FnDecl) ResolvedType { _ = self; - // A void signature (`fn stop(h: AudioHandle)`) types as `unknown` — - // "`unknown` ≈ unit, the house convention" (this file, `synthCall`). - // `ResolvedType` has no unit variant to return instead. + // A void signature (`fn stop(h: AudioHandle)`) types as `unknown`, as + // a call of any fn with no `-> type` does. if (method.return_type.isNone()) return ResolvedType.unknown; const name = a.namedTypeName(method.return_type) orelse return ResolvedType.unknown; const tname = a.strings.slice(a.resolveTypeAliasName(name)); @@ -3687,7 +3691,7 @@ pub const TypeChecker = struct { } } } - } else if (t != .unknown) { + } else if (t != .unknown and t != .unit) { try self.emit(.behavior_action_invalid_return, .error_, node.span, "a behavior action must be a void call, a 'let' binding, or an 'emit' (got a value expression)", .{}); } }, @@ -6255,14 +6259,13 @@ pub const TypeChecker = struct { // `break [label] [value]`. Loop membership and label validity // are permissive. const b = self.arena.break_stmts.items[data]; - if (!b.value.isNone()) { - const t = self.synthExpr(b.value, ctx); - if (self.breakTarget(b.label)) |frame| { - if (frame.yields) { - if (frame.value_t) |earlier| { - if (!try self.armsAgree(earlier, b.value, t)) try self.emit(.type_mismatch, .error_, self.arena.exprSpan(b.value), "break values of a loop must all have the same type", .{}); - } else frame.value_t = t; - } + const t: ResolvedType = if (b.value.isNone()) .unit else self.synthExpr(b.value, ctx); + if (self.breakTarget(b.label)) |frame| { + if (frame.yields) { + if (frame.value_t) |earlier| { + const span = if (b.value.isNone()) self.arena.stmtSpan(stmt_id) else self.arena.exprSpan(b.value); + if (!try self.armsAgree(earlier, b.value, t)) try self.emit(.type_mismatch, .error_, span, "break values of a loop must all have the same type", .{}); + } else frame.value_t = t; } } // E0907: the break must not cross the enclosing @@ -7201,9 +7204,8 @@ pub const TypeChecker = struct { if (ak == .fn_call or ak == .method_call) return t; // Non-call target: the handle-await form (§9.8) — the target // must be a TaskHandle. The result is - // unit (spawn bodies have no value channel); - // `unknown` ≈ unit, the house convention. - if (t == .builtin and t.builtin == .task_handle) return ResolvedType.unknown; + // unit (spawn bodies have no value channel). + if (t == .builtin and t.builtin == .task_handle) return ResolvedType.unit; // A `TimerHandle` is NOT awaitable (§9.10): // a timer is not a task — no join semantics. Precise // message ahead of the generic rejection below. @@ -7217,7 +7219,7 @@ pub const TypeChecker = struct { return ResolvedType.unknown; }, } - return ResolvedType.unknown; + return ResolvedType.unit; }, .paren => unreachable, // parser doesn't emit a paren node — it returns the inner expr else => return ResolvedType.unknown, @@ -7346,7 +7348,7 @@ pub const TypeChecker = struct { const stmt: NodeId = @bitCast(self.arena.extra.items[lp.body_start + i]); if (self.arena.stmtKind(stmt) == .break_stmt) { const b = self.arena.break_stmts.items[self.arena.stmtData(stmt)]; - if (!b.value.isNone()) return self.synthExpr(b.value, ctx_opt); + return if (b.value.isNone()) ResolvedType.unit else self.synthExpr(b.value, ctx_opt); } } return ResolvedType.unknown; @@ -7354,7 +7356,7 @@ pub const TypeChecker = struct { /// Type a block expression `{ stmts; value }`. The body /// statements are checked in order, then the block's type is the trailing - /// value's type (or `unknown` ≈ unit when value-less). Locals declared in + /// value's type, or unit when value-less. Locals declared in /// the block use the flat per-rule locals map — lexical scoping is a later /// refinement (the interpreter is the reference). fn synthBlock(self: *TypeChecker, data: u32, ctx_opt: ?*RuleCtx) TypeError!ResolvedType { @@ -7366,10 +7368,22 @@ pub const TypeChecker = struct { try self.checkStmt(ctx, stmt); } } - if (blk.value.isNone()) return ResolvedType.unknown; + if (blk.value.isNone()) return if (self.runDiverges(blk.body_start, blk.body_len)) ResolvedType.unknown else ResolvedType.unit; return try self.synthExprE(blk.value, ctx_opt); } + /// Whether a statement run ends in a statement that never completes. Such + /// a run has no type rather than unit, so a block that returns or throws + /// fits any destination. + fn runDiverges(self: *TypeChecker, start: u32, len: u32) bool { + if (len == 0) return false; + const last: NodeId = @bitCast(self.arena.extra.items[start + len - 1]); + return switch (self.arena.stmtKind(last)) { + .return_stmt, .throw_stmt, .break_stmt, .continue_stmt => true, + else => false, + }; + } + /// Type a `measure { block }` expression (§17 erratum). Result type /// is always `Duration` (the elapsed wall-clock; the block's own value is /// discarded). Legal ONLY inside a test body — anywhere else it is @@ -7394,9 +7408,7 @@ pub const TypeChecker = struct { /// Type an `if` expression. The condition must be /// `bool`; the then / else branches (block expressions, `else if` chaining /// through a nested `if`) must unify to one result type. An `if` with no - /// `else` has no value (`unknown` ≈ unit) — valid only in statement - /// position (the type-checker does not separately reject a value-position - /// else-less `if`; the codegen surfaces it as `UnsupportedConstruct`). + /// `else` is unit. fn synthIf(self: *TypeChecker, id: NodeId, data: u32, ctx_opt: ?*RuleCtx) TypeError!ResolvedType { const ife = self.arena.if_exprs.items[data]; const cond_t = try self.synthExprE(ife.cond, ctx_opt); @@ -7407,7 +7419,7 @@ pub const TypeChecker = struct { if (ctx_opt) |ctx| try ctx.locals.put(self.gpa, ife.let_binding, .{ .type_ = payload, .is_mut = false }); const then_t = try self.synthExprE(ife.then_block, ctx_opt); if (ctx_opt) |ctx| _ = ctx.locals.remove(ife.let_binding); - if (ife.else_branch.isNone()) return ResolvedType.unknown; + if (ife.else_branch.isNone()) return ResolvedType.unit; const else_t = try self.synthExprE(ife.else_branch, ctx_opt); if (!try self.valueFits(then_t, ife.else_branch, else_t)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(id), "if branches must yield the same type", .{}); @@ -7418,7 +7430,7 @@ pub const TypeChecker = struct { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(ife.cond), "if condition must be a bool expression", .{}); } const then_t = try self.synthExprE(ife.then_block, ctx_opt); - if (ife.else_branch.isNone()) return ResolvedType.unknown; + if (ife.else_branch.isNone()) return ResolvedType.unit; const else_t = try self.synthExprE(ife.else_branch, ctx_opt); if (!try self.valueFits(then_t, ife.else_branch, else_t)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(id), "if branches must yield the same type", .{}); @@ -7470,7 +7482,7 @@ pub const TypeChecker = struct { /// (`assert_eq`/`assert_neq`/`assert_approx`/`assert_some`/`assert_none`, /// `etch-stdlib.md §19.4`) + `panic`/`todo`/`unreachable` — resolve anywhere; /// test-scoped builtins (`test_world`, `tick_until`) only inside a test body. - /// Statement-effect builtins yield `unknown` (≈ unit); args are synth-checked + /// The assertions yield unit and the diverging builtins `unknown`; args are synth-checked /// so nested expressions are typed and light shape checks surface misuse. fn synthBuiltinCall(self: *TypeChecker, id: NodeId, call: ast_mod.CallExpr, callee_name: StringId, ctx_opt: ?*RuleCtx) TypeError!?ResolvedType { const name = self.arena.strings.slice(callee_name); @@ -7516,7 +7528,7 @@ pub const TypeChecker = struct { } if (call.args_len == 3) _ = try self.synthArg(call, 2, ctx_opt); } - return ResolvedType.unknown; + return ResolvedType.unit; } if (std.mem.eql(u8, name, "assert_approx")) { if (call.args_len < 2 or call.args_len > 4) { @@ -7538,7 +7550,7 @@ pub const TypeChecker = struct { } if (call.args_len == 4) _ = try self.synthArg(call, 3, ctx_opt); } - return ResolvedType.unknown; + return ResolvedType.unit; } if (std.mem.eql(u8, name, "assert_some") or std.mem.eql(u8, name, "assert_none")) { if (call.args_len < 1 or call.args_len > 2) { @@ -7550,7 +7562,7 @@ pub const TypeChecker = struct { } if (call.args_len == 2) _ = try self.synthArg(call, 1, ctx_opt); } - return ResolvedType.unknown; + return ResolvedType.unit; } if (std.mem.eql(u8, name, "panic")) { if (call.args_len != 1) { @@ -8385,7 +8397,7 @@ pub const TypeChecker = struct { } } try self.checkMutCollectionReceiver(mc, ctx_opt); - return ResolvedType.unknown; // void return + return ResolvedType.unit; } if (std.mem.eql(u8, method_slice, "len")) { if (mc.args_len != 0) { @@ -8482,8 +8494,8 @@ pub const TypeChecker = struct { return ResolvedType.unknown; } - // builtin TaskHandle method (§9.8): `cancel()` — no args, - // statement-effect (`unknown` ≈ unit; idempotent at runtime). The + // builtin TaskHandle method (§9.8): `cancel()` — no args, unit, + // idempotent at runtime. The // handle's only other operation is `await h` (handled in the await // arm); anything else is an error with a pointer to both. if (recv_t == .builtin and recv_t.builtin == .task_handle) { @@ -8491,21 +8503,21 @@ pub const TypeChecker = struct { if (mc.args_len != 0) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(id), "TaskHandle method 'cancel' takes no arguments", .{}); } - return ResolvedType.unknown; + return ResolvedType.unit; } try self.emit(.type_mismatch, .error_, self.arena.exprSpan(id), "no method '{s}' on a TaskHandle (only 'cancel()'; join with 'await h')", .{method_slice}); return ResolvedType.unknown; } - // builtin TimerHandle method (§9.10): `cancel()` — no args, statement-effect - // (`unknown` ≈ unit), idempotent at runtime. Its ONLY operation: a timer is not + // builtin TimerHandle method (§9.10): `cancel()` — no args, unit, + // idempotent at runtime. Its ONLY operation: a timer is not // a task — no `await t`, no join, nothing else. if (recv_t == .builtin and recv_t.builtin == .timer_handle) { if (std.mem.eql(u8, method_slice, "cancel")) { if (mc.args_len != 0) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(id), "TimerHandle method 'cancel' takes no arguments", .{}); } - return ResolvedType.unknown; + return ResolvedType.unit; } try self.emit(.type_mismatch, .error_, self.arena.exprSpan(id), "no method '{s}' on a TimerHandle (only 'cancel()'; a timer is not awaitable)", .{method_slice}); return ResolvedType.unknown; @@ -8524,7 +8536,7 @@ pub const TypeChecker = struct { } if (std.mem.eql(u8, method_slice, "emit")) { try self.checkWorldEmitArg(id, mc, ctx_opt); - return ResolvedType.unknown; + return ResolvedType.unit; } if (std.mem.eql(u8, method_slice, "tick")) { if (mc.args_len != 1) { @@ -8536,7 +8548,7 @@ pub const TypeChecker = struct { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(arg), "tick(n) requires an integer tick count", .{}); } } - return ResolvedType.unknown; + return ResolvedType.unit; } try self.emit(.type_mismatch, .error_, self.arena.exprSpan(id), "no method '{s}' on the test world (spawn_with / emit / tick)", .{method_slice}); return ResolvedType.unknown; @@ -8545,13 +8557,13 @@ pub const TypeChecker = struct { // Builtin extension methods on an `Entity` receiver, checked // BEFORE the trait-method resolution below (these are interpreter builtins, // not user traits; any other method on an Entity falls through to the - // trait lookup). `activate_extension`/`deactivate_extension` are - // statement-use (`unknown` return, like `array.push`); `has_extension` + // trait lookup). `activate_extension`/`deactivate_extension` are unit; + // `has_extension` // → bool; `active_extensions` → `[string]`. if (recv_t == .builtin and recv_t.builtin == .entity) { if (std.mem.eql(u8, method_slice, "activate_extension") or std.mem.eql(u8, method_slice, "deactivate_extension")) { try self.checkExtensionNameArg(id, mc, method_slice, ctx_opt); - return ResolvedType.unknown; + return ResolvedType.unit; } if (std.mem.eql(u8, method_slice, "has_extension")) { try self.checkExtensionNameArg(id, mc, method_slice, ctx_opt); @@ -8562,13 +8574,12 @@ pub const TypeChecker = struct { return ResolvedType{ .array_dyn = .string_ }; } // structural mutation methods on an `Entity` receiver - // (`etch-grammar.md` §4.5). All three are statement-effect (`unknown` - // return, like `array.push` / `activate_extension`); they enqueue a + // (`etch-grammar.md` §4.5). All three are unit; they enqueue a // deferred command at run. `add` on a present component is a // replace (`@on_replaced`), no separate construct. if (std.mem.eql(u8, method_slice, "despawn")) { if (mc.args_len != 0) try self.emit(.type_mismatch, .error_, self.arena.exprSpan(id), "Entity method 'despawn' takes no arguments", .{}); - return ResolvedType.unknown; + return ResolvedType.unit; } if (std.mem.eql(u8, method_slice, "add")) { if (mc.args_len != 1) { @@ -8577,7 +8588,7 @@ pub const TypeChecker = struct { const arg: NodeId = @bitCast(self.arena.extra.items[mc.args_start]); try self.checkStructuralComponentLiteral(arg); } - return ResolvedType.unknown; + return ResolvedType.unit; } if (std.mem.eql(u8, method_slice, "remove")) { if (mc.args_len != 1) { @@ -8598,7 +8609,7 @@ pub const TypeChecker = struct { // note for what it was and why the number stays reserved. } } - return ResolvedType.unknown; + return ResolvedType.unit; } } @@ -9077,7 +9088,7 @@ pub const TypeChecker = struct { try self.emit(.invalid_field_filter, .error_, span, "field '{s}' does not exist on event '{s}'", .{ self.arena.strings.slice(field_name), self.arena.strings.slice(name_id) }); return ResolvedType.unknown; }, - .builtin, .range, .array_fixed, .array_dyn, .map_t, .set_t, .closure, .enum_t, .generic, .optional, .test_world, .unknown => return ResolvedType.unknown, + .builtin, .range, .array_fixed, .array_dyn, .map_t, .set_t, .closure, .enum_t, .generic, .optional, .test_world, .unit, .unknown => return ResolvedType.unknown, } } @@ -14709,7 +14720,7 @@ test "a value breaking out of a while, an inner loop or to a label does not type \\ if c { break outer 2.5 } \\ break 1 \\ } - \\ break + \\ break 0.5 \\ } \\} ); @@ -14980,3 +14991,281 @@ test "a jump or return that stays in its closure is accepted" { } try std.testing.expectEqual(@as(usize, 0), wrong); } + +const UnitCase = struct { name: []const u8, code: DiagnosticCode, src: []const u8 }; + +const unit_refused = [_]UnitCase{ + .{ .name = "if let with no else", .code = .type_mismatch, .src = + \\rule r() { + \\ let o: int? = some(1) + \\ let v: int = if let x = o { x } + \\} + }, + .{ .name = "assert_approx", .code = .type_mismatch, .src = + \\rule r() { + \\ let v: int = assert_approx(1.0, 1.0) + \\} + }, + .{ .name = "assert_some", .code = .type_mismatch, .src = + \\rule r() { + \\ let o: int? = some(1) + \\ let v: int = assert_some(o) + \\} + }, + .{ .name = "loop left by a break with no value", .code = .type_mismatch, .src = + \\resource Out { n: int = 0 } + \\fn nothing() { } + \\fn nothing_ret() { return } + \\rule r() when resource Out { + \\ let c = true + \\ let mut arr: int[] = [1] + \\ let v: int = loop { break } + \\ get_mut(Out).n = v + \\} + }, + .{ .name = "loop left by a valued and a valueless break", .code = .type_mismatch, .src = + \\resource Out { n: int = 0 } + \\fn nothing() { } + \\fn nothing_ret() { return } + \\rule r() when resource Out { + \\ let c = true + \\ let mut arr: int[] = [1] + \\ let v: int = loop { + \\ if c { break } + \\ break 1 + \\ } + \\ get_mut(Out).n = v + \\} + }, + .{ .name = "if with no else", .code = .type_mismatch, .src = + \\resource Out { n: int = 0 } + \\fn nothing() { } + \\fn nothing_ret() { return } + \\rule r() when resource Out { + \\ let c = true + \\ let mut arr: int[] = [1] + \\ let v: int = if c { 1 } + \\ get_mut(Out).n = v + \\} + }, + .{ .name = "if with no else, condition false", .code = .type_mismatch, .src = + \\resource Out { n: int = 0 } + \\rule r() when resource Out { + \\ let c = false + \\ let v: int = if c { 1 } + \\ get_mut(Out).n = v + \\} + }, + .{ .name = "array push", .code = .type_mismatch, .src = + \\resource Out { n: int = 0 } + \\fn nothing() { } + \\fn nothing_ret() { return } + \\rule r() when resource Out { + \\ let c = true + \\ let mut arr: int[] = [1] + \\ let v: int = arr.push(2) + \\ get_mut(Out).n = v + \\} + }, + .{ .name = "empty block", .code = .type_mismatch, .src = + \\resource Out { n: int = 0 } + \\fn nothing() { } + \\fn nothing_ret() { return } + \\rule r() when resource Out { + \\ let c = true + \\ let mut arr: int[] = [1] + \\ let v: int = { } + \\ get_mut(Out).n = v + \\} + }, + .{ .name = "block ending in a statement", .code = .type_mismatch, .src = + \\resource Out { n: int = 0 } + \\fn nothing() { } + \\fn nothing_ret() { return } + \\rule r() when resource Out { + \\ let c = true + \\ let mut arr: int[] = [1] + \\ let v: int = { let q = 1 } + \\ get_mut(Out).n = v + \\} + }, + .{ .name = "await wait", .code = .type_mismatch, .src = + \\resource Out { n: int = 0 } + \\async rule r() when resource Out { + \\ let v: int = await wait(1.0s) + \\} + }, + .{ .name = "TaskHandle cancel", .code = .type_mismatch, .src = + \\async rule r() { + \\ let h = spawn { } + \\ let v: int = h.cancel() + \\} + }, + .{ .name = "TimerHandle cancel", .code = .type_mismatch, .src = + \\rule r() { + \\ let t = after(1.0s) { } + \\ let v: int = t.cancel() + \\} + }, + .{ .name = "test world tick", .code = .type_mismatch, .src = + \\test "t" { + \\ let w = test_world() + \\ let v: int = w.tick(1) + \\} + }, + .{ .name = "test world emit", .code = .type_mismatch, .src = + \\event E { n: int = 0 } + \\test "t" { + \\ let w = test_world() + \\ let v: int = w.emit(E { n: 1 }) + \\} + }, + .{ .name = "activate_extension", .code = .type_mismatch, .src = + \\component C { v: int = 0 } + \\rule r(e: Entity) when e has C { + \\ let v: int = e.activate_extension("X") + \\} + }, + .{ .name = "despawn", .code = .type_mismatch, .src = + \\component C { v: int = 0 } + \\rule r(e: Entity) when e has C { + \\ let v: int = e.despawn() + \\} + }, + .{ .name = "add", .code = .type_mismatch, .src = + \\component C { v: int = 0 } + \\component D { v: int = 0 } + \\rule r(e: Entity) when e has C { + \\ let v: int = e.add(D { v: 1 }) + \\} + }, + .{ .name = "remove", .code = .type_mismatch, .src = + \\component C { v: int = 0 } + \\rule r(e: Entity) when e has C { + \\ let v: int = e.remove(C) + \\} + }, + .{ .name = "assert_eq", .code = .type_mismatch, .src = + \\rule r() { + \\ let v: int = assert_eq(1, 1) + \\} + }, + .{ .name = "await a TaskHandle", .code = .type_mismatch, .src = + \\async rule r() { + \\ let h = spawn { } + \\ let v: int = await h + \\} + }, + .{ .name = "fn body ending in an if with no else", .code = .return_type_mismatch, .src = + \\resource Out { n: int = 0 } + \\fn f(x: int) -> int { if x > 0 { return 1 } } + \\rule r() when resource Out { + \\ get_mut(Out).n = 1 + \\} + }, +}; + +const unit_accepted = [_]ClosureJumpCase{ + .{ .name = "behavior action on a unit method", .src = + \\component C { v: int = 0 } + \\behavior B { + \\ selector { + \\ action: self.despawn() + \\ } + \\} + }, + .{ .name = "if and else both return", .src = + \\resource Out { n: int = 0 } + \\fn f(x: int) -> int { if x > 0 { return 1 } else { return 2 } } + \\rule r() when resource Out { + \\ get_mut(Out).n = 1 + \\} + }, + .{ .name = "else returns", .src = + \\resource Out { n: int = 0 } + \\fn f(x: int) -> int { if x > 0 { 1 } else { return 2 } } + \\rule r() when resource Out { + \\ get_mut(Out).n = 1 + \\} + }, + .{ .name = "loop left by return only", .src = + \\resource Out { n: int = 0 } + \\fn f(x: int) -> int { loop { if x > 0 { return 1 } } } + \\rule r() when resource Out { + \\ get_mut(Out).n = 1 + \\} + }, + .{ .name = "if and else as statements", .src = + \\resource Out { n: int = 0 } + \\rule r() when resource Out { + \\ let c = true + \\ let mut a = 0 + \\ let mut b = 0 + \\ if c { a = 1 } else { b = 2 } + \\ get_mut(Out).n = a + b + \\} + }, + .{ .name = "match arm that returns", .src = + \\resource Out { n: int = 0 } + \\fn f(x: int) -> int { match x { 1 => 5, _ => { return 0 } } } + \\rule r() when resource Out { + \\ get_mut(Out).n = 1 + \\} + }, + .{ .name = "else that panics", .src = + \\resource Out { n: int = 0 } + \\fn f(c: bool) -> int { if c { 1 } else { panic("x") } } + \\rule r() when resource Out { + \\ get_mut(Out).n = 1 + \\} + }, + .{ .name = "call of a fn with no return type", .src = + \\resource Out { n: int = 0 } + \\fn nothing() { } + \\fn nothing_ret() { return } + \\rule r() when resource Out { + \\ let c = true + \\ let mut arr: int[] = [1] + \\ let v: int = nothing() + \\ get_mut(Out).n = v + \\} + }, + .{ .name = "fn with no return type and a tail value", .src = + \\resource Out { n: int = 0 } + \\fn tail() { 5 } + \\rule r() when resource Out { + \\ let v: int = tail() + \\ get_mut(Out).n = v + \\} + }, +}; + +test "a unit value where a type is declared is refused" { + const gpa = std.testing.allocator; + var missed: usize = 0; + for (unit_refused) |c| { + var r = try parseAndCheck(gpa, c.src); + defer r.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), r.parse_diags.len); + if (countMessage(r.diagnostics.items, c.code, "") == 0) { + missed += 1; + std.debug.print("not refused: {s}\n", .{c.name}); + } + } + try std.testing.expectEqual(@as(usize, 0), missed); +} + +test "a block that never completes, and a fn call, are not unit" { + const gpa = std.testing.allocator; + var wrong: usize = 0; + for (unit_accepted) |c| { + var r = try parseAndCheck(gpa, c.src); + defer r.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), r.parse_diags.len); + if (r.diagnostics.items.len != 0) { + wrong += 1; + for (r.diagnostics.items) |d| std.debug.print("{s}: {s} {s}\n", .{ c.name, d.code.code(), d.primary_message }); + } + } + try std.testing.expectEqual(@as(usize, 0), wrong); +} diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index f65f60b5..cc4ac217 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2789, - else => 2791, + .windows => 2791, + else => 2793, }; } From 625a92d1ca00767d955eeed6edfdac1becaef0dd Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 29 Sep 2026 02:13:16 +0200 Subject: [PATCH 133/141] fix(etch): compare the element types of two collections of one kind valueFits left two collections, optionals or ranges of one kind to the check of their own construct, and that check exists for literals at a let only: an int[] variable passed as a string[], a map of another key type, an int? into bool?, [1, 2] as a string[] argument, a field or a fn value, and an int[2] into int[3] all checked clean. Two such values now fit when their element types are equal, two fixed arrays also on their length. The elements of a literal, a Set.from of a literal and a some(..) are each judged by the literal rule, so f32[] from [1.0, 2.0] still fits. A mistyped literal at a let keeps its per-element diagnostics and gets no second one. Floor 2793 -> 2796 / 2794, read from the suite. Co-Authored-By: Claude Opus 5.5 --- src/etch/types.zig | 303 ++++++++++++++++++++++++++++++++- tools/weld_lint/dead_tests.zig | 4 +- 2 files changed, 300 insertions(+), 7 deletions(-) diff --git a/src/etch/types.zig b/src/etch/types.zig index 30d8bc19..d6862e70 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -4836,8 +4836,7 @@ pub const TypeChecker = struct { } } - /// Whether a value of type `actual` fits a slot of type `declared`. Two - /// composites of one kind are left to the checks of their own construct. A + /// Whether a value of type `actual` fits a slot of type `declared`. A /// generic fits anything: a struct's own type parameters resolve against the /// caller's. fn valueFits(self: *TypeChecker, declared: ResolvedType, value: NodeId, actual: ResolvedType) !bool { @@ -4846,11 +4845,85 @@ pub const TypeChecker = struct { if (declared == .builtin and declared.builtin == .vec3 and actual == .array_fixed) return actual.array_fixed.len == 3 and actual.array_fixed.elem.isNumeric(); if ((declared == .builtin) != (actual == .builtin)) return false; - if (std.meta.activeTag(declared) != std.meta.activeTag(actual)) return isArray(declared) and isArray(actual); + if (std.meta.activeTag(declared) != std.meta.activeTag(actual) and !(isArray(declared) and isArray(actual))) return false; if (isNominal(declared)) return self.sameDeclaration(declared, actual); + return self.elementsFit(declared, value, actual); + } + + /// Two collections, optionals or ranges of one kind: the elements of a + /// literal each by the literal rule, any other value by equal element types. + fn elementsFit(self: *TypeChecker, declared: ResolvedType, value: NodeId, actual: ResolvedType) !bool { + const kind: ?ast_mod.ExprKind = if (value.isNone()) null else self.arena.exprKind(value); + switch (declared) { + .array_fixed, .array_dyn => { + const want = arrayElem(declared); + const have = arrayElem(actual); + if (declared == .array_fixed and actual == .array_fixed and declared.array_fixed.len != actual.array_fixed.len) return false; + if (kind == .array_lit) return self.runFits(want, self.arena.array_lits.items[self.arena.exprData(value)], have); + return want == have; + }, + .set_t => |want| { + if (kind == .method_call) { + const mc = self.arena.method_calls.items[self.arena.exprData(value)]; + if (mc.args_len == 1) { + const arg: NodeId = @bitCast(self.arena.extra.items[mc.args_start]); + if (self.arena.exprKind(arg) == .array_lit) return self.runFits(want, self.arena.array_lits.items[self.arena.exprData(arg)], actual.set_t); + } + } + return want == actual.set_t; + }, + .map_t => |want| { + if (kind == .map_lit) { + const ml = self.arena.map_lits.items[self.arena.exprData(value)]; + var i: u32 = 0; + while (i < ml.entries_len) : (i += 1) { + const entry = self.arena.map_entries.items[ml.entries_start + i]; + if (!try self.elementFits(want.key, entry.key, actual.map_t.key)) return false; + if (!try self.elementFits(want.value, entry.value, actual.map_t.value)) return false; + } + return true; + } + return want.key == actual.map_t.key and want.value == actual.map_t.value; + }, + .optional => |want| { + if (kind == .some_lit) return self.elementFits(want, @bitCast(self.arena.exprData(value)), actual.optional); + return want == actual.optional; + }, + .range => |want| return want == actual.range, + else => return true, + } + } + + fn arrayElem(t: ResolvedType) BuiltinType { + return switch (t) { + .array_fixed => |info| info.elem, + .array_dyn => |elem| elem, + else => unreachable, + }; + } + + fn runFits(self: *TypeChecker, want: BuiltinType, al: ast_mod.ArrayLitExpr, have: BuiltinType) !bool { + var i: u32 = 0; + while (i < al.elements_len) : (i += 1) { + if (!try self.elementFits(want, @bitCast(self.arena.extra.items[al.elements_start + i]), have)) return false; + } return true; } + /// One element of a collection literal: a numeric literal by the literal + /// rule, anything else by the literal's own element type. + fn elementFits(self: *TypeChecker, want: BuiltinType, e: NodeId, have: BuiltinType) !bool { + var lit = e; + if (self.arena.exprKind(lit) == .unary and self.arena.unary_exprs.items[self.arena.exprData(lit)].op == .neg) { + lit = self.arena.unary_exprs.items[self.arena.exprData(lit)].operand; + } + return switch (self.arena.exprKind(lit)) { + .int_lit => self.literalTypeFits(want, e, .int_), + .float_lit => self.literalTypeFits(want, e, .float_), + else => want == have, + }; + } + fn isArray(t: ResolvedType) bool { return t == .array_fixed or t == .array_dyn; } @@ -6082,10 +6155,11 @@ pub const TypeChecker = struct { break :blk self.synthHeadValue(let.value, ctx); }; const final = if (declared) |d| blk: { - if (!try self.valueFits(d, let.value, inferred)) { + const reported = self.diagnostics.items.len; + try self.checkCollectionLitAgainst(let.value, d, inferred); + if (self.diagnostics.items.len == reported and !try self.valueFits(d, let.value, inferred)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(let.value), "let initializer type does not match declared type", .{}); } - try self.checkCollectionLitAgainst(let.value, d, inferred); break :blk d; } else inferred; // A binding to `entity.get_mut(T)` aliases the mutable @@ -15269,3 +15343,222 @@ test "a block that never completes, and a fn call, are not unit" { } try std.testing.expectEqual(@as(usize, 0), wrong); } + +const collection_refused = [_]UnitCase{ + .{ .name = "int[] variable into string[]", .code = .type_mismatch, .src = + \\fn take_s(a: string[]) -> int { 0 } + \\rule r() { + \\ let b: int[] = [1] + \\ let a: string[] = b + \\} + }, + .{ .name = "int[2] variable into int[3]", .code = .type_mismatch, .src = + \\fn take_s(a: string[]) -> int { 0 } + \\rule r() { + \\ let b: int[2] = [1, 2] + \\ let a: int[3] = b + \\} + }, + .{ .name = "int[2] variable into string[]", .code = .type_mismatch, .src = + \\fn take_s(a: string[]) -> int { 0 } + \\rule r() { + \\ let b: int[2] = [1, 2] + \\ let a: string[] = b + \\} + }, + .{ .name = "map variable of another key type", .code = .type_mismatch, .src = + \\fn take_s(a: string[]) -> int { 0 } + \\rule r() { + \\ let mut m: [int: int] = [1: 2] + \\ let n: [string: int] = m + \\} + }, + .{ .name = "set variable of another element type", .code = .type_mismatch, .src = + \\fn take_s(a: string[]) -> int { 0 } + \\rule r() { + \\ let mut s = Set.from([1, 2]) + \\ let t: Set = s + \\} + }, + .{ .name = "int? variable into bool?", .code = .type_mismatch, .src = + \\fn take_s(a: string[]) -> int { 0 } + \\rule r() { + \\ let o: int? = some(1) + \\ let p: bool? = o + \\} + }, + .{ .name = "int[] variable as a string[] argument", .code = .type_mismatch, .src = + \\fn take_s(a: string[]) -> int { 0 } + \\rule r() { + \\ let b: int[] = [1] + \\ let z = take_s(b) + \\} + }, + .{ .name = "literal of the wrong length", .code = .type_mismatch, .src = + \\rule r() { + \\ let a: int[3] = [1, 2] + \\} + }, + .{ .name = "int literal array as a string[] argument", .code = .type_mismatch, .src = + \\resource Out { n: int = 0, ys: string[] } + \\fn take_s(a: string[]) -> int { 0 } + \\rule r() when resource Out { + \\ let z = take_s([1, 2]) + \\} + }, + .{ .name = "int literal array assigned to a string[] local", .code = .type_mismatch, .src = + \\resource Out { n: int = 0, ys: string[] } + \\fn take_s(a: string[]) -> int { 0 } + \\rule r() when resource Out { + \\ let mut a: string[] = ["x"] + \\ a = [1, 2] + \\} + }, + .{ .name = "int literal array assigned to a string[] field", .code = .type_mismatch, .src = + \\resource Out { n: int = 0, ys: string[] } + \\fn take_s(a: string[]) -> int { 0 } + \\rule r() when resource Out { + \\ get_mut(Out).ys = [1] + \\} + }, + .{ .name = "Set.from of int literals into Set", .code = .type_mismatch, .src = + \\resource Out { n: int = 0, ys: string[] } + \\fn take_s(a: string[]) -> int { 0 } + \\rule r() when resource Out { + \\ let t: Set = Set.from([1, 2]) + \\} + }, + .{ .name = "int literal array as a string[] fn value", .code = .return_type_mismatch, .src = + \\fn give() -> string[] { [1, 2] } + \\rule r() { } + }, + .{ .name = "some(1) into bool?", .code = .type_mismatch, .src = + \\rule r() { + \\ let o: bool? = some(1) + \\} + }, +}; + +const collection_accepted = [_]ClosureJumpCase{ + .{ .name = "f32[] from float literals", .src = + \\fn take_s(a: string[]) -> int { 0 } + \\rule r() { + \\ let a: f32[] = [1.0, 2.0] + \\} + }, + .{ .name = "f32? from some(float literal)", .src = + \\fn take_s(a: string[]) -> int { 0 } + \\rule r() { + \\ let o: f32? = some(1.0) + \\} + }, + .{ .name = "int[] variable into int[]", .src = + \\fn take_s(a: string[]) -> int { 0 } + \\rule r() { + \\ let b: int[] = [1] + \\ let a: int[] = b + \\} + }, + .{ .name = "int[2] variable into int[]", .src = + \\fn take_s(a: string[]) -> int { 0 } + \\rule r() { + \\ let b: int[2] = [1, 2] + \\ let a: int[] = b + \\} + }, + .{ .name = "int[] from int literals", .src = + \\rule r() { + \\ let a: int[] = [1, 2] + \\} + }, + .{ .name = "string[] argument from string literals", .src = + \\resource Out { n: int = 0, ys: string[] } + \\fn take_s(a: string[]) -> int { 0 } + \\rule r() when resource Out { + \\ let z = take_s(["a"]) + \\} + }, + .{ .name = "f32? from some(1.5)", .src = + \\rule r() { + \\ let o: f32? = some(1.5) + \\} + }, + .{ .name = "Set from Set.from(int literals)", .src = + \\rule r() { + \\ let t: Set = Set.from([1, 2]) + \\} + }, + .{ .name = "[i32: f32] from a literal", .src = + \\rule r() { + \\ let m: [i32: f32] = [1: 2.0] + \\} + }, + .{ .name = "i32[] from negative literals", .src = + \\rule r() { + \\ let a: i32[] = [-1, 2] + \\} + }, + .{ .name = "i32[] argument from negative literals", .src = + \\fn take(a: i32[]) -> int { 0 } + \\rule r() { + \\ let z = take([-1, 2]) + \\} + }, + .{ .name = "[i32: f32] argument from a literal", .src = + \\fn take(m: [i32: f32]) -> int { 0 } + \\rule r() { + \\ let z = take([1: 2.0]) + \\} + }, + .{ .name = "f32? argument from some(float literal)", .src = + \\fn take(o: f32?) -> int { 0 } + \\rule r() { + \\ let z = take(some(1.0)) + \\} + }, + .{ .name = "Set argument from Set.from(int literals)", .src = + \\fn take(t: Set) -> int { 0 } + \\rule r() { + \\ let z = take(Set.from([1, 2])) + \\} + }, +}; + +test "a collection or optional of another element type is refused at every gate" { + const gpa = std.testing.allocator; + var missed: usize = 0; + for (collection_refused) |c| { + var r = try parseAndCheck(gpa, c.src); + defer r.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), r.parse_diags.len); + if (countMessage(r.diagnostics.items, c.code, "") == 0) { + missed += 1; + std.debug.print("not refused: {s}\n", .{c.name}); + } + } + try std.testing.expectEqual(@as(usize, 0), missed); +} + +test "collection and optional literals fit element by element under the literal rule" { + const gpa = std.testing.allocator; + var wrong: usize = 0; + for (collection_accepted) |c| { + var r = try parseAndCheck(gpa, c.src); + defer r.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), r.parse_diags.len); + if (r.diagnostics.items.len != 0) { + wrong += 1; + for (r.diagnostics.items) |d| std.debug.print("{s}: {s} {s}\n", .{ c.name, d.code.code(), d.primary_message }); + } + } + try std.testing.expectEqual(@as(usize, 0), wrong); +} + +test "a mistyped collection literal in a let is reported once per element" { + const gpa = std.testing.allocator; + var r = try parseAndCheck(gpa, "rule r() {\n let a: string[] = [1, 2]\n}"); + defer r.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), r.parse_diags.len); + try std.testing.expectEqual(@as(usize, 2), r.diagnostics.items.len); + try std.testing.expectEqual(@as(usize, 2), countMessage(r.diagnostics.items, .type_mismatch, "collection element type")); +} diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index cc4ac217..663c3a83 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2791, - else => 2793, + .windows => 2794, + else => 2796, }; } From 55712edafc5940f3416683204211992bc55641a5 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 29 Sep 2026 02:30:46 +0200 Subject: [PATCH 134/141] fix(etch): check the fields of a scene resource, imported or not A scene's resources block checked a field by name and a literal by range, and nothing else: a bool, an array or a string in an int field, and an int literal in a float field, all passed. And a scene file, which may only import, could name no resource at all: the checker looked a resource up in the file's own declarations and refused an imported one as E1789, and the cook registered imported components only. A mistyped resource field is now E0308 ResourceFieldTypeInvalid. An imported resource resolves through its import, its fields read from the arena that declares it, and the cook registers it under its own name and reads an alias as that name. Two components or resources of one name reaching a file are E0101, the runtime keying both by name in one registry. Floor 2796 -> 2804 / 2802, read from the suite. Co-Authored-By: Claude Opus 5.5 --- src/etch/diagnostics.zig | 3 + src/etch/scene_cook.zig | 11 +- src/etch/types.zig | 163 +++++++++++++++++++++-------- tests/etch/import_resolve_test.zig | 34 ++++++ tests/scene/import_cook_test.zig | 101 ++++++++++++++++++ tools/weld_lint/dead_tests.zig | 4 +- 6 files changed, 272 insertions(+), 44 deletions(-) diff --git a/src/etch/diagnostics.zig b/src/etch/diagnostics.zig index e33a94a5..b113d065 100644 --- a/src/etch/diagnostics.zig +++ b/src/etch/diagnostics.zig @@ -63,6 +63,7 @@ pub const DiagnosticCode = enum { prefab_spawn_not_executable, // E0305 PrefabSpawnNotExecutable (spawn("Name") recognized but gated on the prefab runtime; not executable) structural_component_field_unknown, // E0306 StructuralComponentFieldUnknown (spawn/add component-literal field absent from the component decl) structural_component_field_type_invalid, // E0307 StructuralComponentFieldTypeInvalid (spawn/add component-literal field value type mismatch) + resource_field_type_invalid, // E0308 ResourceFieldTypeInvalid (scene `resources` block field value type mismatch) // ── Annotation errors (E0500-E0599) ── annotation_misapplied, // E0502 AnnotationMisapplied @@ -446,6 +447,7 @@ pub const DiagnosticCode = enum { .prefab_spawn_not_executable => "E0305", .structural_component_field_unknown => "E0306", .structural_component_field_type_invalid => "E0307", + .resource_field_type_invalid => "E0308", .annotation_misapplied => "E0502", .annotation_arg_mismatch => "E0503", .requires_cycle => "E0505", @@ -660,6 +662,7 @@ pub const DiagnosticCode = enum { .prefab_spawn_not_executable => "PrefabSpawnNotExecutable", .structural_component_field_unknown => "StructuralComponentFieldUnknown", .structural_component_field_type_invalid => "StructuralComponentFieldTypeInvalid", + .resource_field_type_invalid => "ResourceFieldTypeInvalid", .annotation_misapplied => "AnnotationMisapplied", .annotation_arg_mismatch => "AnnotationArgMismatch", .requires_cycle => "RequiresCycle", diff --git a/src/etch/scene_cook.zig b/src/etch/scene_cook.zig index 0774b827..7effed5e 100644 --- a/src/etch/scene_cook.zig +++ b/src/etch/scene_cook.zig @@ -575,6 +575,7 @@ const Builder = struct { const item = self.ast.import_items.items[decl.items_start + j]; const bound = self.scope.imports.get(types_mod.TypeChecker.importLocalName(item)) orelse continue; if (bound.kind == .component) _ = try self.registerImported(bound, diag_out); + if (bound.kind == .resource) try self.registerImportedResource(bound, diag_out); if (item.alias == 0) continue; // The item that binds its alias last is the one the scope kept. const own = types_mod.TypeChecker.importedExport(self.project, self.ast, target, item) orelse continue; @@ -583,6 +584,13 @@ const Builder = struct { } } + /// Register the resource `entry` names under its own name. + fn registerImportedResource(self: *Builder, entry: ExportEntry, diag_out: ?*[]const u8) CookError!void { + const arena = &self.project.arenas[entry.arena_index]; + const decl = arena.resource_decls.items[arena.itemData(entry.item_id)]; + _ = try self.registerOne(arena, arena.strings.slice(decl.name), decl.fields_start, decl.fields_len, .resource, &.{}, .table, diag_out); + } + /// Register the component `entry` names, after the requisites its own /// module declares, returning the component's name. fn registerImported(self: *Builder, entry: ExportEntry, diag_out: ?*[]const u8) CookError![]const u8 { @@ -1481,7 +1489,8 @@ const Builder = struct { const insts = self.ast.component_instances.items[scene_decl.resources_start .. scene_decl.resources_start + scene_decl.resources_len]; const out = try self.a().alloc(format.ResourceEntry, insts.len); for (insts, 0..) |ci, ri| { - const type_name = self.ast.strings.slice(ci.type_name); + const written_name = self.ast.strings.slice(ci.type_name); + const type_name = self.aliases.get(written_name) orelse written_name; const id = self.registry.idOf(type_name) orelse return fail(diag_out, error.UndeclaredType, "resources block references an undeclared resource type"); if (self.registry.componentKind(id) != .resource) return fail(diag_out, error.ComponentAsResource, "resources block names an entity component, which is no resource"); out[ri] = try self.buildResourceEntry(id, ci, diag_out); diff --git a/src/etch/types.zig b/src/etch/types.zig index d6862e70..6f739d26 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -829,8 +829,8 @@ pub const TypeChecker = struct { try self.validateDeclarations(); } - /// E0101 on two components reaching this file under one name, a component's - /// runtime identity being its name. + /// E0101 on two components or resources reaching this file under one name, + /// the runtime keying both by name in one registry. fn checkComponentIdentities(self: *TypeChecker) !void { const project = self.project orelse return; const Owner = struct { arena: *const AstArena, item: u32 }; @@ -841,9 +841,12 @@ pub const TypeChecker = struct { const spans = self.arena.items.items(.span); var i: u28 = 0; while (i < self.arena.items.len) : (i += 1) { - if (kinds[i] != .component_decl) continue; - const name = self.arena.strings.slice(self.arena.component_decls.items[datas[i]].name); - try owners.put(self.gpa, name, .{ .arena = self.arena, .item = i }); + const name_id = switch (kinds[i]) { + .component_decl => self.arena.component_decls.items[datas[i]].name, + .resource_decl => self.arena.resource_decls.items[datas[i]].name, + else => continue, + }; + try owners.put(self.gpa, self.arena.strings.slice(name_id), .{ .arena = self.arena, .item = i }); } i = 0; while (i < self.arena.items.len) : (i += 1) { @@ -853,15 +856,19 @@ pub const TypeChecker = struct { while (j < decl.items_len) : (j += 1) { const item = self.arena.import_items.items[decl.items_start + j]; const entry = self.importedBinding(importLocalName(item)) orelse continue; - if (entry.kind != .component) continue; const decl_arena = &project.arenas[entry.arena_index]; - const name = decl_arena.strings.slice(decl_arena.component_decls.items[decl_arena.itemData(entry.item_id)].name); + const name_id = switch (entry.kind) { + .component => decl_arena.component_decls.items[decl_arena.itemData(entry.item_id)].name, + .resource => decl_arena.resource_decls.items[decl_arena.itemData(entry.item_id)].name, + else => continue, + }; + const name = decl_arena.strings.slice(name_id); const owner: Owner = .{ .arena = decl_arena, .item = entry.item_id.index }; const gop = try owners.getOrPut(self.gpa, name); if (!gop.found_existing) { gop.value_ptr.* = owner; } else if (!std.meta.eql(gop.value_ptr.*, owner)) { - try self.emit(.duplicate_symbol, .error_, spans[i], "two components named '{s}' reach this file, and a component's runtime identity is its name", .{name}); + try self.emit(.duplicate_symbol, .error_, spans[i], "two components or resources named '{s}' reach this file, and the runtime identifies both by name", .{name}); } } } @@ -2257,7 +2264,7 @@ pub const TypeChecker = struct { /// mismatches. Mirrors `validateDataEntryField` — synthExprE with null ctx; /// check-mode for `.variant` shorthand + anonymous `.{ }` values. Permissive /// on non-builtin declared types (no false positives on Vec3-from-array). - fn checkInstanceField(self: *TypeChecker, owner: []const u8, decl_fields_start: u32, decl_fields_len: u32, field: ast_mod.StructLitField, code_unknown: DiagnosticCode, code_type: ?DiagnosticCode) !void { + fn checkInstanceField(self: *TypeChecker, 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/resource bodies var declared: ?ResolvedType = null; var f: u32 = 0; @@ -2272,13 +2279,6 @@ pub const TypeChecker = struct { try self.emit(code_unknown, .error_, self.arena.exprSpan(field.value), "'{s}' has no field '{s}'", .{ owner, self.arena.strings.slice(field.name) }); return; }; - const tcode = code_type orelse { - // A resource instance value is checked by field name, and for range. - if (d != .builtin) return; - const actual = try self.synthExprE(field.value, null); - if (actual == .builtin) _ = try self.literalTypeFits(d.builtin, field.value, actual.builtin); - return; - }; const actual = blk: { if (d == .enum_t and self.arena.exprKind(field.value) == .tag_path) { break :blk try self.checkEnumShorthand(field.value, d.enum_t); @@ -2293,7 +2293,7 @@ pub const TypeChecker = struct { }; const mismatch = !try self.valueFits(d, field.value, actual); if (mismatch) { - try self.emit(tcode, .error_, self.arena.exprSpan(field.value), "field '{s}' value type does not match its declared type", .{self.arena.strings.slice(field.name)}); + try self.emit(code_type, .error_, self.arena.exprSpan(field.value), "field '{s}' value type does not match its declared type", .{self.arena.strings.slice(field.name)}); } } @@ -2394,19 +2394,13 @@ pub const TypeChecker = struct { } } - /// Cross-arena field check for an imported component instance. 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) 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. + /// Cross-arena field check for an imported component or resource instance: + /// the instance field lives in `self.arena` and the declared fields in + /// `decl_arena`, so names are matched by bytes. A declared type is read + /// across arenas only as a builtin or `string`, so an enum or collection + /// field of an imported resource is checked by name alone. 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 + if (field.name == 0) return; // spread — not produced in instance bodies const field_name_bytes = self.arena.strings.slice(field.name); var declared_type_node: ?NodeId = null; var f: u32 = 0; @@ -2421,10 +2415,6 @@ 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. 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 (!try self.valueFits(.{ .builtin = declared_builtin }, field.value, actual)) { @@ -2433,18 +2423,23 @@ pub const TypeChecker = struct { } /// Resolve a `resources { … }` entry against the resource RTTI: E1789 if not - /// a declared resource; E0303 per unknown field (no resource-field-type code). + /// a declared resource, then E0303 per unknown field and E0308 per mistyped + /// one. An imported resource's fields are read from the arena that declares + /// it. fn checkResourceInstance(self: *TypeChecker, ci: ast_mod.ComponentInstance) !void { - const sym = self.symbols.get(ci.type_name); - if (sym == null or sym.?.kind != .resource) { - try self.emit(.scene_resource_type_unknown, .error_, ci.span, "'{s}' is not a declared resource", .{self.arena.strings.slice(ci.type_name)}); - return; - } - const decl = self.arena.resource_decls.items[self.arena.itemData(sym.?.item_id)]; const owner = self.arena.strings.slice(ci.type_name); + const resource = self.resourceNamed(ci.type_name) orelse { + try self.emit(.scene_resource_type_unknown, .error_, ci.span, "'{s}' is not a declared resource", .{owner}); + return; + }; 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], .resource_field_unknown, null); + const field = self.arena.struct_lit_fields.items[ci.fields_start + f]; + if (resource.arena == self.arena) { + try self.checkInstanceField(owner, resource.decl.fields_start, resource.decl.fields_len, field, .resource_field_unknown, .resource_field_type_invalid); + } else { + try self.checkInstanceFieldForeign(resource.arena, owner, resource.decl.fields_start, resource.decl.fields_len, field, .resource_field_unknown, .resource_field_type_invalid); + } } } @@ -15562,3 +15557,89 @@ test "a mistyped collection literal in a let is reported once per element" { try std.testing.expectEqual(@as(usize, 2), r.diagnostics.items.len); try std.testing.expectEqual(@as(usize, 2), countMessage(r.diagnostics.items, .type_mismatch, "collection element type")); } + +const scene_resource_refused = [_]UnitCase{ + .{ .name = "bool into an int field", .code = .resource_field_type_invalid, .src = + \\resource Mode { players: int = 4, rate: float = 1.0, title: string = "x", tag: Tag = .a } + \\enum Tag { a, b } + \\component C { v: int = 0 } + \\scene "S" { + \\ resources { Mode { players: true } } + \\ entity "e" { uuid: "7b3e2f1a-42a3-4f2b-8c9d-a3f2b1c98d4e" C { v: 1 } } + \\} + }, + .{ .name = "array into an int field", .code = .resource_field_type_invalid, .src = + \\resource Mode { players: int = 4, rate: float = 1.0, title: string = "x", tag: Tag = .a } + \\enum Tag { a, b } + \\component C { v: int = 0 } + \\scene "S" { + \\ resources { Mode { players: [1, 2] } } + \\ entity "e" { uuid: "7b3e2f1a-42a3-4f2b-8c9d-a3f2b1c98d4e" C { v: 1 } } + \\} + }, + .{ .name = "int literal into a float field", .code = .resource_field_type_invalid, .src = + \\resource Mode { players: int = 4, rate: float = 1.0, title: string = "x", tag: Tag = .a } + \\enum Tag { a, b } + \\component C { v: int = 0 } + \\scene "S" { + \\ resources { Mode { rate: 2 } } + \\ entity "e" { uuid: "7b3e2f1a-42a3-4f2b-8c9d-a3f2b1c98d4e" C { v: 1 } } + \\} + }, + .{ .name = "int into a string field", .code = .resource_field_type_invalid, .src = + \\resource Mode { players: int = 4, rate: float = 1.0, title: string = "x", tag: Tag = .a } + \\enum Tag { a, b } + \\component C { v: int = 0 } + \\scene "S" { + \\ resources { Mode { title: 3 } } + \\ entity "e" { uuid: "7b3e2f1a-42a3-4f2b-8c9d-a3f2b1c98d4e" C { v: 1 } } + \\} + }, + .{ .name = "unknown variant into an enum field", .code = .enum_variant_not_found, .src = + \\resource Mode { players: int = 4, rate: float = 1.0, title: string = "x", tag: Tag = .a } + \\enum Tag { a, b } + \\component C { v: int = 0 } + \\scene "S" { + \\ resources { Mode { tag: .z } } + \\ entity "e" { uuid: "7b3e2f1a-42a3-4f2b-8c9d-a3f2b1c98d4e" C { v: 1 } } + \\} + }, +}; + +const scene_resource_accepted = [_]ClosureJumpCase{ + .{ .name = "each field of its type", .src = + \\resource Mode { players: int = 4, rate: float = 1.0, title: string = "x", tag: Tag = .a } + \\enum Tag { a, b } + \\component C { v: int = 0 } + \\scene "S" { + \\ resources { Mode { players: 8, rate: 2.5, title: "wave", tag: .b } } + \\ entity "e" { uuid: "7b3e2f1a-42a3-4f2b-8c9d-a3f2b1c98d4e" C { v: 1 } } + \\} + }, +}; + +test "a scene resource field of the wrong type is refused" { + const gpa = std.testing.allocator; + var missed: usize = 0; + for (scene_resource_refused) |c| { + var r = try parseAndCheck(gpa, c.src); + defer r.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), r.parse_diags.len); + if (countMessage(r.diagnostics.items, c.code, "") == 0) { + missed += 1; + std.debug.print("not refused: {s}\n", .{c.name}); + } + } + try std.testing.expectEqual(@as(usize, 0), missed); +} + +test "a scene resource field of its type is accepted" { + const gpa = std.testing.allocator; + for (scene_resource_accepted) |c| { + var r = try parseAndCheck(gpa, c.src); + defer r.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), r.parse_diags.len); + for (r.diagnostics.items) |d| std.debug.print("{s}: {s} {s}\n", .{ c.name, d.code.code(), d.primary_message }); + try std.testing.expectEqual(@as(usize, 0), r.diagnostics.items.len); + } +} diff --git a/tests/etch/import_resolve_test.zig b/tests/etch/import_resolve_test.zig index c5a38222..692659e5 100644 --- a/tests/etch/import_resolve_test.zig +++ b/tests/etch/import_resolve_test.zig @@ -269,6 +269,8 @@ const position_cases = [_]Case{ .{ .name = "malformed when has resource", .body = "rule r(entity: Entity) when entity has R { }" }, .{ .name = "malformed when resource comp", .body = "rule r() when resource C { }" }, .{ .name = "malformed emit component", .body = "rule r() { emit C { v: 1 } }" }, + .{ .name = "scene resource", .body = "scene \"S\" {\n resources { R { v: 1 } }\n entity \"e\" { uuid: \"7b3e2f1a-42a3-4f2b-8c9d-a3f2b1c98d4e\" C { v: 1 } }\n}" }, + .{ .name = "malformed scene resource field type", .body = "scene \"S\" {\n resources { R { v: true } }\n entity \"e\" { uuid: \"7b3e2f1a-42a3-4f2b-8c9d-a3f2b1c98d4e\" C { v: 1 } }\n}" }, }; fn codesOf(files: []const etch.ProjectFile, out: *std.ArrayListUnmanaged(DiagnosticCode)) !void { @@ -334,3 +336,35 @@ test "one component under two local names is one type" { try etch.validateProject(gpa, &files, &diags); try std.testing.expectEqual(@as(usize, 0), diags.items.len); } + +test "a mistyped field of an imported resource in a scene file is refused" { + const gpa = std.testing.allocator; + const files = [_]etch.ProjectFile{ + .{ .name = "lib.etch", .source = "resource Mode { players: int = 4, title: string = \"x\" }\ncomponent C { v: int = 0 }\n" }, + .{ .name = "level.scene.etch", .source = + \\import lib { Mode, C } + \\scene "S" { + \\ resources { Mode { players: true, title: 3 } } + \\ entity "e" { uuid: "7b3e2f1a-42a3-4f2b-8c9d-a3f2b1c98d4e" C { v: 1 } } + \\} + }, + }; + var diags: std.ArrayListUnmanaged(etch.Diagnostic) = .empty; + defer deinitDiags(gpa, &diags); + try etch.validateProject(gpa, &files, &diags); + try std.testing.expectEqual(@as(usize, 2), diags.items.len); + try std.testing.expectEqual(@as(usize, 2), countCode(diags.items, .resource_field_type_invalid)); +} + +test "a component and an imported resource of one name are refused, the runtime naming both alike" { + const gpa = std.testing.allocator; + const files = [_]etch.ProjectFile{ + .{ .name = "lib.etch", .source = "resource Mode { x: int = 0 }\n" }, + .{ .name = "main.etch", .source = "import lib { Mode as Setting }\ncomponent Mode { v: int = 0 }\n" }, + }; + var diags: std.ArrayListUnmanaged(etch.Diagnostic) = .empty; + defer deinitDiags(gpa, &diags); + try etch.validateProject(gpa, &files, &diags); + try std.testing.expectEqual(@as(usize, 1), diags.items.len); + try std.testing.expectEqual(@as(usize, 1), countCode(diags.items, .duplicate_symbol)); +} diff --git a/tests/scene/import_cook_test.zig b/tests/scene/import_cook_test.zig index d4450e7e..c191f113 100644 --- a/tests/scene/import_cook_test.zig +++ b/tests/scene/import_cook_test.zig @@ -665,3 +665,104 @@ test "a layer file beside a scene does not refuse the scene's cook" { var cooked = try scene_cook.cookSceneInProject(gpa, &files, 2, null, null); cooked.deinit(gpa); } + +const game = + \\resource GameMode { max_players: int = 4, title: string = "x" } + \\component Health { current: i32 = 100, max: i32 = 100 } +; + +test "a scene importing its resource cooks as one declaring it" { + const gpa = std.testing.allocator; + const files = [_]ProjectFile{ + .{ .name = "src/game.etch", .source = game }, + .{ .name = "src/level.scene.etch", .source = + \\import game { GameMode, Health } + \\scene "Level" { + \\ resources { GameMode { max_players: 8, title: "wave" } } + \\ entity "npc" { uuid: "00000000-0000-0000-0000-000000000002" Health { max: 40 } } + \\} + }, + }; + try expectChecked(&files); + var imported = try scene_cook.cookSceneInProject(gpa, &files, 1, null, null); + defer imported.deinit(gpa); + const imported_bytes = try written(&imported); + defer gpa.free(imported_bytes); + var declared = try scene_cook.cook(gpa, game ++ + \\ + \\scene "Level" { + \\ resources { GameMode { max_players: 8, title: "wave" } } + \\ entity "npc" { uuid: "00000000-0000-0000-0000-000000000002" Health { max: 40 } } + \\} + , null); + defer declared.deinit(gpa); + const declared_bytes = try written(&declared); + defer gpa.free(declared_bytes); + try std.testing.expectEqualSlices(u8, declared_bytes, imported_bytes); +} + +test "a scene importing its resource under an alias cooks under the resource's own name" { + const gpa = std.testing.allocator; + const files = [_]ProjectFile{ + .{ .name = "src/game.etch", .source = game }, + .{ .name = "src/level.scene.etch", .source = + \\import game { GameMode as Mode, Health } + \\scene "Level" { + \\ resources { Mode { max_players: 8 } } + \\ entity "npc" { uuid: "00000000-0000-0000-0000-000000000002" Health { max: 40 } } + \\} + }, + }; + try expectChecked(&files); + var imported = try scene_cook.cookSceneInProject(gpa, &files, 1, null, null); + defer imported.deinit(gpa); + const imported_bytes = try written(&imported); + defer gpa.free(imported_bytes); + var declared = try scene_cook.cook(gpa, game ++ + \\ + \\scene "Level" { + \\ resources { GameMode { max_players: 8 } } + \\ entity "npc" { uuid: "00000000-0000-0000-0000-000000000002" Health { max: 40 } } + \\} + , null); + defer declared.deinit(gpa); + const declared_bytes = try written(&declared); + defer gpa.free(declared_bytes); + try std.testing.expectEqualSlices(u8, declared_bytes, imported_bytes); +} + +test "two imported resources under one name refuse the cook" { + const files = [_]ProjectFile{ + .{ .name = "src/a.etch", .source = "resource Mode { x: int = 0 }\ncomponent Health { current: i32 = 100 }" }, + .{ .name = "src/b.etch", .source = "resource Mode { y: int = 0 }" }, + .{ .name = "src/level.scene.etch", .source = + \\import a { Mode, Health } + \\import b { Mode as Other } + \\scene "Level" { + \\ resources { Mode { x: 1 } Other { y: 2 } } + \\ entity "npc" { uuid: "00000000-0000-0000-0000-000000000002" Health { current: 40 } } + \\} + }, + }; + try expectCheckReports(&files, .duplicate_symbol); + const gpa = std.testing.allocator; + try std.testing.expectError(error.DuplicateType, scene_cook.cookSceneInProject(gpa, &files, 2, null, null)); +} + +test "one resource imported under two names cooks once" { + const gpa = std.testing.allocator; + const files = [_]ProjectFile{ + .{ .name = "src/game.etch", .source = game }, + .{ .name = "src/level.scene.etch", .source = + \\import game { GameMode, Health } + \\import game { GameMode as Mode } + \\scene "Level" { + \\ resources { Mode { max_players: 8 } } + \\ entity "npc" { uuid: "00000000-0000-0000-0000-000000000002" Health { max: 40 } } + \\} + }, + }; + try expectChecked(&files); + var cooked = try scene_cook.cookSceneInProject(gpa, &files, 1, null, null); + defer cooked.deinit(gpa); +} diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 663c3a83..02023aa3 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2794, - else => 2796, + .windows => 2802, + else => 2804, }; } From 84053aed78b559c593289b6c643ff8df64b2b373 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 29 Sep 2026 02:42:54 +0200 Subject: [PATCH 135/141] fix(etch): check a service call's arguments by type and label A service call was checked by method name and arity and nothing else: each argument was typed and the type thrown away, so a bool, a string or a struct passed to an int parameter checked clean, a label the method does not declare passed, and the interpreter bound labeled arguments by position, so `toy.pair(b: 2, a: 7)` computed 27. Each argument is now bound to its parameter by label, as for a declared fn, and must fit the parameter's type, read from the arena that declares the service; the interpreter binds by label too. An int no longer converts into a float parameter at run time either, on the interpreter's path and on `Registry.call`, since Etch converts neither numeric kind into the other anywhere. The toy service gains `pair` and `half` to exercise both. Two tests changed: the registry test that asserted the int-to-float conversion now asserts its refusal, and the emitter test's two non-vacuity counts of the toy's methods go from 3 to 5. Floor 2804 -> 2808 / 2806, read from the suite. Co-Authored-By: Claude Opus 5.5 --- src/etch/interp.zig | 22 ++--- src/etch/services.zig | 22 +---- src/etch/types.zig | 100 ++++++++++++++++++++-- tests/etch_bindgen/detch_emitter_test.zig | 4 +- tests/etch_services/service_call_test.zig | 33 +++++++ tests/etch_services/toy.d.etch | 6 ++ tests/etch_services/toy_service.zig | 25 ++++-- tools/weld_lint/dead_tests.zig | 4 +- 8 files changed, 170 insertions(+), 46 deletions(-) diff --git a/src/etch/interp.zig b/src/etch/interp.zig index d503dce7..ebb5f27c 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -4527,14 +4527,20 @@ pub const Interpreter = struct { ) StmtError!Value { if (mc.args_len != spec.params.len) return error.RuntimeFailure; + // Evaluated in source order, then bound to the parameters by label. + const values = try self.gpa.alloc(Value, mc.args_len); + defer self.gpa.free(values); + var j: u32 = 0; + while (j < mc.args_len) : (j += 1) { + values[j] = try self.evalExpr(world, locals, @bitCast(self.ast.extra.items[mc.args_start + j])); + } var args: std.ArrayListUnmanaged(services_mod.Arg) = .empty; defer args.deinit(self.gpa); try args.ensureTotalCapacity(self.gpa, spec.params.len); - var i: u32 = 0; - while (i < mc.args_len) : (i += 1) { - const arg_id: NodeId = @bitCast(self.ast.extra.items[mc.args_start + i]); - const v = try self.evalExpr(world, locals, arg_id); - args.appendAssumeCapacity(try self.valueToArg(v, spec.params[i].type)); + for (spec.params, 0..) |param, i| { + const name = self.ast.strings.find(param.name) orelse 0; + const idx = self.ast.callArgIndexForParam(mc.args_start, mc.args_len, mc.names_start, @intCast(i), name) orelse return error.RuntimeFailure; + args.appendAssumeCapacity(try self.valueToArg(values[idx], param.type)); } const ret = spec.call(entry.ctx, args.items) catch |err| { @@ -4549,11 +4555,7 @@ pub const Interpreter = struct { fn valueToArg(self: *Interpreter, v: Value, want: services_mod.TypeRef) StmtError!services_mod.Arg { return switch (want) { .int_ => if (v == .int_) services_mod.Arg{ .int_ = v.int_ } else error.RuntimeFailure, - .float_ => switch (v) { - .float_ => |f| services_mod.Arg{ .float_ = f }, - .int_ => |n| services_mod.Arg{ .float_ = @floatFromInt(n) }, - else => error.RuntimeFailure, - }, + .float_ => if (v == .float_) services_mod.Arg{ .float_ = v.float_ } else error.RuntimeFailure, .bool_ => if (v == .bool_) services_mod.Arg{ .bool_ = v.bool_ } else error.RuntimeFailure, .string_ => services_mod.Arg{ .string_ = self.stringBytes(v) orelse return error.RuntimeFailure }, .entity_ => if (v == .entity_id) services_mod.Arg{ .entity_ = v.entity_id } else error.RuntimeFailure, diff --git a/src/etch/services.zig b/src/etch/services.zig index 588ede2d..f5c26e4b 100644 --- a/src/etch/services.zig +++ b/src/etch/services.zig @@ -268,21 +268,7 @@ fn typeRefOf(comptime T: type) TypeRef { fn argToZig(comptime T: type, a: Arg) !T { return switch (T) { i64 => if (a == .int_) a.int_ else error.ServiceArgTypeMismatch, - f64 => switch (a) { - .float_ => |f| f, - // An `int` argument reaching a `float` parameter widens here — and - // NOT on the path a rule takes: `interp.valueToArg` has already - // widened it, so from `callService` this arm never runs. It is - // reached only through `Registry.call`, which this file records - // below as having no production caller. What has no second home is - // the ABSENCE of a check: `checkServiceCall` synthesises each - // argument and DISCARDS the type (`_ = try synthExprE`), so nothing - // compares an argument against its declared parameter and the - // widening is accepted with no diagnostic. Delete this arm and the - // language does not change; delete `valueToArg`'s and it does. - .int_ => |i| @floatFromInt(i), - else => error.ServiceArgTypeMismatch, - }, + f64 => if (a == .float_) a.float_ else error.ServiceArgTypeMismatch, bool => if (a == .bool_) a.bool_ else error.ServiceArgTypeMismatch, []const u8 => if (a == .string_) a.string_ else error.ServiceArgTypeMismatch, u64 => if (a == .entity_) a.entity_ else error.ServiceArgTypeMismatch, @@ -569,11 +555,9 @@ test "a registered service calls through and its error union comes back intact" try std.testing.expectError(error.ServiceArgCountMismatch, reg.call("toy", "echo", &.{})); try std.testing.expectError(error.ServiceArgTypeMismatch, reg.call("toy", "echo", &.{.{ .bool_ = true }})); - // An `int` argument widens into a `float` parameter — the rule Etch already - // applies at every other numeric boundary — and the reverse does NOT hold, - // which is what makes the widening a decision rather than a loose check. + // Neither numeric kind converts into the other, as nowhere in Etch. try std.testing.expectEqual(Ret{ .float_ = 2.5 }, try reg.call("toy", "half", &.{.{ .float_ = 5.0 }})); - try std.testing.expectEqual(Ret{ .float_ = 2.5 }, try reg.call("toy", "half", &.{.{ .int_ = 5 }})); + try std.testing.expectError(error.ServiceArgTypeMismatch, reg.call("toy", "half", &.{.{ .int_ = 5 }})); try std.testing.expectError(error.ServiceArgTypeMismatch, reg.call("toy", "echo", &.{.{ .float_ = 5.0 }})); } diff --git a/src/etch/types.zig b/src/etch/types.zig index 6f739d26..d177831a 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -1283,10 +1283,9 @@ pub const TypeChecker = struct { } } - /// Resolve `svc.method(args)` against a declared `service`. - /// Names and arity only — this gate owns the RESOLUTION; the runtime - /// dispatch lives in the interpreter, and the argument-type confrontation needs the foreign - /// arena's type nodes, bounded below. + /// Resolve `svc.method(args)` against a declared `service`: the method by + /// name, then the arguments' arity, labels and types against its parameters, + /// which live in the declaring arena. fn checkServiceCall( self: *TypeChecker, id: NodeId, @@ -1309,11 +1308,12 @@ pub const TypeChecker = struct { } // Arguments are synthesised whatever the outcome, so an error inside an - // argument is reported even when the method name is wrong — the same - // discipline `checkMethodArgs` follows. + // argument is reported even when the method name is wrong. + var arg_types: std.ArrayListUnmanaged(ResolvedType) = .empty; + defer arg_types.deinit(self.gpa); var ai: u32 = 0; while (ai < mc.args_len) : (ai += 1) { - _ = try self.synthExprE(@bitCast(self.arena.extra.items[mc.args_start + ai]), ctx_opt); + try arg_types.append(self.gpa, try self.synthExprE(@bitCast(self.arena.extra.items[mc.args_start + ai]), ctx_opt)); } const method = found orelse { @@ -1342,6 +1342,27 @@ pub const TypeChecker = struct { return ResolvedType.unknown; } + // A parameter name the caller never wrote is interned nowhere in its + // pool, and `0` is a name no label carries. + var pnames: std.ArrayListUnmanaged(StringId) = .empty; + defer pnames.deinit(self.gpa); + var pi: u32 = 0; + while (pi < method.params_len) : (pi += 1) { + const p = svc.arena.fn_params.items[method.params_start + pi]; + try pnames.append(self.gpa, self.arena.strings.find(svc.arena.strings.slice(p.name)) orelse 0); + } + if (try self.checkCallBinding(id, mc.args_start, mc.args_len, mc.names_start, pnames.items, "service method", mc.method_name)) { + pi = 0; + while (pi < method.params_len) : (pi += 1) { + const idx = self.arena.callArgIndexForParam(mc.args_start, mc.args_len, mc.names_start, pi, pnames.items[pi]) orelse continue; + const arg: NodeId = @bitCast(self.arena.extra.items[mc.args_start + idx]); + const ptype = self.foreignFieldType(svc.arena, svc.arena.fn_params.items[method.params_start + pi].type_node); + if (!try self.valueFits(ptype, arg, arg_types.items[idx])) { + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(arg), "argument type does not match the parameter type of service method '{s}.{s}'", .{ svc_slice, method_slice }); + } + } + } + return self.foreignReturnType(svc.arena, method); } @@ -15643,3 +15664,68 @@ test "a scene resource field of its type is accepted" { try std.testing.expectEqual(@as(usize, 0), r.diagnostics.items.len); } } + +const service_decl = + \\service svc { + \\ fn echo(n: int) -> int + \\ fn half(x: float) -> float + \\ fn pair(a: int, b: string) -> int + \\} +; + +const ServiceArgCase = struct { name: []const u8, call: []const u8, code: DiagnosticCode = .type_mismatch }; + +const service_args_refused = [_]ServiceArgCase{ + .{ .name = "bool into an int parameter", .call = "let v = svc.echo(true)", .code = .type_mismatch }, + .{ .name = "string into an int parameter", .call = "let v = svc.echo(\"x\")", .code = .type_mismatch }, + .{ .name = "struct into an int parameter", .call = "let v = svc.echo(P { x: 1 })", .code = .type_mismatch }, + .{ .name = "int literal into a float parameter", .call = "let v = svc.half(2)", .code = .type_mismatch }, + .{ .name = "int into a string parameter", .call = "let v = svc.pair(1, 2)", .code = .type_mismatch }, + .{ .name = "reordered labels of the wrong types", .call = "let v = svc.pair(b: 1, a: \"x\")", .code = .type_mismatch }, + .{ .name = "a label the method does not declare", .call = "let v = svc.echo(zzz: 5)", .code = .arg_count_mismatch }, + .{ .name = "a label already bound positionally", .call = "let v = svc.pair(1, a: 2)", .code = .arg_count_mismatch }, +}; + +const service_args_accepted = [_]ServiceArgCase{ + .{ .name = "int into an int parameter", .call = "let v = svc.echo(5)" }, + .{ .name = "float into a float parameter", .call = "let v = svc.half(2.5)" }, + .{ .name = "each argument of its type", .call = "let v = svc.pair(1, \"x\")" }, + .{ .name = "reordered labels", .call = "let v = svc.pair(b: \"x\", a: 1)" }, + .{ .name = "a label", .call = "let v = svc.echo(n: 5)" }, +}; + +fn serviceCaller(gpa: std.mem.Allocator, call: []const u8) ![]u8 { + return std.mem.concat(gpa, u8, &.{ "struct P { x: int = 0 }\nrule r() {\n ", call, "\n}\n" }); +} + +test "a service argument of the wrong type or label is refused" { + const gpa = std.testing.allocator; + var missed: usize = 0; + for (service_args_refused) |c| { + const src = try serviceCaller(gpa, c.call); + defer gpa.free(src); + var out = try checkServiceProject(gpa, service_decl, src); + defer out.deinit(gpa); + if (countMessage(out.diagnostics.items, c.code, "") == 0) { + missed += 1; + std.debug.print("not refused: {s}\n", .{c.name}); + } + } + try std.testing.expectEqual(@as(usize, 0), missed); +} + +test "a service argument of its parameter's type and label is accepted" { + const gpa = std.testing.allocator; + var wrong: usize = 0; + for (service_args_accepted) |c| { + const src = try serviceCaller(gpa, c.call); + defer gpa.free(src); + var out = try checkServiceProject(gpa, service_decl, src); + defer out.deinit(gpa); + if (out.diagnostics.items.len != 0) { + wrong += 1; + for (out.diagnostics.items) |d| std.debug.print("{s}: {s} {s}\n", .{ c.name, d.code.code(), d.primary_message }); + } + } + try std.testing.expectEqual(@as(usize, 0), wrong); +} diff --git a/tests/etch_bindgen/detch_emitter_test.zig b/tests/etch_bindgen/detch_emitter_test.zig index 884061c0..cb447e20 100644 --- a/tests/etch_bindgen/detch_emitter_test.zig +++ b/tests/etch_bindgen/detch_emitter_test.zig @@ -36,7 +36,7 @@ test "the committed .d.etch matches what the emitter produces" { // NON-VACUITY: the comparison had something to compare. A zero-method spec, // or an emitter returning the empty string, would satisfy the assertion // above and prove nothing. - try std.testing.expectEqual(@as(usize, 3), toy.spec.methods.len); + try std.testing.expectEqual(@as(usize, 5), toy.spec.methods.len); try std.testing.expect(rendered.len > 100); try std.testing.expect(std.mem.indexOf(u8, rendered, emit_detch.header_line) != null); } @@ -134,7 +134,7 @@ test "the emitted artifact is a .d.etch the compiler accepts" { try std.testing.expectEqual(@as(usize, 1), pr.ast.service_decls.items.len); const decl = pr.ast.service_decls.items[0]; try std.testing.expectEqualStrings("toy", pr.ast.strings.slice(decl.name)); - try std.testing.expectEqual(@as(u32, 3), decl.methods_len); + try std.testing.expectEqual(@as(u32, 5), decl.methods_len); // Round trip on the two properties the emitter DERIVES, per method rather // than in aggregate: bodyless, and `throws` exactly where the spec says. diff --git a/tests/etch_services/service_call_test.zig b/tests/etch_services/service_call_test.zig index fa465baa..30ddd92f 100644 --- a/tests/etch_services/service_call_test.zig +++ b/tests/etch_services/service_call_test.zig @@ -306,3 +306,36 @@ test "a local shadows a service, in the checker and in the interpreter alike" { try std.testing.expect(std.mem.indexOf(u8, d.primary_message, "service") == null); } } + +test "a service call binds labeled arguments by name" { + const gpa = std.testing.allocator; + var r = try run(gpa, accumulator ++ + \\ + \\rule use_service(entity: Entity) + \\ when entity has Acc + \\{ + \\ entity.get_mut(Acc).out = toy.pair(b: 2, a: 7) + \\} + , false); + defer r.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), r.diagnostics.items.len); + try std.testing.expectEqual(@as(u64, 0), r.runtime_errors); + try std.testing.expectEqual(@as(i64, 72), r.out); +} + +test "an int argument for a float parameter is refused, and fails rather than converts at run time" { + const gpa = std.testing.allocator; + var r = try run(gpa, accumulator ++ + \\ + \\rule use_service(entity: Entity) + \\ when entity has Acc + \\{ + \\ let h = toy.half(4) + \\ entity.get_mut(Acc).out = 1 + \\} + , false); + defer r.deinit(gpa); + try std.testing.expectEqual(@as(usize, 1), r.diagnostics.items.len); + try std.testing.expectEqual(@as(u64, 1), r.runtime_errors); + try std.testing.expectEqual(@as(i64, 0), r.out); +} diff --git a/tests/etch_services/toy.d.etch b/tests/etch_services/toy.d.etch index 8e839117..1b1a62ec 100644 --- a/tests/etch_services/toy.d.etch +++ b/tests/etch_services/toy.d.etch @@ -11,4 +11,10 @@ service toy { fn risky(n: int) throws -> int fn label(prefix: string) -> string + + /// Return a times ten plus b. + fn pair(a: int, b: int) -> int + + /// Return half of x. + fn half(x: float) -> float } diff --git a/tests/etch_services/toy_service.zig b/tests/etch_services/toy_service.zig index 801bc18d..b9af60e0 100644 --- a/tests/etch_services/toy_service.zig +++ b/tests/etch_services/toy_service.zig @@ -3,9 +3,10 @@ //! on a toy service and never on the physics, which is only the first //! consumer. //! -//! It is deliberately not physics-shaped: three methods covering the three -//! things the tree-walker path has to get right — a value comes back, a Zig -//! error union becomes an Etch `throw`, and a string crosses in both directions. +//! It is deliberately not physics-shaped: its methods cover what the +//! tree-walker path has to get right — a value comes back, a Zig error union +//! becomes an Etch `throw`, a string crosses in both directions, labeled +//! arguments bind by name, and a float parameter takes no int. const std = @import("std"); const services = @import("weld_etch").services; @@ -47,9 +48,19 @@ pub fn label(ctx: *Ctx, prefix: []const u8) []const u8 { return ctx.label_buf[0 .. n + 1]; } -/// The toy's `ServiceSpec` (`etch-abi-zig.md` §8.1). Parameter NAMES are -/// declared because Zig carries none; every type and the `throws` flag are -/// derived from the implementations above. +/// Returns `a * 10 + b`, so an argument bound to the wrong parameter shows in +/// the result. +pub fn pair(ctx: *Ctx, a: i64, b: i64) i64 { + ctx.calls += 1; + return a * 10 + b; +} + +/// Returns `x / 2`: the one float parameter, which an `int` never reaches. +pub fn half(ctx: *Ctx, x: f64) f64 { + ctx.calls += 1; + return x / 2; +} + /// Payload of the toy event a Tier 1 module publishes to Etch. /// `extern` because it crosses a module boundary; the emitter refuses a struct /// with no layout guarantee, and the layout is what makes the field ORDER a @@ -78,6 +89,8 @@ pub const spec = services.ServiceSpec{ services.method("echo", "Add the service's base to n.", *Ctx, &.{"n"}, echo), services.method("risky", "Fail when n is greater than two.", *Ctx, &.{"n"}, risky), services.method("label", null, *Ctx, &.{"prefix"}, label), + services.method("pair", "Return a times ten plus b.", *Ctx, &.{ "a", "b" }, pair), + services.method("half", "Return half of x.", *Ctx, &.{"x"}, half), }, }; diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 02023aa3..fba57c78 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2802, - else => 2804, + .windows => 2806, + else => 2808, }; } From 21e5aa497b0cb4a6ebdbc85833aedcc73611b8f7 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 29 Sep 2026 02:58:58 +0200 Subject: [PATCH 136/141] fix(etch): read a const by name, lowercase or uppercase A const was declared, registered and exported, and read nowhere. A lowercase name was looked up among the locals only and refused as E0102; an uppercase one, which the grammar reads as an IDENT like any other and the parser keeps as a path, typed unknown whether defined or not, so `let x: bool = MAX` and an undefined `NOPE` both checked clean. At run time every const read failed. A name that is no local now resolves to a const, the file's own or an imported one, typed by its declaration; an uppercase name that names nothing, no declaration, import or builtin type, is E0102. The interpreter indexes the consts at compile time and reads a string, an enum variant or a folded value. Floor 2808 -> 2811 / 2809, read from the suite. Co-Authored-By: Claude Opus 5.5 --- src/etch/interp.zig | 108 ++++++++++++++++- src/etch/types.zig | 187 +++++++++++++++++++++++++++++ tests/etch/import_resolve_test.zig | 5 +- tools/weld_lint/dead_tests.zig | 4 +- 4 files changed, 295 insertions(+), 9 deletions(-) diff --git a/src/etch/interp.zig b/src/etch/interp.zig index ebb5f27c..7e4464ca 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -1135,6 +1135,8 @@ pub const Interpreter = struct { /// Top-level `fn` declarations keyed by name, for /// resolving a free-function call `f(args)` whose callee names a `fn`. fns: std.AutoHashMapUnmanaged(StringId, ast_mod.FnDecl) = .empty, + /// Top-level `const` declarations by name, read where a name is no local. + consts: std.AutoHashMapUnmanaged(StringId, ast_mod.ConstDecl) = .empty, /// Inherent `impl` methods keyed by `methodKey(type_name, method_name)`, for /// `recv.method()` / `Type.assoc()` dispatch. methods: std.AutoHashMapUnmanaged(u64, ast_mod.FnDecl) = .empty, @@ -1414,6 +1416,7 @@ pub const Interpreter = struct { for (self.run_strings.items) |s| self.gpa.free(s); self.run_strings.deinit(self.gpa); self.fns.deinit(self.gpa); + self.consts.deinit(self.gpa); self.methods.deinit(self.gpa); self.struct_decls.deinit(self.gpa); self.enum_decls.deinit(self.gpa); @@ -1588,13 +1591,17 @@ pub const Interpreter = struct { // resolution. var fns: std.AutoHashMapUnmanaged(StringId, ast_mod.FnDecl) = .empty; errdefer fns.deinit(gpa); + var consts: std.AutoHashMapUnmanaged(StringId, ast_mod.ConstDecl) = .empty; + errdefer consts.deinit(gpa); i = 0; while (i < ast.items.len) : (i += 1) { const kind = ast.items.items(.kind)[i]; const data = ast.items.items(.data)[i]; - if (kind != .fn_decl) continue; - const decl = ast.fn_decls.items[data]; - try fns.put(gpa, decl.name, decl); + switch (kind) { + .fn_decl => try fns.put(gpa, ast.fn_decls.items[data].name, ast.fn_decls.items[data]), + .const_decl => try consts.put(gpa, ast.const_decls.items[data].name, ast.const_decls.items[data]), + else => {}, + } } // Pass D — index inherent `impl` methods by `(type_name, method_name)` @@ -1727,6 +1734,7 @@ pub const Interpreter = struct { .bridge = bridge, .rule_descs = slice, .fns = fns, + .consts = consts, .methods = methods, .struct_decls = struct_decls, .enum_decls = enum_decls, @@ -5159,7 +5167,11 @@ pub const Interpreter = struct { /// `null` when the field is not enum-typed or the variant is unknown /// (the resolver has already rejected those programs). fn enumFieldShorthand(self: *Interpreter, f: ast_mod.Field, value: NodeId) ?Value { - const ename = self.ast.resolveTypeAliasName(self.ast.namedTypeName(f.type_node) orelse return null); + return self.enumShorthandOf(f.type_node, value); + } + + fn enumShorthandOf(self: *Interpreter, type_node: NodeId, value: NodeId) ?Value { + const ename = self.ast.resolveTypeAliasName(self.ast.namedTypeName(type_node) orelse return null); const edecl = self.enum_decls.get(ename) orelse return null; // Expression-position `tag_path` data IS the variant ident (the // parser interns it directly; multi-segment is a parse error there). @@ -5168,6 +5180,19 @@ pub const Interpreter = struct { return Value{ .enum_value = .{ .type_name = ename, .variant = vidx } }; } + /// The value of the top-level `const` `name` names, `null` when none does. + fn constValue(self: *Interpreter, name: StringId) StmtError!?Value { + const decl = self.consts.get(name) orelse return null; + return switch (self.ast.exprKind(decl.value)) { + .string_lit => Value{ .string_id = self.ast.exprData(decl.value) }, + .tag_path => self.enumShorthandOf(decl.type_node, decl.value) orelse error.RuntimeFailure, + else => evalConst(self.gpa, self.ast, decl.value) catch |err| switch (err) { + error.OutOfMemory => error.OutOfMemory, + else => error.RuntimeFailure, + }, + }; + } + /// Resolve an enum assignment RHS against a known enum type id. A bare /// `.variant` shorthand resolves to a typed /// `enum_value` against `enum_type_name_id`; any other form (a variable, a @@ -6371,7 +6396,7 @@ pub const Interpreter = struct { .ident => { const name_id: StringId = data; if (locals.get(name_id)) |v| return v; - return error.RuntimeFailure; + return (try self.constValue(data)) orelse error.RuntimeFailure; }, .field_access => { const fa = self.ast.field_accesses.items[data]; @@ -6963,7 +6988,8 @@ pub const Interpreter = struct { try dbuf.commands.append(dbuf.gpa, .{ .spawn = .{ .component_ids = ids, .payloads = payloads } }); return Value{ .unit = {} }; }, - else => return error.RuntimeFailure, // path / tag_path / unsupported variants + .path => return (try self.constValue(data)) orelse error.RuntimeFailure, + else => return error.RuntimeFailure, // tag_path / unsupported variants } } }; @@ -18190,3 +18216,73 @@ test "a continue that leaves its closure fails the rule and does not continue th try std.testing.expectEqual(RuntimeErrorKind.ControlFlowEscapesClosure, le.kind); try std.testing.expectEqual(@as(i64, 0), readResourceIntNamed(&world, "Out", "n")); } + +const ConstRun = struct { name: []const u8, src: []const u8, out: i64 }; + +const const_runs = [_]ConstRun{ + .{ .name = "a lowercase const", .out = 7, .src = + \\resource Out { n: int = 0 } + \\const limit: int = 7 + \\const MAX: int = 9 + \\const label: string = "hi" + \\rule r() when resource Out { + \\ get_mut(Out).n = limit + \\} + }, + .{ .name = "an uppercase const", .out = 9, .src = + \\resource Out { n: int = 0 } + \\const limit: int = 7 + \\const MAX: int = 9 + \\const label: string = "hi" + \\rule r() when resource Out { + \\ get_mut(Out).n = MAX + \\} + }, + .{ .name = "two consts in arithmetic", .out = 16, .src = + \\resource Out { n: int = 0 } + \\const limit: int = 7 + \\const MAX: int = 9 + \\const label: string = "hi" + \\rule r() when resource Out { + \\ get_mut(Out).n = limit + MAX + \\} + }, + .{ .name = "an enum const", .out = 3, .src = + \\resource Out { n: int = 0 } + \\enum Tag { a, b } + \\const START: Tag = .b + \\rule r() when resource Out { + \\ let t: Tag = START + \\ get_mut(Out).n = match t { .a => 1, .b => 3 } + \\} + }, + .{ .name = "a string const", .out = 2, .src = + \\resource Out { n: int = 0 } + \\const label: string = "hi" + \\rule r() when resource Out { + \\ let s = label + \\ get_mut(Out).n = s.len() + \\} + }, +}; + +test "a const is read at run time by either spelling" { + const gpa = std.testing.allocator; + var wrong: usize = 0; + for (const_runs) |c| { + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, c.src); + defer pr.deinit(gpa); + try std.testing.expect(pr.diagnostics.len == 0); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const report = try interp.runFor(&world, 1); + const out = readResourceIntNamed(&world, "Out", "n"); + if (report.runtime_errors != 0 or out != c.out) { + wrong += 1; + std.debug.print("{s}: {d} runtime errors, Out.n = {d}, expected {d}\n", .{ c.name, report.runtime_errors, out, c.out }); + } + } + try std.testing.expectEqual(@as(usize, 0), wrong); +} diff --git a/src/etch/types.zig b/src/etch/types.zig index d177831a..0b488daa 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -406,6 +406,15 @@ fn foreignBuiltinFieldType(decl_arena: *const AstArena, type_node: NodeId) ?Buil /// `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). +/// A type the engine provides, which a value position may name as a receiver. +fn isBuiltinTypeName(name: []const u8) bool { + if (BuiltinType.fromName(name) != null) return true; + for (builtin_resources) |r| { + if (std.mem.eql(u8, r.name, name)) return true; + } + return std.mem.eql(u8, name, "Set"); +} + fn containsUppercase(s: []const u8) bool { for (s) |c| { if (c >= 'A' and c <= 'Z') return true; @@ -2358,6 +2367,19 @@ pub const TypeChecker = struct { /// A resource declaration and the arena it lives in. const ResourceRef = struct { arena: *const AstArena, decl: ast_mod.ResourceDecl }; + /// The declared type of the `const` `name` names, the file's own or an + /// imported one. + fn constType(self: *TypeChecker, name: StringId) ?ResolvedType { + if (self.importedBinding(name)) |entry| { + if (entry.kind != .const_) return null; + const a = &self.project.?.arenas[entry.arena_index]; + return self.foreignFieldType(a, a.const_decls.items[a.itemData(entry.item_id)].type_node); + } + const sym = self.symbols.get(name) orelse return null; + if (sym.kind != .const_) return null; + return self.namedTypeToResolved(self.arena.const_decls.items[self.arena.itemData(sym.item_id)].type_node); + } + /// The resource `name` names; a symbol that is no resource names none. fn resourceNamed(self: *TypeChecker, name: StringId) ?ResourceRef { if (self.importedBinding(name)) |entry| { @@ -7040,6 +7062,16 @@ pub const TypeChecker = struct { return local.type_; } } + if (self.constType(name_id)) |t| return t; + try self.emit(.undefined_symbol, .error_, self.arena.exprSpan(id), "unknown identifier '{s}'", .{self.arena.strings.slice(name_id)}); + return ResolvedType.unknown; + }, + .path => { + // An uppercase name in value position is a const read; a type + // name there is typed by the construct that reads it. + const name_id: StringId = data; + if (self.constType(name_id)) |t| return t; + if (self.symbols.contains(name_id) or self.importedBinding(name_id) != null or isBuiltinTypeName(self.arena.strings.slice(name_id))) return ResolvedType.unknown; try self.emit(.undefined_symbol, .error_, self.arena.exprSpan(id), "unknown identifier '{s}'", .{self.arena.strings.slice(name_id)}); return ResolvedType.unknown; }, @@ -15729,3 +15761,158 @@ test "a service argument of its parameter's type and label is accepted" { } try std.testing.expectEqual(@as(usize, 0), wrong); } + +const const_refused = [_]UnitCase{ + .{ .name = "an undefined uppercase name", .code = .undefined_symbol, .src = + \\resource Out { n: int = 0 } + \\const limit: int = 7 + \\const MAX: int = 9 + \\const label: string = "hi" + \\rule r() when resource Out { + \\ get_mut(Out).n = NOPE + \\} + }, + .{ .name = "an undefined lowercase name", .code = .undefined_symbol, .src = + \\resource Out { n: int = 0 } + \\const limit: int = 7 + \\const MAX: int = 9 + \\const label: string = "hi" + \\rule r() when resource Out { + \\ get_mut(Out).n = nope + \\} + }, + .{ .name = "a string const into an int", .code = .type_mismatch, .src = + \\resource Out { n: int = 0 } + \\const limit: int = 7 + \\const MAX: int = 9 + \\const label: string = "hi" + \\rule r() when resource Out { + \\ let s: int = label + \\} + }, + .{ .name = "an int const into a bool", .code = .type_mismatch, .src = + \\resource Out { n: int = 0 } + \\const limit: int = 7 + \\const MAX: int = 9 + \\const label: string = "hi" + \\rule r() when resource Out { + \\ let s: bool = MAX + \\} + }, + .{ .name = "an undefined uppercase receiver", .code = .undefined_symbol, .src = + \\rule r() { + \\ let v = Nope.x + \\} + }, +}; + +const const_accepted = [_]ClosureJumpCase{ + .{ .name = "a lowercase const", .src = + \\resource Out { n: int = 0 } + \\const limit: int = 7 + \\const MAX: int = 9 + \\const label: string = "hi" + \\rule r() when resource Out { + \\ get_mut(Out).n = limit + \\} + }, + .{ .name = "an uppercase const", .src = + \\resource Out { n: int = 0 } + \\const limit: int = 7 + \\const MAX: int = 9 + \\const label: string = "hi" + \\rule r() when resource Out { + \\ get_mut(Out).n = MAX + \\} + }, + .{ .name = "an uppercase const into its type", .src = + \\resource Out { n: int = 0 } + \\const limit: int = 7 + \\const MAX: int = 9 + \\const label: string = "hi" + \\rule r() when resource Out { + \\ let s: int = MAX + \\} + }, + .{ .name = "two consts in arithmetic", .src = + \\resource Out { n: int = 0 } + \\const limit: int = 7 + \\const MAX: int = 9 + \\const label: string = "hi" + \\rule r() when resource Out { + \\ get_mut(Out).n = limit + MAX + \\} + }, + .{ .name = "an enum const", .src = + \\resource Out { n: int = 0 } + \\enum Tag { a, b } + \\const START: Tag = .b + \\rule r() when resource Out { + \\ let t: Tag = START + \\ get_mut(Out).n = match t { .a => 1, .b => 3 } + \\} + }, + .{ .name = "a string const", .src = + \\resource Out { n: int = 0 } + \\const label: string = "hi" + \\rule r() when resource Out { + \\ let s = label + \\ get_mut(Out).n = s.len() + \\} + }, + .{ .name = "a builtin type as a receiver", .src = + \\rule r() { + \\ let v = Vec3.x + \\} + }, + .{ .name = "a builtin resource as a receiver", .src = + \\rule r() { + \\ let v = GameTime.dt + \\} + }, + .{ .name = "Entity as a receiver", .src = + \\rule r() { + \\ let v = Entity.null + \\} + }, + .{ .name = "Error as a receiver", .src = + \\rule r() { + \\ let v = Error.message + \\} + }, + .{ .name = "Set as a receiver", .src = + \\rule r() { + \\ let v = Set.x + \\} + }, +}; + +test "a const read of the wrong type, or a name that names nothing, is refused" { + const gpa = std.testing.allocator; + var missed: usize = 0; + for (const_refused) |c| { + var r = try parseAndCheck(gpa, c.src); + defer r.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), r.parse_diags.len); + if (countMessage(r.diagnostics.items, c.code, "") == 0) { + missed += 1; + std.debug.print("not refused: {s}\n", .{c.name}); + } + } + try std.testing.expectEqual(@as(usize, 0), missed); +} + +test "a const read by either spelling, and a builtin type name, are accepted" { + const gpa = std.testing.allocator; + var wrong: usize = 0; + for (const_accepted) |c| { + var r = try parseAndCheck(gpa, c.src); + defer r.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), r.parse_diags.len); + if (r.diagnostics.items.len != 0) { + wrong += 1; + for (r.diagnostics.items) |d| std.debug.print("{s}: {s} {s}\n", .{ c.name, d.code.code(), d.primary_message }); + } + } + try std.testing.expectEqual(@as(usize, 0), wrong); +} diff --git a/tests/etch/import_resolve_test.zig b/tests/etch/import_resolve_test.zig index 692659e5..6cbe7782 100644 --- a/tests/etch/import_resolve_test.zig +++ b/tests/etch/import_resolve_test.zig @@ -224,6 +224,7 @@ const positions_lib = \\resource R { v: int = 0 } \\event P { v: int = 0 } \\event Hit { who: Entity } + \\const CAP: int = 8 \\ ; @@ -269,6 +270,8 @@ const position_cases = [_]Case{ .{ .name = "malformed when has resource", .body = "rule r(entity: Entity) when entity has R { }" }, .{ .name = "malformed when resource comp", .body = "rule r() when resource C { }" }, .{ .name = "malformed emit component", .body = "rule r() { emit C { v: 1 } }" }, + .{ .name = "const read", .body = "rule r() { let x: int = CAP }" }, + .{ .name = "malformed const read type", .body = "rule r() { let x: bool = CAP }" }, .{ .name = "scene resource", .body = "scene \"S\" {\n resources { R { v: 1 } }\n entity \"e\" { uuid: \"7b3e2f1a-42a3-4f2b-8c9d-a3f2b1c98d4e\" C { v: 1 } }\n}" }, .{ .name = "malformed scene resource field type", .body = "scene \"S\" {\n resources { R { v: true } }\n entity \"e\" { uuid: \"7b3e2f1a-42a3-4f2b-8c9d-a3f2b1c98d4e\" C { v: 1 } }\n}" }, }; @@ -287,7 +290,7 @@ test "a name an import binds is judged as its declaration is, position by positi for (position_cases) |c| { const declared_src = try std.mem.concat(gpa, u8, &.{ positions_lib, c.body }); defer gpa.free(declared_src); - const imported_src = try std.mem.concat(gpa, u8, &.{ "import lib { C, D, R, P, Hit }\n", c.body }); + const imported_src = try std.mem.concat(gpa, u8, &.{ "import lib { C, D, R, P, Hit, CAP }\n", c.body }); defer gpa.free(imported_src); var declared: std.ArrayListUnmanaged(DiagnosticCode) = .empty; defer declared.deinit(gpa); diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index fba57c78..824fc7c3 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2806, - else => 2808, + .windows => 2809, + else => 2811, }; } From ff041ef0a4e53d14bf6dc747c1712d64d16fdc6d Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 29 Sep 2026 03:46:40 +0200 Subject: [PATCH 137/141] fix(etch): judge an imported item as its declaration, at every position An item bound by an import was judged by its local name only, so across 69 positions measured one by one, 61 differed from the same program with the item declared in the file: a struct literal of an imported struct, a match on an imported enum, a call of an imported fn, a method of an imported type, a default of an imported trait and a bound an imported impl satisfies were refused or unchecked. The harness compared codes only, and one position read as agreeing because both sides gave E0200, for different reasons; it now compares code and message. A name now resolves to its declaration across files: a struct, an enum, a fn with its parameters, types and labels, an alias target, the fields of a declaration written in another file, and a foreign file's own imports. Impls follow the imports (etch-resolver-types.md 7.5): an imported file's inherent and trait impls are visible, an inherent impl may target an imported type, and a trait impl may name an imported trait. A method two imported files define is ambiguous, on the E0101 collectImplMethods already reuses for 7.5, and one this file redefines is E0101 as a second local impl is. E0217 has its producer: an impl whose trait and type are both of other modules is an orphan. self in an impl on an enum is the enum. Floor 2811 -> 2818 / 2816, read from the suite. Co-Authored-By: Claude Opus 5.5 --- src/etch/diagnostics.zig | 3 +- src/etch/types.zig | 850 +++++++++++++++--------- tests/etch/diagnostic_coverage_test.zig | 38 +- tests/etch/import_resolve_test.zig | 251 ++++++- tools/weld_lint/dead_tests.zig | 4 +- 5 files changed, 815 insertions(+), 331 deletions(-) diff --git a/src/etch/diagnostics.zig b/src/etch/diagnostics.zig index b113d065..b119dec1 100644 --- a/src/etch/diagnostics.zig +++ b/src/etch/diagnostics.zig @@ -47,8 +47,7 @@ pub const DiagnosticCode = enum { ambiguous_trait_method, // E0211 AmbiguousTraitMethod incomplete_trait_impl, // E0214 IncompleteTraitImpl conditional_impl_condition_not_proven, // E0215 ConditionalImplConditionNotProven - /// E0217 OrphanImpl. No producer while traits do not resolve across modules (`etch-resolver-types.md` §7.4). - orphan_impl, + orphan_impl, // E0217 OrphanImpl immutable_receiver_for_mut_self, // E0220 ImmutableReceiverForMutSelfMethod closure_cannot_mutate_capture, // E0221 ClosureCannotMutateCapture collection_field_element_invalid, // E0222 CollectionFieldElementInvalid (resource collection field: unsupported element or nested collection) diff --git a/src/etch/types.zig b/src/etch/types.zig index 0b488daa..d8cc31c0 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -388,24 +388,6 @@ 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. Mirrors -/// the builtin path of `namedTypeToResolved` but reads the FOREIGN arena's 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 { - const name = decl_arena.namedTypeName(type_node) orelse return null; - const resolved = decl_arena.resolveTypeAliasName(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). /// A type the engine provides, which a value position may name as a receiver. fn isBuiltinTypeName(name: []const u8) bool { if (BuiltinType.fromName(name) != null) return true; @@ -415,6 +397,9 @@ fn isBuiltinTypeName(name: []const u8) bool { return std.mem.eql(u8, name, "Set"); } +/// `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). fn containsUppercase(s: []const u8) bool { for (s) |c| { if (c >= 'A' and c <= 'Z') return true; @@ -535,6 +520,9 @@ pub const TypeChecker = struct { /// in declaration order. The kind-2 trait dispatch (`etch-resolver-types.md /// §5.2`) scans this for the receiver type, AFTER inherent (§5.5 order). trait_impls: std.ArrayListUnmanaged(TraitImplEntry) = .empty, + /// The other files this file imports from, in import order, once each: + /// their impls are visible here (`etch-resolver-types.md` §7.5). + import_arenas: std.ArrayListUnmanaged(*const AstArena) = .empty, /// Generic type-parameter names currently in scope. Set /// while checking a generic `fn` / `impl` / `struct` / `enum` body so a /// type annotation naming a param resolves to `.generic` rather than an @@ -650,8 +638,8 @@ pub const TypeChecker = struct { project: ?*const ProjectContext = null, /// Symbols brought into this file's scope by a selective `import a.b { X }`, /// 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 `TYPE_IDENT` resolution. Empty in single-file mode. + /// if present, else the imported name). Built by `bindImports` after pass 1. + /// Empty in single-file mode. imported_symbols: std.AutoHashMapUnmanaged(StringId, ExportEntry) = .empty, /// Module aliases from `import a.b as m` / bare `import a.b`: /// local alias `StringId` → target file index. Qualified `m.Type` resolution @@ -758,17 +746,17 @@ pub const TypeChecker = struct { decl: ast_mod.EventDecl, }; - /// One `impl Trait for Type [when …]`. - /// `methods_start`/`methods_len` index `arena.impl_methods` (the - /// impl-provided methods); `when_root` is `RuleDecl.none_when` for an - /// unconditional impl. + /// One `impl Trait for Type [when …]` of `arena`. Its names are + /// `arena`'s, `methods_start`/`methods_len` index `arena.impl_methods` + /// (the impl-provided methods), and `when_root` indexes `arena.when_nodes` + /// or is `RuleDecl.none_when` for an unconditional impl. pub const TraitImplEntry = struct { + arena: *const AstArena, trait_name: StringId, type_name: StringId, when_root: u32, methods_start: u32, methods_len: u32, - span: SourceSpan, }; pub fn deinit(self: *TypeChecker) void { @@ -776,6 +764,7 @@ pub const TypeChecker = struct { self.test_symbols.deinit(self.gpa); self.methods.deinit(self.gpa); self.trait_impls.deinit(self.gpa); + self.import_arenas.deinit(self.gpa); self.conc_labels.deinit(self.gpa); self.break_frames.deinit(self.gpa); self.escape_names.deinit(self.gpa); @@ -910,6 +899,44 @@ pub const TypeChecker = struct { return out; } + /// The fields of the file's components, resources, events and structs, and + /// its const values, judged once the imports bind the types they name. + fn validateDeclaredValues(self: *TypeChecker) !void { + const kinds = self.arena.items.items(.kind); + const datas = self.arena.items.items(.data); + var i: u28 = 0; + while (i < self.arena.items.len) : (i += 1) { + const data = datas[i]; + switch (kinds[i]) { + .component_decl => { + const decl = self.arena.component_decls.items[data]; + try self.validateFieldsInDecl(decl.fields_start, decl.fields_len, .component_like); + }, + .resource_decl => { + const decl = self.arena.resource_decls.items[data]; + try self.validateFieldsInDecl(decl.fields_start, decl.fields_len, .resource); + }, + .event_decl => { + const decl = self.arena.event_decls.items[data]; + try self.validateFieldsInDecl(decl.fields_start, decl.fields_len, .event_); + }, + .struct_decl => { + const decl = self.arena.struct_decls.items[data]; + // Generic params in scope so a field typed + // by a param (`min: T`) is accepted as a generic field. + try self.addGenerics(decl.generics_start, decl.generics_len); + defer self.removeGenerics(decl.generics_start, decl.generics_len); + try self.validateFieldsInDecl(decl.fields_start, decl.fields_len, .struct_); + }, + .const_decl => { + const decl = self.arena.const_decls.items[data]; + try self.checkConstValue(decl.value, decl.type_node); + }, + else => {}, + } + } + } + /// The passes before the imports bind: the file's own symbols. fn collectDeclarations(self: *TypeChecker) !void { // E1901 runs FIRST: it decides whether the file is even allowed to @@ -927,6 +954,7 @@ pub const TypeChecker = struct { } fn validateDeclarations(self: *TypeChecker) !void { + try self.validateDeclaredValues(); try self.validateTypeAliases(); try self.validateImpls(); try self.validateDataDecls(); @@ -1365,7 +1393,7 @@ pub const TypeChecker = struct { while (pi < method.params_len) : (pi += 1) { const idx = self.arena.callArgIndexForParam(mc.args_start, mc.args_len, mc.names_start, pi, pnames.items[pi]) orelse continue; const arg: NodeId = @bitCast(self.arena.extra.items[mc.args_start + idx]); - const ptype = self.foreignFieldType(svc.arena, svc.arena.fn_params.items[method.params_start + pi].type_node); + const ptype = self.foreignType(svc.arena, svc.arena.fn_params.items[method.params_start + pi].type_node); if (!try self.valueFits(ptype, arg, arg_types.items[idx])) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(arg), "argument type does not match the parameter type of service method '{s}.{s}'", .{ svc_slice, method_slice }); } @@ -1391,37 +1419,69 @@ pub const TypeChecker = struct { ); } - /// The return type of a foreign-arena signature, resolved BY BYTES. - /// - /// Bounded to builtins on purpose, and the bound is inherited rather than - /// invented: the same question is settled for cross-arena field types - /// and recorded "builtin-typed-only; named foreign field types are a - /// documented residual". Resolving a named foreign type here would mean - /// re-keying `symbols` and `generic_scope`, which are `StringId` maps over - /// THIS arena's pool. A named return type therefore types as `unknown`, - /// which is permissive and never wrong — it is the same value every - /// unresolved expression carries. - /// A foreign arena's declared field type, resolved BY BYTES. Same bound and - /// same reason as `foreignReturnType`. - fn foreignFieldType(self: *TypeChecker, a: *const AstArena, type_node: NodeId) ResolvedType { - _ = self; - const name = a.namedTypeName(type_node) orelse return ResolvedType.unknown; - const tname = a.strings.slice(a.resolveTypeAliasName(name)); - if (std.mem.eql(u8, tname, "string")) return .{ .builtin = .string_ }; - if (BuiltinType.fromName(tname)) |bt| return .{ .builtin = bt }; - return ResolvedType.unknown; + /// A type written in arena `a`, as this file names it. A declaration `a` + /// names, its own or one it imports, resolves when this file binds the + /// same name, through an import, to that same declaration, and is unknown + /// otherwise; a builtin, a `string` and a collection or optional of a + /// builtin resolve anywhere. + fn foreignType(self: *TypeChecker, a: *const AstArena, type_node: NodeId) ResolvedType { + if (a == self.arena) return self.namedTypeToResolved(type_node); + switch (a.typeNodeKind(type_node)) { + .named, .generic => { + const raw = if (a.typeNodeKind(type_node) == .named) a.namedTypeName(type_node).? else a.generic_type_nodes.items[a.typeNodeData(type_node)].name; + const resolved = a.resolveTypeAliasName(raw); + const bytes = a.strings.slice(resolved); + if (std.mem.eql(u8, bytes, "string")) return .{ .builtin = .string_ }; + if (BuiltinType.fromName(bytes)) |bt| return .{ .builtin = bt }; + const local = self.arena.strings.find(bytes) orelse return .unknown; + const have = self.declNamed(local) orelse return .unknown; + const want = self.declNamedIn(a, resolved) orelse return .unknown; + if (!have.eql(want)) return .unknown; + return switch (self.kindNamed(local).?) { + .component => .{ .component = local }, + .resource => .{ .resource = local }, + .struct_ => .{ .struct_t = local }, + .enum_ => .{ .enum_t = local }, + .event_ => .{ .event_t = local }, + else => .unknown, + }; + }, + .slice => { + const elem = self.foreignType(a, a.array_types.items[a.typeNodeData(type_node)].elem); + return if (elem == .builtin) .{ .array_dyn = elem.builtin } else .unknown; + }, + .set_type => { + const elem = self.foreignType(a, a.set_types.items[a.typeNodeData(type_node)].elem); + return if (elem == .builtin) .{ .set_t = elem.builtin } else .unknown; + }, + .map_type => { + const mt = a.map_types.items[a.typeNodeData(type_node)]; + const k = self.foreignType(a, mt.key); + const v = self.foreignType(a, mt.value); + return if (k == .builtin and v == .builtin) .{ .map_t = .{ .key = k.builtin, .value = v.builtin } } else .unknown; + }, + .optional => { + const payload = self.foreignType(a, @bitCast(a.typeNodeData(type_node))); + return if (payload == .builtin) .{ .optional = payload.builtin } else .unknown; + }, + else => return .unknown, + } + } + + /// The export of arena `a` named `bytes`. + fn exportIn(self: *TypeChecker, a: *const AstArena, bytes: []const u8) ?ExportEntry { + const project = self.project orelse return null; + for (project.arenas, 0..) |*arena, i| { + if (arena == a) return project.exports[i].get(bytes); + } + return null; } fn foreignReturnType(self: *TypeChecker, a: *const AstArena, method: ast_mod.FnDecl) ResolvedType { - _ = self; // A void signature (`fn stop(h: AudioHandle)`) types as `unknown`, as // a call of any fn with no `-> type` does. if (method.return_type.isNone()) return ResolvedType.unknown; - const name = a.namedTypeName(method.return_type) orelse return ResolvedType.unknown; - const tname = a.strings.slice(a.resolveTypeAliasName(name)); - if (std.mem.eql(u8, tname, "string")) return .{ .builtin = .string_ }; - if (BuiltinType.fromName(tname)) |bt| return .{ .builtin = bt }; - return ResolvedType.unknown; + return self.foreignType(a, method.return_type); } /// Validate every `type Name = Type` alias once all symbols are known: the alias @@ -1464,6 +1524,10 @@ pub const TypeChecker = struct { // 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; + if (entry.kind == .type_alias) switch (self.importedAliasTarget(entry)) { + .builtin, .component, .resource => continue, + else => {}, + }; } 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)}); } @@ -2352,6 +2416,114 @@ pub const TypeChecker = struct { return self.imported_symbols.get(name); } + /// A declaration, identified across the files of a project. + const DeclRef = struct { + arena: *const AstArena, + item: NodeId, + + fn eql(a: DeclRef, b: DeclRef) bool { + return a.arena == b.arena and std.meta.eql(a.item, b.item); + } + }; + + /// The declaration `name` names in this file. + fn declNamed(self: *TypeChecker, name: StringId) ?DeclRef { + if (self.importedBinding(name)) |entry| return .{ .arena = &self.project.?.arenas[entry.arena_index], .item = entry.item_id }; + const sym = self.symbols.get(name) orelse return null; + return .{ .arena = self.arena, .item = sym.item_id }; + } + + /// The declaration `name`, written in arena `a`, names there: one of its + /// own, else one it imports by name. + fn declNamedIn(self: *TypeChecker, a: *const AstArena, name: StringId) ?DeclRef { + if (a == self.arena) return self.declNamed(name); + const project = self.project orelse return null; + const bytes = a.strings.slice(name); + const entry = self.exportIn(a, bytes) orelse importedIn(project, a, bytes) orelse return null; + return .{ .arena = &project.arenas[entry.arena_index], .item = entry.item_id }; + } + + /// The public export a selective import of arena `a` binds `bytes` to. + fn importedIn(project: *const ProjectContext, a: *const AstArena, bytes: []const u8) ?ExportEntry { + for (a.items.items(.kind), a.items.items(.data)) |kind, data| { + if (kind != .import_decl) continue; + const decl = a.import_decls.items[data]; + var j: u32 = 0; + while (j < decl.items_len) : (j += 1) { + const item = a.import_items.items[decl.items_start + j]; + if (!std.mem.eql(u8, a.strings.slice(importLocalName(item)), bytes)) continue; + const entry = importedExport(project, a, moduleOf(project, a, decl) orelse return null, item) orelse return null; + return if (entry.visibility == .private) null else entry; + } + } + return null; + } + + /// The file index of the module `decl`, an import of arena `a`, names. + fn moduleOf(project: *const ProjectContext, a: *const AstArena, decl: ast_mod.ImportDecl) ?usize { + var it = project.module_index.iterator(); + next: while (it.next()) |e| { + var rest: []const u8 = e.key_ptr.*; + var s: u32 = 0; + while (s < decl.path_len) : (s += 1) { + if (s != 0) { + if (rest.len == 0 or rest[0] != '.') continue :next; + rest = rest[1..]; + } + const seg = a.strings.slice(a.import_path_segs.items[decl.path_start + s]); + if (!std.mem.startsWith(u8, rest, seg)) continue :next; + rest = rest[seg.len..]; + } + if (rest.len == 0) return e.value_ptr.*; + } + return null; + } + + /// What an impl is for: a declaration, or the builtin `Entity`. + const ImplTarget = union(enum) { + decl: DeclRef, + entity, + + fn eql(a: ImplTarget, b: ImplTarget) bool { + return switch (a) { + .decl => |d| b == .decl and d.eql(b.decl), + .entity => b == .entity, + }; + } + }; + + /// The impl target `name`, written in arena `a`, denotes. + fn implTargetIn(self: *TypeChecker, a: *const AstArena, name: StringId) ?ImplTarget { + if (self.declNamedIn(a, name)) |d| return .{ .decl = d }; + if (std.mem.eql(u8, a.strings.slice(name), "Entity")) return .entity; + return null; + } + + /// A function declaration and the arena it lives in. + const FnRef = struct { arena: *const AstArena, decl: ast_mod.FnDecl }; + + /// The top-level `fn` `name` names, the file's own or an imported one. + fn fnNamed(self: *TypeChecker, name: StringId) ?FnRef { + if (self.importedBinding(name)) |entry| { + if (entry.kind != .fn_) return null; + const a = &self.project.?.arenas[entry.arena_index]; + return .{ .arena = a, .decl = a.fn_decls.items[a.itemData(entry.item_id)] }; + } + const sym = self.symbols.get(name) orelse return null; + if (sym.kind != .fn_) return null; + return .{ .arena = self.arena, .decl = self.arena.fn_decls.items[self.arena.itemData(sym.item_id)] }; + } + + /// The method named `bytes` among `a.impl_methods[start .. start + len]`. + fn methodIn(a: *const AstArena, start: u32, len: u32, bytes: []const u8) ?ast_mod.FnDecl { + var i: u32 = 0; + while (i < len) : (i += 1) { + const m = a.impl_methods.items[start + i]; + if (std.mem.eql(u8, a.strings.slice(m.name), bytes)) return m; + } + return null; + } + /// The component `name` names; a symbol that is no component names none. fn componentNamed(self: *TypeChecker, name: StringId) ?ComponentRef { if (self.importedBinding(name)) |entry| { @@ -2367,13 +2539,52 @@ pub const TypeChecker = struct { /// A resource declaration and the arena it lives in. const ResourceRef = struct { arena: *const AstArena, decl: ast_mod.ResourceDecl }; + const StructRef = struct { arena: *const AstArena, decl: ast_mod.StructDecl }; + + /// The struct `name` names, the file's own or an imported one. + fn structNamed(self: *TypeChecker, name: StringId) ?StructRef { + if (self.importedBinding(name)) |entry| { + if (entry.kind != .struct_) return null; + const a = &self.project.?.arenas[entry.arena_index]; + return .{ .arena = a, .decl = a.struct_decls.items[a.itemData(entry.item_id)] }; + } + const sym = self.symbols.get(name) orelse return null; + if (sym.kind != .struct_) return null; + return .{ .arena = self.arena, .decl = self.arena.struct_decls.items[self.arena.itemData(sym.item_id)] }; + } + + const EnumRef = struct { arena: *const AstArena, decl: ast_mod.EnumDecl }; + + /// The enum `name` names, the file's own or an imported one. + fn enumNamed(self: *TypeChecker, name: StringId) ?EnumRef { + if (self.importedBinding(name)) |entry| { + if (entry.kind != .enum_) return null; + const a = &self.project.?.arenas[entry.arena_index]; + return .{ .arena = a, .decl = a.enum_decls.items[a.itemData(entry.item_id)] }; + } + const sym = self.symbols.get(name) orelse return null; + if (sym.kind != .enum_) return null; + return .{ .arena = self.arena, .decl = self.arena.enum_decls.items[self.arena.itemData(sym.item_id)] }; + } + + /// A declared type of arena `a`, as this file names it. + fn typeIn(self: *TypeChecker, a: *const AstArena, type_node: NodeId) ResolvedType { + return if (a == self.arena) self.namedTypeToResolved(type_node) else self.foreignType(a, type_node); + } + + /// Whether `declared`, a name of arena `a`, is the name this file writes + /// `written`. + fn sameName(self: *TypeChecker, a: *const AstArena, declared: StringId, written: StringId) bool { + return if (a == self.arena) declared == written else std.mem.eql(u8, a.strings.slice(declared), self.arena.strings.slice(written)); + } + /// The declared type of the `const` `name` names, the file's own or an /// imported one. fn constType(self: *TypeChecker, name: StringId) ?ResolvedType { if (self.importedBinding(name)) |entry| { if (entry.kind != .const_) return null; const a = &self.project.?.arenas[entry.arena_index]; - return self.foreignFieldType(a, a.const_decls.items[a.itemData(entry.item_id)].type_node); + return self.foreignType(a, a.const_decls.items[a.itemData(entry.item_id)].type_node); } const sym = self.symbols.get(name) orelse return null; if (sym.kind != .const_) return null; @@ -2421,7 +2632,7 @@ pub const TypeChecker = struct { if (a == self.arena) { if (f.name == name) return self.namedTypeToResolved(f.type_node); } else if (std.mem.eql(u8, a.strings.slice(f.name), want)) { - return self.foreignFieldType(a, f.type_node); + return self.foreignType(a, f.type_node); } } return null; @@ -2439,9 +2650,7 @@ pub const TypeChecker = struct { /// Cross-arena field check for an imported component or resource instance: /// the instance field lives in `self.arena` and the declared fields in - /// `decl_arena`, so names are matched by bytes. A declared type is read - /// across arenas only as a builtin or `string`, so an enum or collection - /// field of an imported resource is checked by name alone. + /// `decl_arena`, so names are matched by bytes. 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 instance bodies const field_name_bytes = self.arena.strings.slice(field.name); @@ -2458,9 +2667,12 @@ 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; }; - const declared_builtin = foreignBuiltinFieldType(decl_arena, tn) orelse return; - const actual = try self.synthExprE(field.value, null); - if (!try self.valueFits(.{ .builtin = declared_builtin }, field.value, actual)) { + const declared = self.foreignType(decl_arena, tn); + const actual = if (declared == .enum_t and self.arena.exprKind(field.value) == .tag_path) + try self.checkEnumShorthand(field.value, declared.enum_t) + else + try self.synthExprE(field.value, null); + if (!try self.valueFits(declared, field.value, actual)) { try self.emit(code_type, .error_, self.arena.exprSpan(field.value), "field '{s}' value type does not match its declared type", .{field_name_bytes}); } } @@ -2864,10 +3076,10 @@ pub const TypeChecker = struct { } // Entry type: must resolve to a declared struct. Unknown → E0102; // known-but-not-a-struct → E1762 (the table itself cannot conform). - var entry_struct: ?ast_mod.StructDecl = null; - if (self.symbols.get(decl.entry_type)) |sym| { - if (sym.kind == .struct_) { - entry_struct = self.arena.struct_decls.items[self.arena.itemData(sym.item_id)]; + var entry_struct: ?StructRef = null; + if (self.kindNamed(decl.entry_type)) |kind| { + if (kind == .struct_) { + entry_struct = self.structNamed(decl.entry_type); } else { try self.emit(.entry_type_mismatch, .error_, decl.entry_type_span, "data entry type '{s}' is not a struct", .{self.arena.strings.slice(decl.entry_type)}); } @@ -2907,19 +3119,19 @@ pub const TypeChecker = struct { if (!has_spread) { if (entry_struct) |sd| { var sf: u32 = 0; - while (sf < sd.fields_len) : (sf += 1) { - const sfield = self.arena.fields.items[sd.fields_start + sf]; + while (sf < sd.decl.fields_len) : (sf += 1) { + const sfield = sd.arena.fields.items[sd.decl.fields_start + sf]; if (!sfield.default_value.isNone()) continue; var provided = false; f = 0; while (f < entry.fields_len) : (f += 1) { - if (self.arena.struct_lit_fields.items[entry.fields_start + f].name == sfield.name) { + if (self.sameName(sd.arena, sfield.name, self.arena.struct_lit_fields.items[entry.fields_start + f].name)) { provided = true; break; } } if (!provided) { - try self.emit(.entry_field_required_missing, .error_, entry.span, "data entry '{s}' is missing required field '{s}' (no declared default)", .{ id_slice, self.arena.strings.slice(sfield.name) }); + try self.emit(.entry_field_required_missing, .error_, entry.span, "data entry '{s}' is missing required field '{s}' (no declared default)", .{ id_slice, sd.arena.strings.slice(sfield.name) }); } } } @@ -2931,14 +3143,14 @@ pub const TypeChecker = struct { /// struct: E1763 unknown field, E1764 value type. Value typing mirrors /// `checkStructLitAgainst` (check mode for `.variant` shorthands and /// anonymous `.{ … }` values, synth otherwise). - fn validateDataEntryField(self: *TypeChecker, decl: ast_mod.DataDecl, entry_struct: ?ast_mod.StructDecl, field: ast_mod.StructLitField) !void { + fn validateDataEntryField(self: *TypeChecker, decl: ast_mod.DataDecl, entry_struct: ?StructRef, field: ast_mod.StructLitField) !void { const sd = entry_struct orelse return; // type already reported var declared: ?ResolvedType = null; var sf: u32 = 0; - while (sf < sd.fields_len) : (sf += 1) { - const sfield = self.arena.fields.items[sd.fields_start + sf]; - if (sfield.name == field.name) { - declared = self.namedTypeToResolved(sfield.type_node); + while (sf < sd.decl.fields_len) : (sf += 1) { + const sfield = sd.arena.fields.items[sd.decl.fields_start + sf]; + if (self.sameName(sd.arena, sfield.name, field.name)) { + declared = self.typeIn(sd.arena, sfield.type_node); break; } } @@ -3833,14 +4045,12 @@ pub const TypeChecker = struct { // `@requires` is NOT resolved here — see `pass2Resolve`. // Pass 1 is the loop that BUILDS the symbol table, so a name // declared further down the file is not in it yet. - try self.validateFieldsInDecl(decl.fields_start, decl.fields_len, .component_like); }, .resource_decl => { const decl = self.arena.resource_decls.items[data]; try self.refuseReservedEngineName(decl.name, span, "resource"); try self.registerSymbol(.resource, decl.name, item_id, span); try self.validateAnnotations(decl.annotations_extra, decl.annotations_len, .resource); - try self.validateFieldsInDecl(decl.fields_start, decl.fields_len, .resource); }, .event_decl => { // An `event` is a frame-arena struct-message, not a POD component: @@ -3854,7 +4064,6 @@ pub const TypeChecker = struct { const decl = self.arena.event_decls.items[data]; try self.registerSymbol(.event_, decl.name, item_id, span); try self.validateAnnotations(decl.annotations_extra, decl.annotations_len, .event); - try self.validateFieldsInDecl(decl.fields_start, decl.fields_len, .event_); }, .rule_decl => { const decl = self.arena.rule_decls.items[data]; @@ -3873,11 +4082,6 @@ pub const TypeChecker = struct { // and enum-typed fields; nested-struct fields stay deferred. const decl = self.arena.struct_decls.items[data]; try self.registerSymbol(.struct_, decl.name, item_id, span); - // Generic params in scope so a field typed - // by a param (`min: T`) is accepted as a generic field. - try self.addGenerics(decl.generics_start, decl.generics_len); - defer self.removeGenerics(decl.generics_start, decl.generics_len); - try self.validateFieldsInDecl(decl.fields_start, decl.fields_len, .struct_); }, .enum_decl => { // A C-like `enum` is a value type. Register the name; validate @@ -3904,12 +4108,12 @@ pub const TypeChecker = struct { try self.collectImplMethods(impl, span); } else { try self.trait_impls.append(self.gpa, .{ + .arena = self.arena, .trait_name = impl.trait_name, .type_name = impl.type_name, .when_root = impl.when_root, .methods_start = impl.methods_start, .methods_len = impl.methods_len, - .span = span, }); } }, @@ -3930,7 +4134,6 @@ pub const TypeChecker = struct { const decl = self.arena.const_decls.items[data]; try self.validateAnnotations(decl.annotations_extra, decl.annotations_len, .const_); try self.registerSymbol(.const_, decl.name, item_id, span); - try self.checkConstValue(decl.value, decl.type_node); }, .test_decl => { // Top-level `test` block. The test name lives in a @@ -4106,6 +4309,7 @@ pub const TypeChecker = struct { try self.emit(.not_a_module, .error_, span, "import path '{s}' does not name a module in the project", .{path}); continue; }; + try self.importArena(&project.arenas[target_idx]); if (decl.items_len == 0) { // Whole-module form (1 or 3): record the alias → target binding. @@ -4136,6 +4340,26 @@ pub const TypeChecker = struct { } } + /// Record `a` as a file this one imports from, and its trait impls. + fn importArena(self: *TypeChecker, a: *const AstArena) !void { + if (a == self.arena) return; + for (self.import_arenas.items) |seen| { + if (seen == a) return; + } + try self.import_arenas.append(self.gpa, a); + for (a.impl_decls.items) |impl| { + if (impl.trait_name == 0) continue; + try self.trait_impls.append(self.gpa, .{ + .arena = a, + .trait_name = impl.trait_name, + .type_name = impl.type_name, + .when_root = impl.when_root, + .methods_start = impl.methods_start, + .methods_len = impl.methods_len, + }); + } + } + /// The dotted module path `decl` names (`import a.b.c` → `"a.b.c"`), /// `gpa`-owned. pub fn importPath(gpa: std.mem.Allocator, arena: *const AstArena, decl: ast_mod.ImportDecl) ![]u8 { @@ -4204,9 +4428,11 @@ pub const TypeChecker = struct { if (impl.trait_name == 0) { // Inherent impl (§5.1): target is a declared struct / component / // resource. No coherence (§7.5). - if (self.symbols.get(impl.type_name)) |sym| { - if (sym.kind != .struct_ and sym.kind != .component and sym.kind != .resource) { + if (self.kindNamed(impl.type_name)) |kind| { + if (kind != .struct_ and kind != .component and kind != .resource) { try self.emit(.undefined_symbol, .error_, spans[i], "impl target '{s}' is not a struct, component, or resource", .{tname}); + } else { + try self.checkImportedMethodDuplicates(impl, spans[i]); } } else { try self.emit(.undefined_symbol, .error_, spans[i], "impl target type '{s}' is not declared", .{tname}); @@ -4217,74 +4443,74 @@ pub const TypeChecker = struct { } } + /// `E0101` for each method of the inherent `impl` that an inherent impl of + /// an imported file already gives the same type, as a second impl of this + /// file would be. + fn checkImportedMethodDuplicates(self: *TypeChecker, impl: ast_mod.ImplDecl, span: SourceSpan) !void { + const target = self.implTargetIn(self.arena, impl.type_name) orelse return; + var i: u32 = 0; + while (i < impl.methods_len) : (i += 1) { + const name = self.arena.strings.slice(self.arena.impl_methods.items[impl.methods_start + i].name); + for (self.import_arenas.items) |a| { + if (self.inherentMethodIn(a, target, name) == null) continue; + try self.emit(.duplicate_symbol, .error_, span, "duplicate method '{s}' on type '{s}'", .{ name, self.arena.strings.slice(impl.type_name) }); + break; + } + } + } + /// Validate one `impl Trait for Type [when …]` ( - /// `etch-resolver-types.md §7.2/§7.4`). Checks: the trait is declared; every - /// abstract trait method is provided (E0214, else the trait must supply a - /// default); the target type is a struct / component / resource / `Entity`. + /// `etch-resolver-types.md §7.2/§7.4`). Checks: the trait is declared or + /// imported; the target type is a struct / component / resource / enum, + /// declared or imported, or `Entity`; one of the two is this file's + /// (E0217); every abstract trait method is provided (E0214, else the trait + /// must supply a default). fn validateTraitImpl(self: *TypeChecker, impl: ast_mod.ImplDecl, span: SourceSpan) !void { const trait_slice = self.arena.strings.slice(impl.trait_name); const type_slice = self.arena.strings.slice(impl.type_name); - // The trait must be a declared `trait`. - const trait_sym = self.symbols.get(impl.trait_name); - const trait_local = trait_sym != null and trait_sym.?.kind == .trait_; - if (!trait_local) { + const trait_kind = self.kindNamed(impl.trait_name); + if (trait_kind == null or trait_kind.? != .trait_) { try self.emit(.undefined_symbol, .error_, span, "trait '{s}' is not declared", .{trait_slice}); return; // nothing further provable without the trait } + const trait = self.declNamed(impl.trait_name).?; - // The target type must be a local struct / component / resource, or the - // builtin `Entity` (the conditional-impl receiver, §7.3). - const type_sym = self.symbols.get(impl.type_name); + const type_kind = self.kindNamed(impl.type_name); const type_is_entity = std.mem.eql(u8, type_slice, "Entity"); - const type_local = type_sym != null and (type_sym.?.kind == .struct_ or type_sym.?.kind == .component or type_sym.?.kind == .resource or type_sym.?.kind == .enum_); - if (!type_local and !type_is_entity) { + const type_declared = if (type_kind) |k| k == .struct_ or k == .component or k == .resource or k == .enum_ else false; + if (!type_declared and !type_is_entity) { try self.emit(.undefined_symbol, .error_, span, "trait-impl target '{s}' is not a struct, component, resource, or Entity", .{type_slice}); + } else if (trait.arena != self.arena and !(type_declared and self.declNamed(impl.type_name).?.arena == self.arena)) { + try self.emit(.orphan_impl, .error_, span, "impl of trait '{s}' for '{s}' declares neither in this module", .{ trait_slice, type_slice }); } - // Orphan rule (§7.4): not checked. A check above the `!trait_local` - // return would fire on undeclared names, which `undefined_symbol` - // already reports. - // E0214: every abstract trait method (no default body) must be provided. - const tdecl = self.arena.trait_decls.items[self.arena.itemData(trait_sym.?.item_id)]; + const tdecl = trait.arena.trait_decls.items[trait.arena.itemData(trait.item)]; var m: u32 = 0; while (m < tdecl.methods_len) : (m += 1) { - const tmethod = self.arena.impl_methods.items[tdecl.methods_start + m]; + const tmethod = trait.arena.impl_methods.items[tdecl.methods_start + m]; if (tmethod.has_body) continue; // default-bodied → optional - if (!self.implProvidesMethod(impl, tmethod.name)) { - try self.emit(.incomplete_trait_impl, .error_, span, "impl of trait '{s}' for '{s}' is missing method '{s}'", .{ trait_slice, type_slice, self.arena.strings.slice(tmethod.name) }); + const name = trait.arena.strings.slice(tmethod.name); + if (methodIn(self.arena, impl.methods_start, impl.methods_len, name) == null) { + try self.emit(.incomplete_trait_impl, .error_, span, "impl of trait '{s}' for '{s}' is missing method '{s}'", .{ trait_slice, type_slice, name }); } } // §10.2 — W0902 PrivateTypeInPublicImpl: a PUBLIC trait implemented for a // PRIVATE target type surfaces the private type through a public - // interface. Warning, not error — legitimate for internal use. Fires - // only when the target is a local private type AND the trait is a local - // PUBLIC trait (both symbols resolved above). An imported target is - // public by construction (only public items import), so it never trips - // this; an imported PUBLIC trait implemented for a private local type is - // the spec's "trait imported" case, but an imported-trait impl does not - // resolve here today (returns early at the `!trait_local` gate) — a - // pre-existing, orthogonal gap, not this milestone's surface. - if (type_sym) |ts| { + // interface. Warning, not error — legitimate for internal use. An + // imported target is public by construction (only public items import), + // and so is an imported trait. + if (self.symbols.get(impl.type_name)) |ts| { const target_private = self.arena.itemVisibility(ts.item_id) == .private; - const trait_public = self.arena.itemVisibility(trait_sym.?.item_id) == .public; + const trait_public = trait.arena != self.arena or self.arena.itemVisibility(trait.item) == .public; if (target_private and trait_public) { try self.emit(.private_type_in_public_impl, .warning, span, "public trait '{s}' implemented for private type '{s}'", .{ trait_slice, type_slice }); } } } - /// `true` if `impl` provides a method named `name`. - fn implProvidesMethod(self: *TypeChecker, impl: ast_mod.ImplDecl, name: StringId) bool { - var m: u32 = 0; - while (m < impl.methods_len) : (m += 1) { - if (self.arena.impl_methods.items[impl.methods_start + m].name == name) return true; - } - return false; - } - /// Validate an `enum`'s variant set: non-empty, /// no duplicate variant names. The grammar already guarantees ≥1 variant, /// so the duplicate check is the substantive one (E0101). @@ -4306,26 +4532,43 @@ pub const TypeChecker = struct { /// `enum_name`, or `null` if `enum_name` is not /// a declared enum or has no such variant. fn enumVariantIndex(self: *TypeChecker, enum_name: StringId, variant: StringId) ?u32 { - const decl = self.enumDecl(enum_name) orelse return null; + const e = self.enumNamed(enum_name) orelse return null; var i: u32 = 0; - while (i < decl.variants_len) : (i += 1) { - if (self.arena.enum_variants.items[decl.variants_start + i].name == variant) return i; + while (i < e.decl.variants_len) : (i += 1) { + if (self.sameName(e.arena, e.arena.enum_variants.items[e.decl.variants_start + i].name, variant)) return i; } return null; } - /// The `EnumDecl` for `enum_name`, or `null` if it is not a declared enum. - fn enumDecl(self: *TypeChecker, enum_name: StringId) ?ast_mod.EnumDecl { - const sym = self.symbols.get(enum_name) orelse return null; - if (sym.kind != .enum_) return null; - return self.arena.enum_decls.items[self.arena.itemData(sym.item_id)]; + /// The inherent method `method_name` of the type `type_name` names + /// (§5.1): this file's own, else the one a file it imports defines. Two + /// imported files defining it is the `AmbiguousInherentMethod` of §7.5, + /// on the duplicate-symbol code as `collectImplMethods` reports it. + fn lookupMethod(self: *TypeChecker, type_name: StringId, method_name: StringId, span: SourceSpan) TypeError!?FnRef { + if (self.methods.get(methodKey(type_name, method_name))) |idx| return .{ .arena = self.arena, .decl = self.arena.impl_methods.items[idx] }; + const target = self.implTargetIn(self.arena, type_name) orelse return null; + const bytes = self.arena.strings.slice(method_name); + var found: ?FnRef = null; + for (self.import_arenas.items) |a| { + const m = self.inherentMethodIn(a, target, bytes) orelse continue; + if (found != null) { + try self.emit(.duplicate_symbol, .error_, span, "ambiguous method '{s}' on type '{s}' — two imported modules define it", .{ bytes, self.arena.strings.slice(type_name) }); + break; + } + found = .{ .arena = a, .decl = m }; + } + return found; } - /// Resolve an inherent method `(type_name, method_name)` to its `FnDecl` - /// (§5.1), or `null` if no such method exists. - fn lookupMethod(self: *TypeChecker, type_name: StringId, method_name: StringId) ?ast_mod.FnDecl { - const idx = self.methods.get(methodKey(type_name, method_name)) orelse return null; - return self.arena.impl_methods.items[idx]; + /// The method named `bytes` an inherent impl of arena `a` gives `target`. + fn inherentMethodIn(self: *TypeChecker, a: *const AstArena, target: ImplTarget, bytes: []const u8) ?ast_mod.FnDecl { + for (a.impl_decls.items) |impl| { + if (impl.trait_name != 0) continue; + const t = self.implTargetIn(a, impl.type_name) orelse continue; + if (!t.eql(target)) continue; + if (methodIn(a, impl.methods_start, impl.methods_len, bytes)) |m| return m; + } + return null; } /// Which declaration kind a validated field range belongs to. `component_like` @@ -4637,25 +4880,14 @@ pub const TypeChecker = struct { } } - /// `true` if `name` is a declared `enum`, checked against the AST slab - /// (declaration-order independent, unlike the incrementally-built pass-1 - /// symbol table). Includes the synthetic builtin `ErrorCode`. + /// Whether `name` is a declared `enum`, the file's own or an imported one. fn declaredEnumName(self: *TypeChecker, name: StringId) bool { - for (self.arena.enum_decls.items) |decl| { - if (decl.name == name) return true; - } - return false; + return self.enumNamed(name) != null; } - /// `true` if `name` is a declared `struct`. Checked - /// against the AST struct slab (not the symbol table) so a later-declared - /// struct is seen — pass 1 registers symbols incrementally, mirroring - /// `declaredEnumName`. + /// Whether `name` is a declared `struct`, the file's own or an imported one. fn declaredStructName(self: *TypeChecker, name: StringId) bool { - for (self.arena.struct_decls.items) |decl| { - if (decl.name == name) return true; - } - return false; + return self.structNamed(name) != null; } /// Validate annotation applicability for a `(start, len)` range in @@ -5126,6 +5358,7 @@ pub const TypeChecker = struct { .resource => .{ .resource = resolved_name }, .struct_ => .{ .struct_t = resolved_name }, .enum_ => .{ .enum_t = resolved_name }, + .type_alias => self.importedAliasTarget(entry), else => .unknown, }; } @@ -5151,14 +5384,11 @@ pub const TypeChecker = struct { // (e.g. `T<…>`, unusual) resolves to the variable. const gt = self.arena.generic_type_nodes.items[self.arena.typeNodeData(type_node)]; if (self.generic_scope.contains(gt.name)) return .{ .generic = gt.name }; - if (self.symbols.get(gt.name)) |sym| { - return switch (sym.kind) { - .struct_ => .{ .struct_t = gt.name }, - .enum_ => .{ .enum_t = gt.name }, - else => .unknown, - }; - } - return .unknown; + return switch (self.kindNamed(gt.name) orelse return .unknown) { + .struct_ => .{ .struct_t = gt.name }, + .enum_ => .{ .enum_t = gt.name }, + else => .unknown, + }; }, .array => { const at = self.arena.array_types.items[self.arena.typeNodeData(type_node)]; @@ -5199,6 +5429,12 @@ pub const TypeChecker = struct { } } + /// The type an imported `type` alias names, read in the arena declaring it. + fn importedAliasTarget(self: *TypeChecker, entry: ExportEntry) ResolvedType { + const a = &self.project.?.arenas[entry.arena_index]; + return self.foreignType(a, a.type_alias_decls.items[a.itemData(entry.item_id)].target); + } + /// Outcome of resolving a `.path` qualified type node (`alias.Member`, /// resolution). Split so the silent central resolver (`namedTypeToResolved`) /// and the emitting use site (`validateTypeAliases`) share one lookup, each @@ -5279,14 +5515,16 @@ pub const TypeChecker = struct { /// receivers) to the impl's target type so `self.field` / `self.method()` /// resolve. Associated fns (`self_kind == .none`) bind no receiver. fn checkImpl(self: *TypeChecker, impl: ast_mod.ImplDecl) !void { - // The receiver type for `self`: the impl's target. A declared struct → - // `.struct_t`; a component / resource → their resolved type; the builtin - // `Entity` (a trait impl `impl Trait for Entity`) → `.entity`; anything - // else (validateImpls already flagged it) → `unknown`. - const self_type: ResolvedType = if (self.symbols.get(impl.type_name)) |sym| switch (sym.kind) { + // The receiver type for `self`: the impl's target, declared or + // imported — a struct, component, resource or enum as its resolved + // type, the builtin `Entity` (a trait impl `impl Trait for Entity`) as + // `.entity`, anything else (validateImpls already flagged it) as + // `unknown`. + const self_type: ResolvedType = if (self.kindNamed(impl.type_name)) |kind| switch (kind) { .struct_ => .{ .struct_t = impl.type_name }, .component => .{ .component = impl.type_name }, .resource => .{ .resource = impl.type_name }, + .enum_ => .{ .enum_t = impl.type_name }, else => ResolvedType.unknown, } else if (std.mem.eql(u8, self.arena.strings.slice(impl.type_name), "Entity")) .{ .builtin = .entity } @@ -6042,7 +6280,7 @@ pub const TypeChecker = struct { try scratch.locals.put(self.gpa, field.name, .{ .type_ = self.namedTypeToResolved(field.type_node), .is_mut = false }); } else { const name = try self.arena.strings.intern(self.gpa, a.strings.slice(field.name)); - try scratch.locals.put(self.gpa, name, .{ .type_ = self.foreignFieldType(a, field.type_node), .is_mut = false }); + try scratch.locals.put(self.gpa, name, .{ .type_ = self.foreignType(a, field.type_node), .is_mut = false }); } } const t = try self.synthExprE(node.filter_value, &scratch); @@ -7120,7 +7358,7 @@ pub const TypeChecker = struct { // Resolved here (a bare type is not field-accessible otherwise). if (self.arena.exprKind(fa.receiver) == .path) { const path_name = self.arena.exprData(fa.receiver); - if (self.enumDecl(path_name) != null) { + if (self.enumNamed(path_name) != null) { if (self.enumVariantIndex(path_name, fa.field_name) == null) { try self.emit(.enum_variant_not_found, .error_, self.arena.exprSpan(id), "enum '{s}' has no variant '{s}'", .{ self.arena.strings.slice(path_name), self.arena.strings.slice(fa.field_name) }); return ResolvedType.unknown; @@ -7726,11 +7964,7 @@ pub const TypeChecker = struct { // outside a test body returns null → falls through to E0102 // (§32: "fall through to E0102"). if (try self.synthBuiltinCall(id, call, callee_name, ctx_opt)) |t| return t; - if (self.symbols.get(callee_name)) |sym| { - if (sym.kind == .fn_) { - return try self.synthFreeFnCall(id, call, sym.item_id, ctx_opt); - } - } + if (self.fnNamed(callee_name)) |f| return try self.synthFreeFnCall(id, call, callee_name, f, ctx_opt); } } @@ -7860,11 +8094,14 @@ pub const TypeChecker = struct { return ok; } - /// Collect a callee's parameter names into a caller-owned buffer. - fn fnParamNames(self: *TypeChecker, params_start: u32, params_len: u32, buf: *std.ArrayListUnmanaged(StringId)) !void { + /// Collect the parameter names of `f` into a caller-owned buffer, as this + /// file's names: a name this file never writes is `0`, which no argument + /// label is. + fn fnParamNames(self: *TypeChecker, f: FnRef, buf: *std.ArrayListUnmanaged(StringId)) !void { var i: u32 = 0; - while (i < params_len) : (i += 1) { - try buf.append(self.gpa, self.arena.fn_params.items[params_start + i].name); + while (i < f.decl.params_len) : (i += 1) { + const name = f.arena.fn_params.items[f.decl.params_start + i].name; + try buf.append(self.gpa, if (f.arena == self.arena) name else self.arena.strings.find(f.arena.strings.slice(name)) orelse 0); } } @@ -7872,41 +8109,42 @@ pub const TypeChecker = struct { /// the argument binding (named arguments per §3.3 — E0203) then /// each bound argument against its declared parameter type; the result /// is the declared return type (`unknown` for a void fn). - fn synthFreeFnCall(self: *TypeChecker, id: NodeId, call: ast_mod.CallExpr, item_id: NodeId, ctx_opt: ?*RuleCtx) TypeError!ResolvedType { - const decl = self.arena.fn_decls.items[self.arena.itemData(item_id)]; + fn synthFreeFnCall(self: *TypeChecker, id: NodeId, call: ast_mod.CallExpr, callee_name: StringId, f: FnRef, ctx_opt: ?*RuleCtx) TypeError!ResolvedType { + const decl = f.decl; + const name = self.arena.strings.slice(callee_name); // Function coloring (§9.3): calling an `async fn` from a // non-async context is E0901. (A legal async→async call is via `await`, // which reaches here with `current_is_async` true.) if (decl.is_async and !self.current_is_async) { - try self.emit(.async_call_in_non_async_context, .error_, self.arena.exprSpan(id), "cannot call `async fn` '{s}' from a non-async context (needs an `async fn`/`async rule` + `await`)", .{self.arena.strings.slice(decl.name)}); + try self.emit(.async_call_in_non_async_context, .error_, self.arena.exprSpan(id), "cannot call `async fn` '{s}' from a non-async context (needs an `async fn`/`async rule` + `await`)", .{name}); } else if (decl.is_async and !self.consumesAsyncEffect(id)) { // E0905: a BARE async call in an async context. The // `await` is the SOLE call-grain consumer of the `{async}` effect // (§9.2 revision 2) — the four constructs relocate the suspension, // they do not consume it. - try self.emit(.unconsumed_async_effect, .error_, self.arena.exprSpan(id), "bare call to `async fn` '{s}' — consume the async effect with `await` (inside spawn/branch/race/sync bodies too: the constructs relocate the await into a child task, they do not replace it)", .{self.arena.strings.slice(decl.name)}); + try self.emit(.unconsumed_async_effect, .error_, self.arena.exprSpan(id), "bare call to `async fn` '{s}' — consume the async effect with `await` (inside spawn/branch/race/sync bodies too: the constructs relocate the await into a child task, they do not replace it)", .{name}); } // E0902: a `throws` callee needs somewhere for its throw // to go — an enclosing `try`/`catch`, or a `throws` caller. if (decl.throws and !self.current_can_throw) { - try self.emitUnhandledThrows(id, self.arena.strings.slice(decl.name)); + try self.emitUnhandledThrows(id, name); } - const ret: ResolvedType = if (decl.return_type.isNone()) ResolvedType.unknown else self.namedTypeToResolved(decl.return_type); + const ret: ResolvedType = if (decl.return_type.isNone()) ResolvedType.unknown else self.typeIn(f.arena, decl.return_type); var pnames: std.ArrayListUnmanaged(StringId) = .empty; defer pnames.deinit(self.gpa); - try self.fnParamNames(decl.params_start, decl.params_len, &pnames); - if (!try self.checkCallBinding(id, call.args_start, call.args_len, call.names_start, pnames.items, "function", decl.name)) { + try self.fnParamNames(f, &pnames); + if (!try self.checkCallBinding(id, call.args_start, call.args_len, call.names_start, pnames.items, "function", callee_name)) { return ret; } - if (decl.generics_len > 0) return try self.synthGenericFnCall(id, call, decl, ctx_opt); + if (decl.generics_len > 0) return try self.synthGenericFnCall(id, call, name, f, pnames.items, ctx_opt); var i: u32 = 0; while (i < decl.params_len) : (i += 1) { - const p = self.arena.fn_params.items[decl.params_start + i]; - const ptype = self.namedTypeToResolved(p.type_node); - const arg = self.arena.callArgForParam(call.args_start, call.args_len, call.names_start, i, p.name) orelse continue; + const p = f.arena.fn_params.items[decl.params_start + i]; + const ptype = self.typeIn(f.arena, p.type_node); + const arg = self.arena.callArgForParam(call.args_start, call.args_len, call.names_start, i, pnames.items[i]) orelse continue; const arg_t = try self.synthExprE(arg, ctx_opt); if (!try self.valueFits(ptype, arg, arg_t)) { - try self.emit(.type_mismatch, .error_, self.arena.exprSpan(arg), "argument type does not match the parameter type of function '{s}'", .{self.arena.strings.slice(decl.name)}); + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(arg), "argument type does not match the parameter type of function '{s}'", .{name}); } } return ret; @@ -7920,44 +8158,46 @@ pub const TypeChecker = struct { /// monomorphisation instance table (§6.2) drives a bytecode codegen /// lowering — there is no consumer in the direct AST→Zig path, so the /// resolver computes the substitution per call without persisting it. - fn synthGenericFnCall(self: *TypeChecker, id: NodeId, call: ast_mod.CallExpr, decl: ast_mod.FnDecl, ctx_opt: ?*RuleCtx) TypeError!ResolvedType { + fn synthGenericFnCall(self: *TypeChecker, id: NodeId, call: ast_mod.CallExpr, name: []const u8, f: FnRef, pnames: []const StringId, ctx_opt: ?*RuleCtx) TypeError!ResolvedType { + const decl = f.decl; + // Keyed by the parameter names of `f.arena`. var subst: std.AutoHashMapUnmanaged(StringId, ResolvedType) = .empty; defer subst.deinit(self.gpa); + // A formal naming a type parameter checks nothing: `unifyGeneric` + // judges it. + const unbound: std.AutoHashMapUnmanaged(StringId, ResolvedType) = .empty; - var pnames: std.ArrayListUnmanaged(StringId) = .empty; - defer pnames.deinit(self.gpa); - try self.fnParamNames(decl.params_start, decl.params_len, &pnames); var i: u32 = 0; while (i < decl.params_len) : (i += 1) { - const p = self.arena.fn_params.items[decl.params_start + i]; - const arg = self.arena.callArgForParam(call.args_start, call.args_len, call.names_start, i, p.name) orelse continue; + const p = f.arena.fn_params.items[decl.params_start + i]; + const arg = self.arena.callArgForParam(call.args_start, call.args_len, call.names_start, i, pnames[i]) orelse continue; const arg_t = try self.synthExprE(arg, ctx_opt); - try self.unifyGeneric(decl, p.type_node, arg_t, &subst, self.arena.exprSpan(arg)); - if (!try self.valueFits(self.namedTypeToResolved(p.type_node), arg, arg_t)) { - try self.emit(.type_mismatch, .error_, self.arena.exprSpan(arg), "argument type does not match the parameter type of function '{s}'", .{self.arena.strings.slice(decl.name)}); + try self.unifyGeneric(f, p.type_node, arg_t, &subst, self.arena.exprSpan(arg)); + if (!try self.valueFits(self.substituteGeneric(f, p.type_node, &unbound), arg, arg_t)) { + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(arg), "argument type does not match the parameter type of function '{s}'", .{name}); } } var gi: u32 = 0; while (gi < decl.generics_len) : (gi += 1) { - const gp = self.arena.generic_params.items[decl.generics_start + gi]; + const gp = f.arena.generic_params.items[decl.generics_start + gi]; const inferred = subst.get(gp.name); if (inferred == null) { - try self.emit(.generic_type_annotation_required, .error_, self.arena.exprSpan(id), "cannot infer type parameter '{s}' of '{s}' from the arguments", .{ self.arena.strings.slice(gp.name), self.arena.strings.slice(decl.name) }); + try self.emit(.generic_type_annotation_required, .error_, self.arena.exprSpan(id), "cannot infer type parameter '{s}' of '{s}' from the arguments", .{ f.arena.strings.slice(gp.name), name }); continue; } - try self.checkGenericBounds(gp, inferred.?, self.arena.exprSpan(id)); + try self.checkGenericBounds(f.arena, gp, inferred.?, self.arena.exprSpan(id)); } if (decl.return_type.isNone()) return ResolvedType.unknown; - return self.substituteGeneric(decl, decl.return_type, &subst); + return self.substituteGeneric(f, decl.return_type, &subst); } - /// `true` if `name` is a generic parameter of `decl`. - fn isGenericParamOf(self: *TypeChecker, decl: ast_mod.FnDecl, name: StringId) bool { + /// `true` if `name`, a name of `f.arena`, is a generic parameter of `f`. + fn isGenericParamOf(f: FnRef, name: StringId) bool { var i: u32 = 0; - while (i < decl.generics_len) : (i += 1) { - if (self.arena.generic_params.items[decl.generics_start + i].name == name) return true; + while (i < f.decl.generics_len) : (i += 1) { + if (f.arena.generic_params.items[f.decl.generics_start + i].name == name) return true; } return false; } @@ -7966,28 +8206,28 @@ pub const TypeChecker = struct { /// type, binding generic variables into `subst` (§6.4). /// Handles a bare param `T` and an element-of `T[]` / `T[N]`; deeper nesting /// is not inferred (leaves the param unbound → E0603 if unresolved). - fn unifyGeneric(self: *TypeChecker, decl: ast_mod.FnDecl, formal: NodeId, actual: ResolvedType, subst: *std.AutoHashMapUnmanaged(StringId, ResolvedType), span: SourceSpan) TypeError!void { - switch (self.arena.typeNodeKind(formal)) { + fn unifyGeneric(self: *TypeChecker, f: FnRef, formal: NodeId, actual: ResolvedType, subst: *std.AutoHashMapUnmanaged(StringId, ResolvedType), span: SourceSpan) TypeError!void { + switch (f.arena.typeNodeKind(formal)) { .named => { - const name = self.arena.namedTypeName(formal).?; - if (!self.isGenericParamOf(decl, name)) return; // concrete formal — no binding + const name = f.arena.namedTypeName(formal).?; + if (!isGenericParamOf(f, name)) return; // concrete formal — no binding if (actual == .unknown) return; // don't pin a param to a post-error unknown if (subst.get(name)) |prev| { if (!ResolvedType.eql(prev, actual)) { - try self.emit(.inconsistent_generic_inference, .error_, span, "type parameter '{s}' is inferred as two different types", .{self.arena.strings.slice(name)}); + try self.emit(.inconsistent_generic_inference, .error_, span, "type parameter '{s}' is inferred as two different types", .{f.arena.strings.slice(name)}); } } else { try subst.put(self.gpa, name, actual); } }, .array, .slice => { - const at = self.arena.array_types.items[self.arena.typeNodeData(formal)]; + const at = f.arena.array_types.items[f.arena.typeNodeData(formal)]; const elem: ?ResolvedType = switch (actual) { .array_fixed => |info| .{ .builtin = info.elem }, .array_dyn => |e| .{ .builtin = e }, else => null, }; - if (elem) |ea| try self.unifyGeneric(decl, at.elem, ea, subst, span); + if (elem) |ea| try self.unifyGeneric(f, at.elem, ea, subst, span); }, else => {}, // generic_type / map / set — not inferred } @@ -7997,22 +8237,22 @@ pub const TypeChecker = struct { /// param `T` → its inferred type (or `.generic` if still /// unbound); `T[]` → a dynamic array of the substituted element; otherwise /// the ordinary resolution. - fn substituteGeneric(self: *TypeChecker, decl: ast_mod.FnDecl, node: NodeId, subst: *std.AutoHashMapUnmanaged(StringId, ResolvedType)) ResolvedType { - switch (self.arena.typeNodeKind(node)) { + fn substituteGeneric(self: *TypeChecker, f: FnRef, node: NodeId, subst: *const std.AutoHashMapUnmanaged(StringId, ResolvedType)) ResolvedType { + switch (f.arena.typeNodeKind(node)) { .named => { - const name = self.arena.namedTypeName(node).?; - if (self.isGenericParamOf(decl, name)) { + const name = f.arena.namedTypeName(node).?; + if (isGenericParamOf(f, name)) { return subst.get(name) orelse ResolvedType{ .generic = name }; } - return self.namedTypeToResolved(node); + return self.typeIn(f.arena, node); }, .slice => { - const at = self.arena.array_types.items[self.arena.typeNodeData(node)]; - const e = self.substituteGeneric(decl, at.elem, subst); + const at = f.arena.array_types.items[f.arena.typeNodeData(node)]; + const e = self.substituteGeneric(f, at.elem, subst); if (e == .builtin) return .{ .array_dyn = e.builtin }; return ResolvedType.unknown; }, - else => return self.namedTypeToResolved(node), + else => return self.typeIn(f.arena, node), } } @@ -8020,29 +8260,33 @@ pub const TypeChecker = struct { /// 4, §6.5). `component` / `resource` require the RTTI category; /// `trait` requires an `impl Trait for ` in the compilation set; /// E0601 otherwise. - fn checkGenericBounds(self: *TypeChecker, gp: ast_mod.GenericParam, actual: ResolvedType, span: SourceSpan) TypeError!void { + fn checkGenericBounds(self: *TypeChecker, a: *const AstArena, gp: ast_mod.GenericParam, actual: ResolvedType, span: SourceSpan) TypeError!void { var bi: u32 = 0; while (bi < gp.bounds_len) : (bi += 1) { - const b = self.arena.generic_bounds.items[gp.bounds_start + bi]; + const b = a.generic_bounds.items[gp.bounds_start + bi]; const ok = switch (b.kind) { .component => actual == .component, .resource => actual == .resource, .event => false, // `event` bound needs the `event` keyword .trait_ => blk: { const tn = typeNameOfResolved(actual) orelse break :blk false; - break :blk self.typeImplementsTrait(tn, b.trait_name); + const target = self.implTargetIn(self.arena, tn) orelse break :blk false; + const trait = self.declNamedIn(a, b.trait_name) orelse break :blk false; + break :blk self.typeImplementsTrait(target, trait); }, }; if (!ok) { - try self.emit(.bound_not_satisfied, .error_, span, "type argument for '{s}' does not satisfy its bound", .{self.arena.strings.slice(gp.name)}); + try self.emit(.bound_not_satisfied, .error_, span, "type argument for '{s}' does not satisfy its bound", .{a.strings.slice(gp.name)}); } } } - /// `true` if an `impl for ` exists. - fn typeImplementsTrait(self: *TypeChecker, type_name: StringId, trait_name: StringId) bool { + /// `true` if an `impl` of `trait` for `target` is visible here. + fn typeImplementsTrait(self: *TypeChecker, target: ImplTarget, trait: DeclRef) bool { for (self.trait_impls.items) |entry| { - if (entry.type_name == type_name and entry.trait_name == trait_name) return true; + const t = self.implTargetIn(entry.arena, entry.type_name) orelse continue; + const tr = self.declNamedIn(entry.arena, entry.trait_name) orelse continue; + if (t.eql(target) and tr.eql(trait)) return true; } return false; } @@ -8085,12 +8329,11 @@ pub const TypeChecker = struct { /// supplied by its context — check mode, resolver-types §4). fn checkStructLitAgainst(self: *TypeChecker, id: NodeId, data: u32, struct_name: StringId, ctx_opt: ?*RuleCtx) TypeError!ResolvedType { const sl = self.arena.struct_lits.items[data]; - const sym = self.symbols.get(struct_name); - if (sym == null or sym.?.kind != .struct_) { + const target = self.structNamed(struct_name) orelse { try self.emit(.undefined_symbol, .error_, self.arena.exprSpan(id), "'{s}' is not a struct type", .{self.arena.strings.slice(struct_name)}); return ResolvedType.unknown; - } - const decl = self.arena.struct_decls.items[self.arena.itemData(sym.?.item_id)]; + }; + const decl = target.decl; var i: u32 = 0; while (i < sl.fields_len) : (i += 1) { const flit = self.arena.struct_lit_fields.items[sl.fields_start + i]; @@ -8098,9 +8341,9 @@ pub const TypeChecker = struct { var declared: ?ResolvedType = null; var f_i: u32 = 0; while (f_i < decl.fields_len) : (f_i += 1) { - const f = self.arena.fields.items[decl.fields_start + f_i]; - if (f.name == flit.name) { - declared = self.namedTypeToResolved(f.type_node); + const f = target.arena.fields.items[decl.fields_start + f_i]; + if (self.sameName(target.arena, f.name, flit.name)) { + declared = self.typeIn(target.arena, f.type_node); break; } } @@ -8160,18 +8403,18 @@ pub const TypeChecker = struct { // zero-fill would diverge on nested declared defaults). var df_i: u32 = 0; while (df_i < decl.fields_len) : (df_i += 1) { - const f = self.arena.fields.items[decl.fields_start + df_i]; - if (self.namedTypeToResolved(f.type_node) != .struct_t) continue; + const f = target.arena.fields.items[decl.fields_start + df_i]; + if (self.typeIn(target.arena, f.type_node) != .struct_t) continue; var provided = false; var li: u32 = 0; while (li < sl.fields_len) : (li += 1) { - if (self.arena.struct_lit_fields.items[sl.fields_start + li].name == f.name) { + if (self.sameName(target.arena, f.name, self.arena.struct_lit_fields.items[sl.fields_start + li].name)) { provided = true; break; } } if (!provided) { - try self.emit(.struct_field_missing, .error_, self.arena.exprSpan(id), "struct-typed field '{s}' must be provided in the literal (no declared default)", .{self.arena.strings.slice(f.name)}); + try self.emit(.struct_field_missing, .error_, self.arena.exprSpan(id), "struct-typed field '{s}' must be provided in the literal (no declared default)", .{target.arena.strings.slice(f.name)}); } } return .{ .struct_t = struct_name }; @@ -8224,11 +8467,11 @@ pub const TypeChecker = struct { } return try self.synthSetAssociated(id, mc, ctx_opt); } - const method = self.lookupMethod(type_name, mc.method_name) orelse { + const method = (try self.lookupMethod(type_name, mc.method_name, self.arena.exprSpan(id))) orelse { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(id), "no associated function '{s}' on type '{s}'", .{ method_slice, self.arena.strings.slice(type_name) }); return ResolvedType.unknown; }; - if (method.self_kind != .none) { + if (method.decl.self_kind != .none) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(id), "'{s}' is a method (takes self) — call it as a receiver method 'value.{s}(...)'", .{ method_slice, method_slice }); return ResolvedType.unknown; } @@ -8748,12 +8991,12 @@ pub const TypeChecker = struct { }; // Step 1 — inherent method (§5.1). - if (self.lookupMethod(type_name, mc.method_name)) |method| { - if (method.self_kind == .none) { + if (try self.lookupMethod(type_name, mc.method_name, self.arena.exprSpan(id))) |method| { + if (method.decl.self_kind == .none) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(id), "'{s}' is an associated function (no self) — call it as '{s}.{s}(...)'", .{ method_slice, self.arena.strings.slice(type_name), method_slice }); return ResolvedType.unknown; } - try self.checkMutSelfReceiver(method, mc, ctx_opt); + try self.checkMutSelfReceiver(method.decl, mc, ctx_opt); return try self.checkMethodArgs(id, mc, method, ctx_opt); } @@ -8761,10 +9004,10 @@ pub const TypeChecker = struct { if (try self.findTraitMethod(type_name, mc.method_name, self.arena.exprSpan(id))) |disp| { // Conditional impl (§7.3): the `when` conditions must be provable // from the calling rule's `when` (E0215 otherwise). - if (!self.traitImplConditionsProven(disp.when_root, ctx_opt)) { + if (!self.traitImplConditionsProven(disp.impl_arena, disp.when_root, ctx_opt)) { try self.emit(.conditional_impl_condition_not_proven, .error_, self.arena.exprSpan(id), "conditional impl method '{s}' requires components the calling context does not guarantee — add the matching 'when ... has' to the rule", .{method_slice}); } - try self.checkMutSelfReceiver(disp.method, mc, ctx_opt); + try self.checkMutSelfReceiver(disp.method.decl, mc, ctx_opt); return try self.checkMethodArgs(id, mc, disp.method, ctx_opt); } @@ -8773,77 +9016,90 @@ pub const TypeChecker = struct { return ResolvedType.unknown; } - /// One resolved trait-dispatch candidate: the - /// `FnDecl` to call (impl-provided or trait default) + the impl's `when_root` - /// (for the §7.3 conditional proof). - const TraitDispatch = struct { method: ast_mod.FnDecl, when_root: u32 }; + /// One resolved trait-dispatch candidate: the method to call + /// (impl-provided or trait default) + the arena of its impl, whose + /// `when_root` it is (for the §7.3 conditional proof). + const TraitDispatch = struct { method: FnRef, impl_arena: *const AstArena, when_root: u32 }; /// Find the trait method `method_name` on `type_name` (`etch-resolver-types.md - /// §5.2`). Scans `trait_impls` for the type; a candidate is an impl-provided - /// method or, failing that, the trait's default-bodied method. >1 distinct - /// candidate ⇒ E0211 AmbiguousTraitMethod (the first is returned to avoid a - /// cascade). Simplification: multiple conditional impls of the same - /// trait also surface as E0211 (the §7.3 most-specific-wins / E0216 tie-break - /// is a refinement — flagged for review). + /// §5.2`). Scans the visible trait impls for the type; a candidate is an + /// impl-provided method or, failing that, the trait's default-bodied + /// method. >1 distinct candidate ⇒ E0211 AmbiguousTraitMethod (the first + /// is returned to avoid a cascade). Simplification: multiple conditional + /// impls of the same trait also surface as E0211 (the §7.3 + /// most-specific-wins / E0216 tie-break is a refinement — flagged for + /// review). fn findTraitMethod(self: *TypeChecker, type_name: StringId, method_name: StringId, span: SourceSpan) TypeError!?TraitDispatch { + const target = self.implTargetIn(self.arena, type_name) orelse return null; + const bytes = self.arena.strings.slice(method_name); var found: ?TraitDispatch = null; var count: u32 = 0; for (self.trait_impls.items) |entry| { - if (entry.type_name != type_name) continue; - var resolved: ?ast_mod.FnDecl = null; - var k: u32 = 0; - while (k < entry.methods_len) : (k += 1) { - const mth = self.arena.impl_methods.items[entry.methods_start + k]; - if (mth.name == method_name) { - resolved = mth; - break; - } + const t = self.implTargetIn(entry.arena, entry.type_name) orelse continue; + if (!t.eql(target)) continue; + var resolved: ?FnRef = null; + if (methodIn(entry.arena, entry.methods_start, entry.methods_len, bytes)) |m| { + resolved = .{ .arena = entry.arena, .decl = m }; + } else if (self.declNamedIn(entry.arena, entry.trait_name)) |trait| { + resolved = traitDefaultMethod(trait, bytes); } - if (resolved == null) resolved = self.traitDefaultMethod(entry.trait_name, method_name); if (resolved) |mth| { count += 1; - if (found == null) found = .{ .method = mth, .when_root = entry.when_root }; + if (found == null) found = .{ .method = mth, .impl_arena = entry.arena, .when_root = entry.when_root }; } } if (count > 1) { - try self.emit(.ambiguous_trait_method, .error_, span, "ambiguous trait method '{s}' — implemented by more than one trait/impl for this type", .{self.arena.strings.slice(method_name)}); + try self.emit(.ambiguous_trait_method, .error_, span, "ambiguous trait method '{s}' — implemented by more than one trait/impl for this type", .{bytes}); } return found; } - /// The trait's default-bodied method `method_name`, or `null`. - fn traitDefaultMethod(self: *TypeChecker, trait_name: StringId, method_name: StringId) ?ast_mod.FnDecl { - const sym = self.symbols.get(trait_name) orelse return null; - if (sym.kind != .trait_) return null; - const tdecl = self.arena.trait_decls.items[self.arena.itemData(sym.item_id)]; + /// The default-bodied method named `bytes` of `trait`, or `null`. + fn traitDefaultMethod(trait: DeclRef, bytes: []const u8) ?FnRef { + if (trait.arena.itemKind(trait.item) != .trait_decl) return null; + const tdecl = trait.arena.trait_decls.items[trait.arena.itemData(trait.item)]; var m: u32 = 0; while (m < tdecl.methods_len) : (m += 1) { - const tm = self.arena.impl_methods.items[tdecl.methods_start + m]; - if (tm.name == method_name and tm.has_body) return tm; + const tm = trait.arena.impl_methods.items[tdecl.methods_start + m]; + if (tm.has_body and std.mem.eql(u8, trait.arena.strings.slice(tm.name), bytes)) return .{ .arena = trait.arena, .decl = tm }; } return null; } - /// Prove a conditional trait impl's `when` (§7.3). Unconditional ⇒ always - /// proven; otherwise every `has C` the impl requires must be in the calling - /// rule's guaranteed component set (`ctx.components_in_when`). Outside a rule - /// context, or for an `or`/`not`/resource condition (not provable), - /// the proof fails (conservative). - fn traitImplConditionsProven(self: *TypeChecker, when_root: u32, ctx_opt: ?*RuleCtx) bool { + /// Prove a conditional trait impl's `when` (§7.3), a condition of arena + /// `a`. Unconditional ⇒ always proven; otherwise every `has C` the impl + /// requires must be in the calling rule's guaranteed component set + /// (`ctx.components_in_when`). Outside a rule context, or for an + /// `or`/`not`/resource condition (not provable), the proof fails + /// (conservative). + fn traitImplConditionsProven(self: *TypeChecker, a: *const AstArena, when_root: u32, ctx_opt: ?*RuleCtx) bool { if (when_root == ast_mod.RuleDecl.none_when) return true; const ctx = ctx_opt orelse return false; - return self.requiredComponentsProven(when_root, ctx); + return self.requiredComponentsProven(a, when_root, ctx); } - fn requiredComponentsProven(self: *TypeChecker, idx: u32, ctx: *RuleCtx) bool { - const node = self.arena.when_nodes.items[idx]; + fn requiredComponentsProven(self: *TypeChecker, a: *const AstArena, idx: u32, ctx: *RuleCtx) bool { + const node = a.when_nodes.items[idx]; return switch (node.kind) { - .has, .has_with_filter => ctx.components_in_when.contains(node.type_name), - .logical_and => self.requiredComponentsProven(node.lhs, ctx) and self.requiredComponentsProven(node.rhs, ctx), + .has, .has_with_filter => self.componentGuaranteed(a, node.type_name, ctx), + .logical_and => self.requiredComponentsProven(a, node.lhs, ctx) and self.requiredComponentsProven(a, node.rhs, ctx), else => false, // or / not / resource conditions are not provable }; } + /// Whether the calling rule's `when` guarantees the component that + /// `name`, a name of arena `a`, names. + fn componentGuaranteed(self: *TypeChecker, a: *const AstArena, name: StringId, ctx: *RuleCtx) bool { + if (a == self.arena) return ctx.components_in_when.contains(name); + const want = self.declNamedIn(a, name) orelse return false; + var it = ctx.components_in_when.keyIterator(); + while (it.next()) |k| { + const have = self.declNamed(k.*) orelse continue; + if (have.eql(want)) return true; + } + return false; + } + /// E0220-shaped check for the builtin mutating collection methods: `push` / /// `insert` are `mut self` per stdlib §13.2/§14.2, so the receiver must be a /// mutable binding. Same reachability rule as `checkMutSelfReceiver`, without a @@ -8869,7 +9125,8 @@ pub const TypeChecker = struct { /// Check a method/associated-fn call's argument count + types against the /// resolved `method` and return its declared return type. /// `self` is not part of the argument list (it is the receiver). - fn checkMethodArgs(self: *TypeChecker, id: NodeId, mc: ast_mod.MethodCall, method: ast_mod.FnDecl, ctx_opt: ?*RuleCtx) TypeError!ResolvedType { + fn checkMethodArgs(self: *TypeChecker, id: NodeId, mc: ast_mod.MethodCall, m: FnRef, ctx_opt: ?*RuleCtx) TypeError!ResolvedType { + const method = m.decl; // Function coloring (§9.3): calling an `async method` from a // non-async context is E0901 (a legal call is via `await` in an async // context, which reaches here with `current_is_async` true). @@ -8879,18 +9136,18 @@ pub const TypeChecker = struct { // E0905 (mirror of the free-fn site, §9.2 revision 2). try self.emit(.unconsumed_async_effect, .error_, self.arena.exprSpan(id), "bare call to `async` method '{s}' — consume the async effect with `await` (inside spawn/branch/race/sync bodies too: the constructs relocate the await into a child task, they do not replace it)", .{self.arena.strings.slice(mc.method_name)}); } - const ret: ResolvedType = if (method.return_type.isNone()) ResolvedType.unknown else self.namedTypeToResolved(method.return_type); + const ret: ResolvedType = if (method.return_type.isNone()) ResolvedType.unknown else self.typeIn(m.arena, method.return_type); var pnames: std.ArrayListUnmanaged(StringId) = .empty; defer pnames.deinit(self.gpa); - try self.fnParamNames(method.params_start, method.params_len, &pnames); + try self.fnParamNames(m, &pnames); if (!try self.checkCallBinding(id, mc.args_start, mc.args_len, mc.names_start, pnames.items, "method", mc.method_name)) { return ret; } var i: u32 = 0; while (i < method.params_len) : (i += 1) { - const p = self.arena.fn_params.items[method.params_start + i]; - const ptype = self.namedTypeToResolved(p.type_node); - const arg = self.arena.callArgForParam(mc.args_start, mc.args_len, mc.names_start, i, p.name) orelse continue; + const p = m.arena.fn_params.items[method.params_start + i]; + const ptype = self.typeIn(m.arena, p.type_node); + const arg = self.arena.callArgForParam(mc.args_start, mc.args_len, mc.names_start, i, pnames.items[i]) orelse continue; const arg_t = try self.synthExprE(arg, ctx_opt); if (!try self.valueFits(ptype, arg, arg_t)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(arg), "argument type does not match the parameter type of method '{s}'", .{self.arena.strings.slice(mc.method_name)}); @@ -8962,7 +9219,7 @@ pub const TypeChecker = struct { var covered: std.ArrayListUnmanaged(bool) = .empty; defer covered.deinit(self.gpa); if (enum_name) |en| { - if (self.enumDecl(en)) |d| try covered.appendNTimes(self.gpa, false, d.variants_len); + if (self.enumNamed(en)) |e| try covered.appendNTimes(self.gpa, false, e.decl.variants_len); } var i: u32 = 0; @@ -9190,12 +9447,11 @@ pub const TypeChecker = struct { .struct_t => |name_id| { // Field of a `struct` value — e.g. `self.x` // inside a method or `v.x` on a struct local. - const sym = self.symbols.get(name_id) orelse return ResolvedType.unknown; - const decl = self.arena.struct_decls.items[self.arena.itemData(sym.item_id)]; + const target = self.structNamed(name_id) orelse return ResolvedType.unknown; var i: u32 = 0; - while (i < decl.fields_len) : (i += 1) { - const f = self.arena.fields.items[decl.fields_start + i]; - if (f.name == field_name) return self.namedTypeToResolved(f.type_node); + while (i < target.decl.fields_len) : (i += 1) { + const f = target.arena.fields.items[target.decl.fields_start + i]; + if (self.sameName(target.arena, f.name, field_name)) return self.typeIn(target.arena, f.type_node); } try self.emit(.invalid_field_filter, .error_, span, "field '{s}' does not exist on struct '{s}'", .{ self.arena.strings.slice(field_name), self.arena.strings.slice(name_id) }); return ResolvedType.unknown; diff --git a/tests/etch/diagnostic_coverage_test.zig b/tests/etch/diagnostic_coverage_test.zig index 8f7d5a1d..d0212b3e 100644 --- a/tests/etch/diagnostic_coverage_test.zig +++ b/tests/etch/diagnostic_coverage_test.zig @@ -6,29 +6,25 @@ //! test asserting absence cannot do. //! //! WHAT THIS FILE DOES NOT COVER, and why none of it can be covered the same -//! way. Of the 203 declared codes, 138 already carry an assertion elsewhere and -//! 33 land here. The remaining 32 cannot have a test that reddens, because -//! there is nothing to stop emitting: +//! way. These codes cannot have a test that reddens, because there is nothing +//! to stop emitting — they appear ONLY in `src/etch/diagnostics.zig`, declared +//! and referenced nowhere else in the tree: //! -//! E0420 E0421 E1544 E1549 E1563 E1610 E1611 E1622 E1642 E1643 E1660 E1662 -//! E1663 E1667 E1668 E1688 E1691 E1692 E1694 E1700 E1701 E1724 E1725 E1748 -//! E1796 E1802 E1807 E1902 W1682 W1790 W1801 +//! E0420 E0421 E1216 E1544 E1549 E1563 E1610 E1611 E1622 E1642 E1643 E1660 +//! E1662 E1663 E1667 E1668 E1688 E1691 E1692 E1694 E1700 E1701 E1724 E1725 +//! E1748 E1796 E1797 E1802 E1807 W1682 W1790 W1801 //! -//! appear ONLY in `src/etch/diagnostics.zig` — declared, referenced nowhere -//! else in the tree. `E1902` is the one with a reference, in the `.d.etch` -//! drift tool, as a report LABEL rather than an emitted diagnostic. +//! `E1902` has one reference, in the `.d.etch` drift tool, as a report LABEL +//! rather than an emitted diagnostic. //! -//! `E0217` is the 32nd, left off that list only because the last test here -//! names it, to assert its absence. -//! -//! AND WHAT THE COUNT ITSELF DOES NOT SEE. The 138 is a STATIC reading of which -//! tests name which code, and it is not verified per code: a test may name a -//! code it does not exercise. That reading was wrong three times here — it -//! called E1208, E1209 and E1215 uncovered when inline tests in `interp.zig` -//! do cover them, through a helper whose parameter is `anytype` and therefore -//! invisible to any search over signatures. What settled it was mutation: -//! making the checker swallow a code and observing which tests go red. Only -//! the 33 below have been verified that way. +//! AND WHAT A STATIC READING DOES NOT SEE. Which tests name which code is not +//! verified per code: a test may name a code it does not exercise. That +//! reading was wrong three times here — it called E1208, E1209 and E1215 +//! uncovered when inline tests in `interp.zig` do cover them, through a helper +//! whose parameter is `anytype` and therefore invisible to any search over +//! signatures. What settled it was mutation: making the checker swallow a code +//! and observing which tests go red. Only the 33 below have been verified that +//! way. const std = @import("std"); const weld_etch = @import("weld_etch"); @@ -556,7 +552,7 @@ test "W1740 empty track" { try expectAnyCode(c.diags.items, .empty_track); } -test "E0217 has no producer: an impl naming two undeclared symbols answers undefined_symbol" { +test "an impl naming two undeclared symbols answers undefined_symbol, not orphan_impl" { const gpa = std.testing.allocator; // WRONG FIX when the loop below reddens: deleting it. An undeclared trait // or type is not a foreign one, so `orphan_impl` never answers this program. diff --git a/tests/etch/import_resolve_test.zig b/tests/etch/import_resolve_test.zig index 6cbe7782..4ac07891 100644 --- a/tests/etch/import_resolve_test.zig +++ b/tests/etch/import_resolve_test.zig @@ -225,6 +225,43 @@ const positions_lib = \\event P { v: int = 0 } \\event Hit { who: Entity } \\const CAP: int = 8 + \\struct Pt { x: int = 0, y: int = 0 } + \\struct Seg { a: Pt } + \\struct Box { v: T } + \\enum Dir { north, south } + \\struct Cfg { d: Dir = .north } + \\resource Mode { d: Dir = .north } + \\event Moved { d: Dir } + \\fn twice(n: int) -> int { n * 2 } + \\fn go() { } + \\fn mk() -> Pt { Pt { x: 1 } } + \\fn same(t: T) -> T { t } + \\trait Shape { + \\ fn area(self) -> int + \\} + \\trait Doubler { + \\ fn base(self) -> int + \\ fn doubled(self) -> int { self.base() * 2 } + \\} + \\impl Pt { + \\ fn len(self) -> int { self.x } + \\ fn scaled(self, k: int) -> int { self.x * k } + \\ fn origin() -> Pt { Pt { x: 0 } } + \\} + \\impl Shape for Pt { + \\ fn area(self) -> int { self.x * self.y } + \\} + \\impl Doubler for Pt { + \\ fn base(self) -> int { self.x } + \\} + \\fn shaped(t: T) -> int { 0 } + \\trait Hurt { + \\ fn hp(self) -> int + \\} + \\impl Hurt for Entity when self has C { + \\ fn hp(self) -> int { self.get(C).v } + \\} + \\type Meters = float \\ ; @@ -272,16 +309,86 @@ const position_cases = [_]Case{ .{ .name = "malformed emit component", .body = "rule r() { emit C { v: 1 } }" }, .{ .name = "const read", .body = "rule r() { let x: int = CAP }" }, .{ .name = "malformed const read type", .body = "rule r() { let x: bool = CAP }" }, + .{ .name = "struct literal", .body = "rule r() { let p = Pt { x: 1 } }" }, + .{ .name = "struct literal bad field", .body = "rule r() { let p = Pt { w: 1 } }" }, + .{ .name = "struct literal bad type", .body = "rule r() { let p = Pt { x: true } }" }, + .{ .name = "anon literal by annotation", .body = "rule r() { let p: Pt = .{ x: 1 } }" }, + .{ .name = "anon literal bad field", .body = "rule r() { let p: Pt = .{ w: 1 } }" }, + .{ .name = "nested anon field", .body = "rule r() { let s = Seg { a: .{ x: 1 } } }" }, + .{ .name = "struct field missing", .body = "rule r() { let s = Seg { } }" }, + .{ .name = "struct param and return", .body = "fn f(p: Pt) -> Pt { p }" }, + .{ .name = "struct field read", .body = "fn f(p: Pt) -> int { p.x }" }, + .{ .name = "struct field read unknown", .body = "fn f(p: Pt) -> int { p.w }" }, + .{ .name = "struct field read type", .body = "fn f(p: Pt) -> bool { p.x }" }, + .{ .name = "struct field of local struct", .body = "struct Wrap { p: Pt }" }, + .{ .name = "data entry type", .body = "data T: Pt {\n a: { x: 1 },\n}" }, + .{ .name = "data entry bad field", .body = "data T: Pt {\n a: { w: 1 },\n}" }, + .{ .name = "method call", .body = "fn f(p: Pt) -> int { p.len() }" }, + .{ .name = "associated fn", .body = "rule r() { let p = Pt.origin() }" }, + .{ .name = "trait method via lib impl", .body = "fn f(p: Pt) -> int { p.area() }" }, + .{ .name = "trait default method", .body = "fn f(p: Pt) -> int { p.doubled() }" }, + .{ .name = "unknown method", .body = "fn f(p: Pt) -> int { p.nope() }" }, + .{ .name = "bound via lib impl", .body = "fn g(t: T) -> int { 0 }\nfn f(p: Pt) -> int { g(p) }" }, + .{ .name = "generic struct param", .body = "fn f(b: Box) -> int { 0 }" }, + .{ .name = "generic struct literal", .body = "rule r() { let b = Box { v: 1 } }" }, + .{ .name = "enum value", .body = "rule r() { let d = Dir.north }" }, + .{ .name = "enum wrong variant", .body = "rule r() { let d = Dir.west }" }, + .{ .name = "match enum value", .body = "fn f() -> int {\n let d = Dir.north\n match d { Dir.north => 1, .south => 2 }\n}" }, + .{ .name = "match enum param", .body = "fn f(d: Dir) -> int { match d { Dir.north => 1, .south => 2 } }" }, + .{ .name = "match wrong variant", .body = "fn f(d: Dir) -> int { match d { .west => 1, _ => 0 } }" }, + .{ .name = "match non-exhaustive", .body = "fn f(d: Dir) -> int { match d { .north => 1 } }" }, + .{ .name = "enum shorthand in lib struct", .body = "rule r() { let c = Cfg { d: .south } }" }, + .{ .name = "enum shorthand wrong variant", .body = "rule r() { let c = Cfg { d: .west } }" }, + .{ .name = "enum field of local struct", .body = "struct L { d: Dir = .north }" }, + .{ .name = "enum field local struct lit", .body = "struct L { d: Dir = .north }\nrule r() { let l = L { d: .south } }" }, + .{ .name = "enum field of local resource", .body = "resource L { d: Dir = .north }" }, + .{ .name = "enum collection in resource", .body = "resource L { ds: Dir[] }" }, + .{ .name = "enum field of local event", .body = "event L { d: Dir }" }, + .{ .name = "enum field of lib resource", .body = "rule r() when resource Mode { let x = match get(Mode).d { .north => 1, .south => 2 } }" }, + .{ .name = "enum field of lib event", .body = "@on_event(Moved)\nrule r() { let x = match event.d { .north => 1, .south => 2 } }" }, + .{ .name = "enum param and return", .body = "fn f(d: Dir) -> Dir { d }" }, + .{ .name = "fn call", .body = "rule r() { let x = twice(2) }" }, + .{ .name = "fn call arity", .body = "rule r() { let x = twice(1, 2) }" }, + .{ .name = "fn call arg type", .body = "rule r() { let x = twice(true) }" }, + .{ .name = "fn call named arg", .body = "rule r() { let x = twice(m: 1) }" }, + .{ .name = "fn call result type", .body = "rule r() { let x: bool = twice(1) }" }, + .{ .name = "fn call struct result", .body = "rule r() { let y = mk().x }" }, + .{ .name = "fn call unit", .body = "rule r() { go() }" }, + .{ .name = "generic fn call", .body = "rule r() { let x: int = same(1) }" }, + .{ .name = "generic fn call result type", .body = "rule r() { let x: bool = same(1) }" }, + .{ .name = "lib bound via lib impl", .body = "fn f(p: Pt) -> int { shaped(p) }" }, + .{ .name = "lib bound unsatisfied", .body = "fn f() -> int { shaped(1) }" }, + .{ .name = "lib bound, local impl", .body = "struct Sq { s: int = 1 }\nimpl Shape for Sq {\n fn area(self) -> int { 1 }\n}\nfn f(q: Sq) -> int { shaped(q) }" }, + .{ .name = "local inherent impl on lib struct", .body = "impl Pt {\n fn sum(self) -> int { self.x + self.y }\n}\nfn f(p: Pt) -> int { p.sum() }" }, + .{ .name = "local trait for lib struct", .body = "trait Named {\n fn nm(self) -> int\n}\nimpl Named for Pt {\n fn nm(self) -> int { 1 }\n}\nfn f(p: Pt) -> int { p.nm() }" }, + .{ .name = "method arg type", .body = "fn f(p: Pt) -> int { p.scaled(true) }" }, + .{ .name = "method named arg", .body = "fn f(p: Pt) -> int { p.scaled(k: 2) }" }, + .{ .name = "method result type", .body = "fn f(p: Pt) -> bool { p.len() }" }, + .{ .name = "associated fn result field", .body = "rule r() { let x = Pt.origin().x }" }, + .{ .name = "method via fn result", .body = "rule r() { let x = mk().len() }" }, + .{ .name = "conditional lib impl proven", .body = "rule r(e: Entity) when e has C { let x = e.hp() }" }, + .{ .name = "conditional lib impl unproven", .body = "rule r(e: Entity) when e has D { let x = e.hp() }" }, + .{ .name = "self in a trait impl on a lib enum", .body = "trait Named {\n fn nm(self) -> int\n}\nimpl Named for Dir {\n fn nm(self) -> int { match self { .west => 1, _ => 0 } }\n}" }, + .{ .name = "local impl redefining a lib method", .body = "impl Pt {\n fn len(self) -> int { 1 }\n}" }, + .{ .name = "impl lib trait for local", .body = "struct Sq { s: int = 1 }\nimpl Shape for Sq {\n fn area(self) -> int { self.s }\n}" }, + .{ .name = "incomplete impl of lib trait", .body = "struct Sq { s: int = 1 }\nimpl Shape for Sq { }" }, + .{ .name = "lib trait default on local", .body = "struct Sq { s: int = 1 }\nimpl Doubler for Sq {\n fn base(self) -> int { self.s }\n}\nfn f(q: Sq) -> int { q.doubled() }" }, + .{ .name = "bound on lib trait, local impl", .body = "struct Sq { s: int = 1 }\nimpl Shape for Sq {\n fn area(self) -> int { 1 }\n}\nfn g(t: T) -> int { 0 }\nfn f(q: Sq) -> int { g(q) }" }, + .{ .name = "alias as param type", .body = "fn f(m: Meters) -> float { m }" }, + .{ .name = "alias as alias target", .body = "type Km = Meters" }, + .{ .name = "alias let mismatch", .body = "rule r() { let m: Meters = true }" }, .{ .name = "scene resource", .body = "scene \"S\" {\n resources { R { v: 1 } }\n entity \"e\" { uuid: \"7b3e2f1a-42a3-4f2b-8c9d-a3f2b1c98d4e\" C { v: 1 } }\n}" }, .{ .name = "malformed scene resource field type", .body = "scene \"S\" {\n resources { R { v: true } }\n entity \"e\" { uuid: \"7b3e2f1a-42a3-4f2b-8c9d-a3f2b1c98d4e\" C { v: 1 } }\n}" }, }; -fn codesOf(files: []const etch.ProjectFile, out: *std.ArrayListUnmanaged(DiagnosticCode)) !void { +/// Each diagnostic of `files` as its code and message, one per line: two +/// diagnostics of one code for different reasons read as different. +fn judgementOf(files: []const etch.ProjectFile, out: *std.ArrayListUnmanaged(u8)) !void { const gpa = std.testing.allocator; var diags: std.ArrayListUnmanaged(etch.Diagnostic) = .empty; defer deinitDiags(gpa, &diags); try etch.validateProject(gpa, files, &diags); - for (diags.items) |d| try out.append(gpa, d.code); + for (diags.items) |d| try out.print(gpa, "{t} {s}\n", .{ d.code, d.primary_message }); } test "a name an import binds is judged as its declaration is, position by position" { @@ -290,22 +397,148 @@ test "a name an import binds is judged as its declaration is, position by positi for (position_cases) |c| { const declared_src = try std.mem.concat(gpa, u8, &.{ positions_lib, c.body }); defer gpa.free(declared_src); - const imported_src = try std.mem.concat(gpa, u8, &.{ "import lib { C, D, R, P, Hit, CAP }\n", c.body }); + const imported_src = try std.mem.concat(gpa, u8, &.{ "import lib { C, D, R, P, Hit, CAP, Pt, Seg, Box, Dir, Cfg, Mode, Moved, twice, go, mk, same, Shape, Doubler, shaped, Hurt, Meters }\n", c.body }); defer gpa.free(imported_src); - var declared: std.ArrayListUnmanaged(DiagnosticCode) = .empty; + var declared: std.ArrayListUnmanaged(u8) = .empty; defer declared.deinit(gpa); - var imported: std.ArrayListUnmanaged(DiagnosticCode) = .empty; + var imported: std.ArrayListUnmanaged(u8) = .empty; defer imported.deinit(gpa); - try codesOf(&.{.{ .name = "main.etch", .source = declared_src }}, &declared); - try codesOf(&.{ .{ .name = "lib.etch", .source = positions_lib }, .{ .name = "main.etch", .source = imported_src } }, &imported); - if (!std.mem.eql(DiagnosticCode, declared.items, imported.items)) { + try judgementOf(&.{.{ .name = "main.etch", .source = declared_src }}, &declared); + try judgementOf(&.{ .{ .name = "lib.etch", .source = positions_lib }, .{ .name = "main.etch", .source = imported_src } }, &imported); + if (!std.mem.eql(u8, declared.items, imported.items)) { differing += 1; - std.debug.print("{s}: declared {any}, imported {any}\n", .{ c.name, declared.items, imported.items }); + std.debug.print("{s}:\n declared {{\n{s}}}\n imported {{\n{s}}}\n", .{ c.name, declared.items, imported.items }); } } try std.testing.expectEqual(@as(usize, 0), differing); } +const orphan_lib = + \\trait Shape { + \\ fn area(self) -> int + \\} + \\struct Pt { x: int = 0 } +; + +const OrphanCase = struct { name: []const u8, main: []const u8, orphans: usize }; +const orphan_cases = [_]OrphanCase{ + .{ .name = "imported trait for imported type", .main = "import lib { Shape, Pt }\nimpl Shape for Pt {\n fn area(self) -> int { 0 }\n}", .orphans = 1 }, + .{ .name = "imported trait for Entity", .main = "import lib { Shape }\nimpl Shape for Entity {\n fn area(self) -> int { 0 }\n}", .orphans = 1 }, + .{ .name = "imported trait for local type", .main = "import lib { Shape }\nstruct Sq { s: int = 1 }\nimpl Shape for Sq {\n fn area(self) -> int { 0 }\n}", .orphans = 0 }, + .{ .name = "local trait for imported type", .main = "import lib { Pt }\ntrait Named {\n fn nm(self) -> int\n}\nimpl Named for Pt {\n fn nm(self) -> int { 0 }\n}", .orphans = 0 }, +}; + +test "an impl whose trait and type are both of other modules is an orphan" { + const gpa = std.testing.allocator; + var wrong: usize = 0; + for (orphan_cases) |c| { + const files = [_]etch.ProjectFile{ + .{ .name = "lib.etch", .source = orphan_lib }, + .{ .name = "main.etch", .source = c.main }, + }; + var diags: std.ArrayListUnmanaged(etch.Diagnostic) = .empty; + defer deinitDiags(gpa, &diags); + try etch.validateProject(gpa, &files, &diags); + const orphans = countCode(diags.items, .orphan_impl); + if (orphans != c.orphans or diags.items.len != c.orphans) { + wrong += 1; + std.debug.print("{s}: {d} orphan_impl among {d} diagnostics, expected {d}\n", .{ c.name, orphans, diags.items.len, c.orphans }); + } + } + try std.testing.expectEqual(@as(usize, 0), wrong); +} + +const extended_pt = [_]etch.ProjectFile{ + .{ .name = "lib.etch", .source = "struct Pt { x: int = 0 }" }, + .{ .name = "ext1.etch", .source = "import lib { Pt }\nimpl Pt {\n fn len(self) -> int { 1 }\n}" }, + .{ .name = "ext2.etch", .source = "import lib { Pt }\nimpl Pt {\n fn len(self) -> int { 2 }\n}" }, +}; + +fn diagnosticsWith(gpa: std.mem.Allocator, main: []const u8, diags: *std.ArrayListUnmanaged(etch.Diagnostic)) !void { + const files = extended_pt ++ [_]etch.ProjectFile{.{ .name = "main.etch", .source = main }}; + try etch.validateProject(gpa, &files, diags); +} + +test "an inherent method two imported modules define is ambiguous at its call" { + const gpa = std.testing.allocator; + var diags: std.ArrayListUnmanaged(etch.Diagnostic) = .empty; + defer deinitDiags(gpa, &diags); + try diagnosticsWith(gpa, "import lib { Pt }\nimport ext1\nimport ext2\nfn f(p: Pt) -> int { p.len() }", &diags); + try std.testing.expectEqual(@as(usize, 1), countCode(diags.items, .duplicate_symbol)); + try std.testing.expectEqual(@as(usize, 1), diags.items.len); +} + +test "an inherent method one imported module defines is a method of the type" { + const gpa = std.testing.allocator; + var diags: std.ArrayListUnmanaged(etch.Diagnostic) = .empty; + defer deinitDiags(gpa, &diags); + try diagnosticsWith(gpa, "import lib { Pt }\nimport ext1\nfn f(p: Pt) -> int { p.len() }", &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); +} + +test "an inherent method of a module not imported is no method of the type" { + const gpa = std.testing.allocator; + var diags: std.ArrayListUnmanaged(etch.Diagnostic) = .empty; + defer deinitDiags(gpa, &diags); + try diagnosticsWith(gpa, "import lib { Pt }\nfn f(p: Pt) -> int { p.len() }", &diags); + try std.testing.expectEqual(@as(usize, 1), countCode(diags.items, .type_mismatch)); + try std.testing.expectEqual(@as(usize, 1), diags.items.len); +} + +test "self in an impl on an enum is the enum" { + const gpa = std.testing.allocator; + const files = [_]etch.ProjectFile{.{ .name = "main.etch", .source = + \\enum Dir { north, south } + \\trait Named { + \\ fn nm(self) -> int + \\} + \\impl Named for Dir { + \\ fn nm(self) -> int { match self { .west => 1, _ => 0 } } + \\} + }}; + 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, .enum_variant_not_found)); + try std.testing.expectEqual(@as(usize, 1), diags.items.len); +} + +test "a type a module imports is judged in its signatures as the declaration" { + const gpa = std.testing.allocator; + const ext = etch.ProjectFile{ .name = "use.etch", .source = "import lib { Pt }\nfn use_pt(p: Pt) -> int { p.x }" }; + var ok: std.ArrayListUnmanaged(etch.Diagnostic) = .empty; + defer deinitDiags(gpa, &ok); + try etch.validateProject(gpa, &(extended_pt ++ [_]etch.ProjectFile{ ext, .{ .name = "main.etch", .source = "import lib { Pt }\nimport use { use_pt }\nfn f(p: Pt) -> int { use_pt(p) }" } }), &ok); + try std.testing.expectEqual(@as(usize, 0), ok.items.len); + var bad: std.ArrayListUnmanaged(etch.Diagnostic) = .empty; + defer deinitDiags(gpa, &bad); + try etch.validateProject(gpa, &(extended_pt ++ [_]etch.ProjectFile{ ext, .{ .name = "main.etch", .source = "import lib { Pt }\nimport use { use_pt }\nfn f() -> int { use_pt(1) }" } }), &bad); + try std.testing.expectEqual(@as(usize, 1), countCode(bad.items, .type_mismatch)); + try std.testing.expectEqual(@as(usize, 1), bad.items.len); +} + +test "an item imported under another name is judged as its declaration" { + const gpa = std.testing.allocator; + const main_ok = + \\import lib { Pt as Point, twice as tw, Shape as Sh } + \\fn g(t: T) -> int { 0 } + \\fn f(p: Point) -> int { p.len() + p.area() + tw(n: 1) + g(p) } + ; + const main_bad = + \\import lib { twice as tw } + \\fn f() -> int { tw(true) } + ; + var ok: std.ArrayListUnmanaged(etch.Diagnostic) = .empty; + defer deinitDiags(gpa, &ok); + try etch.validateProject(gpa, &.{ .{ .name = "lib.etch", .source = positions_lib }, .{ .name = "main.etch", .source = main_ok } }, &ok); + try std.testing.expectEqual(@as(usize, 0), ok.items.len); + var bad: std.ArrayListUnmanaged(etch.Diagnostic) = .empty; + defer deinitDiags(gpa, &bad); + try etch.validateProject(gpa, &.{ .{ .name = "lib.etch", .source = positions_lib }, .{ .name = "main.etch", .source = main_bad } }, &bad); + try std.testing.expectEqual(@as(usize, 1), countCode(bad.items, .type_mismatch)); + try std.testing.expectEqual(@as(usize, 1), bad.items.len); +} + test "a composite value for a field of an imported component is refused" { const gpa = std.testing.allocator; const files = [_]etch.ProjectFile{ diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 824fc7c3..704b145d 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2809, - else => 2811, + .windows => 2816, + else => 2818, }; } From ec1452e05190ec25cefdc322c6686a1ec7a90367 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 29 Sep 2026 03:52:04 +0200 Subject: [PATCH 138/141] docs(brief): record the third axis, points 8, 7, 3, 4, 6 and 5 S5/G9 tervicies: each point's measurement on the base, its counter-factuals and the predictions they refuted, the tests changed, the counter-factual helper's defect and the items raised untouched. Floor 2787 -> 2818 / 2816, and the Windows floor 2785 read on the four cells of d82633a0. Co-Authored-By: Claude Opus 5.5 --- briefs/m1.d-phase-1-debt.md | 145 ++++++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index babc8d54..b61660c4 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -7434,6 +7434,151 @@ and let anything else through. The floor moves 2779 → 2787, read from the suite: 2770 / 2787 (17 skipped), 329/329 steps, and the closure agrees. Windows is 2785, derived until CI reads it. +### S5/G9 tervicies — the eight points of the third axis: 8, 7, 3, 4, 6 and 5 closed + +The CI of `d82633a0` was green, and the Windows floor 2785 was read on its four cells +(`2755/2785 tests passed (30 skipped)` on each). The ruling set the order 8, 7, then 3, 4 +and 6, then 5, then the measurement of 1 and 2, and a stop after it. Each point was +probed on the base before it was fixed, and its witnesses were run red on the base. Its +counter-factuals ran one at a time in the worktree, with the predictions written first. + +- **Point 8, `c636164a` — a `break` or `continue` that leaves its closure, refused as + `E0911 ControlFlowEscapesClosure`** (`etch-reference-part1.md` §7.7, `sha256 2b5d36d4…`). + - Measured on the base: twelve probes. Each checked clean, and a `break` in a closure + left the caller's loop (`Out.n = 7`). A closure never called was never checked at all. + - The parser records every jump whose target loop lies outside the innermost closure. + The checker refuses each once, whether the closure is called or not. The interpreter + fails such a jump instead of passing it to the caller. + - A closure body now starts with no enclosing `fn` return, task branch or loop. This + lifts a false `E0906` on a closure's own `return` inside a `sync` branch. + - Counter-factuals a, c, d, e and f fell as predicted. **Prediction b was refuted:** + with the loop depth not reset, five cases went red and not six. The closure "called + twice" is declared outside the loop, so it keeps its verdict. + - Changed test: the "loop broken from a closure" case is removed from the mistyped + table. Its premise was the escape now refused. +- **Point 7, `5a3f2423` — `ResolvedType.unit`**, a type that fits only itself. + - Unit is the type of a valueless `break`'s loop, of a block with no tail (a block + ending in `return`, `throw`, `break` or `continue` stays `unknown`), and of an + `if`/`if let` with no `else`. It is also the type of the effect builtins (`push`, both + `cancel`s, `emit`/`tick` on the test world, the extension, structural and assertion + methods) and of the `await` resumes of wait and event targets. + - A call of a fn with no `->` stays `unknown`, per the ruling. Measured: such a fn + returns its tail value at run time (`5` and `4` on two probes). + - Counter-factuals a to h fell as predicted. **CF-d was run twice:** the mutation text + matched both `if` sites, which made four cases fall, and it was redone anchored (three + cases). + - Changed test: the labeled-loop test now breaks `0.5`. Its bare `break` would now + disagree with the valued one. +- **Collection elements, `625a92d1` — a gap found while working on 7**, closed in the same + session. `valueFits` took two collections of one kind for equal, so `let a: string[] = + ints` checked clean. The fourteen cases of its table gave 0 diagnostics on the base. + - `elementsFit` now compares element types, and lengths for two fixed arrays. + - A literal (`[…]`, `Set.from([…])`, a map literal, `some(…)`) is judged element by + element under the literal rule, a negation included. A `let` then reports the element + alone, not the element and the initializer. + - Counter-factuals a to k fell as predicted. +- **Point 3, `55712eda` — a scene resource's fields get a type diagnostic: + `E0308 ResourceFieldTypeInvalid`.** + - Measured on the base: a bool, an array or a string in an int field, and an int + literal in a float field, all passed. + - A scene file, which may only import, could name no resource at all: `E0858` locally, + `E1789` imported. The checker now resolves a resource through the import. The cook + registers an imported resource under its own name, even when it is imported under an + alias. + - Components and resources share the identity check, because the runtime names both + by name. + - **Two predictions were refuted.** CF-c also reddened a harness position: the local + code differs from the foreign one on the malformed field. CF-f stayed green: the cook's + duplicate guard was redundant with `registerOne`'s own refusal, so it is removed. +- **Point 4, `84053aed` — a service call's arguments are checked by type and label** + (`E0200`), and the interpreter binds them by label. + - Measured on the base: `toy.pair(b: 2, a: 7)`, which is `a * 10 + b`, returned 27 + where 72 is right. + - At run time an int argument for a float parameter was silently widened. It now fails + rather than converts, and `valueToArg` and `argToZig` both lose their int arm. + - The toy service gains `pair` and `half`, and `toy.d.etch` is regenerated by + `zig build bindgen-detch`. + - **CF-a's prediction was refuted:** with the type loop off, the runtime `half(4)` test + reddened too, because its expected diagnostic was gone. + - Changed tests: the `.d.etch` emitter counts go from 3 methods to 5, and `services.zig`'s + `half(int)` now expects `ServiceArgTypeMismatch`. +- **Point 6, `21e5aa49` — a const is read by name, in either spelling** (a lowercase + IDENT, an uppercase path). + - Measured on the base: a lowercase const read `E0102`, and an uppercase one typed + `unknown` whether defined or not. Every const read failed at run time. + - An uppercase name that names no declaration, import or builtin type is now `E0102`. + The interpreter indexes the consts and reads a string, an enum variant or a folded + value. + - **A tooling defect of mine:** the counter-factual helper reported CF-e as "ok" when + the mutation did not compile. All 88 counter-factual logs of this gate were swept, and + that was the only one. The helper now reads `compilation errors` as BUILD-FAILED, and + CF-e was redone with a valid mutation, which fell as predicted. +- **Point 5, `ff041ef0` — an imported item is judged as its declaration, position by + position.** + - The harness compiles each position twice, with the item declared in the file and + imported from another. It grew from 44 positions to 112. + - **Measured on the base: 61 of the 67 positions point 5 added before its green run + differ.** The six that agree are those the earlier count of 46 of 52 left. A 68th, + `self` in an impl on an imported enum, agrees with or without the fix, since both + sides are silent without it, so it has a single-file witness of its own. + - **The instrument was defective and it is corrected.** The harness compared codes + only, and "method arg type" read as agreeing because both sides gave `E0200`: a type + mismatch on one side, a missing method on the other. It now compares code and message. + It found this one coincidence and no other on the group-1 tree, so the positions + counted as converged before the correction stand. + - What a name now resolves to across files: + - structs, enums (variants, `match` exhaustiveness, shorthand), fns (parameters, + types, labels, generic inference and bounds) and alias targets; + - the fields of a declaration written in another file; + - a foreign file's own selective imports. + - Impls follow the imports (`etch-resolver-types.md` §7.5): + - an imported file's inherent and trait impls are visible; + - an inherent impl may target an imported type, and a trait impl may name an + imported trait; + - a conditional impl's `when` is proven across files. + - `E0217 OrphanImpl` gains its producer, because traits now resolve across modules: an + impl whose trait and type are both of other modules, or of a foreign trait for + `Entity`, is refused. + - A method defined by two imported files is ambiguous on `E0101`, the duplicate-symbol + code that `collectImplMethods` already reuses for §7.5's `AmbiguousInherentMethod`. A + method that this file redefines is also `E0101`, as a second local impl would be. + - `self` in an impl on an enum is the enum: it was `unknown`, while `validateTraitImpl` + admitted the enum. + - Predictions P1, P2 and P4 held. **P5 was refuted by the decision above:** it + predicted `E0218`, and following the house reuse of `E0101` replaced it. + - Twelve counter-factuals fell as predicted, once the recounts written before the runs + are counted. **Two witnesses were missing and were added before the runs:** without + them, removing the transitive signature path or the enum arm left everything green. + - Changed tests: + - `diagnostic_coverage_test.zig`'s E0217 test is renamed, since E0217 now has a + producer. Its assertion, that an undeclared trait answers `E0102` and not `E0217`, + is unchanged. + - That file's header list is re-derived, now 32 codes declared and referenced + nowhere else. The stale totals are dropped: 203 declared codes, where 210 are + declared now. +- **Raised, untouched**, each measured: + - The cook converts an int to a float in a resource or component field without a word. + - A `break` in a fn body outside any loop is not refused, and leaves into the caller's + loop at run time. No code exists for it. + - A closure body is typed at its calls only, so a closure never called is typed nowhere + (point 8's check is the parser's and covers jumps only). + - A hook's arena reads no const and no fn: it has its own string pool. + - Imports are checked and not executed: the interpreter has no import handling, so + every item of point 5 is a checker-side result. + - A foreign type resolves only when this file binds the same name to it. Under an + import alias (`Pt as Point`), a foreign signature naming `Pt` stays `unknown`, and so + does a qualified `m.Pt` written in another file. + - `etch-resolver-types.md` §7.5 and `etch-diagnostics.md` name `E0218 + AmbiguousInherentMethod`. The code reuses `E0101`, by a decision written at + `collectImplMethods`. The two disagree. + - `E1797` is declared and never emitted as a diagnostic: the cook raises it as a Zig + error. + - The Phase 1 coverage rows of `etch-diagnostics.md` §25 owe `E0911`, `E0308` and now + `E0217`. + +The floor moves 2787 → 2818, read from the suite: 2801 / 2818 (17 skipped), 329/329 +steps, and the closure agrees. Windows is 2816, derived until CI reads it. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree From a2f83a6ebf959d3ac4ecd4e6ca3238ce1913af89 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 29 Sep 2026 04:13:24 +0200 Subject: [PATCH 139/141] docs(brief): measure block scoping and an optional of any type S5/G9 quattuorvicies: points 1 and 2 costed by the compiler before any code, with the behaviour of both components probed against the spec, the choice each point leaves open, and where the two meet. Co-Authored-By: Claude Opus 5.5 --- briefs/m1.d-phase-1-debt.md | 91 +++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index b61660c4..0b9ad369 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -7579,6 +7579,97 @@ counter-factuals ran one at a time in the worktree, with the predictions written The floor moves 2787 → 2818, read from the suite: 2801 / 2818 (17 skipped), 329/329 steps, and the closure agrees. Windows is 2816, derived until CI reads it. +### S5/G9 quattuorvicies — points 1 and 2, measured before a line is written + +**Method.** Every count below is the compiler's. A declaration is renamed in the +worktree, `test-etch-inline-tmp` is built, each refused token is patched mechanically, +and the build is repeated until it compiles; the count is the number of refused sites, +in production code unless stated. It replaces the unreproducible 105 of S4/G8. A +textual selector gave 33 `put` in the interpreter where the compiler gives 45, because +the calls go through other receivers (`scope.put`, `frame.scope.put`), so no figure here +is a grep's. Where a textual call graph was also used, its first level agrees with the +compiler's lists (27 / 10 / 13). + +**Point 1 — block scoping** (`etch-reference-part1.md` §4.2: a same-scope shadow is +refused, a nested one allowed; §4.3: a binding lives to the end of its block; +`etch-resolver-types.md` §2.2: a fn body holds its params first). +- Current behaviour, six probes, all as predicted (check / run, `Out.n`): + - a nested shadow `let x = 1; if true { let x = 2 }; x`: 0 / 2, where the spec gives 1; + - a block's `let` read after the block: 0 / 5, where the spec gives `E0102`; + - a same-scope re-`let`: 0 / 2, where the spec gives an error with no code assigned; + - an `if let x` shadowing an outer `x`: `E0102` on the later read, because the + `if let` removes the outer binding, and 99 at run time, where the spec gives 91; + - a `for` body's `let` read after the loop: 0 / 2, where the spec gives `E0102`; + - a `match` binding shadowing `x`: 0 / 44, where the spec gives 41. + - Both components diverge from the spec in the same direction, except on the + `if let`, where they also diverge from each other. +- The checker: `RuleCtx.locals` has 38 sites. The statement runs are 18 `checkStmt` + calls and 1 `synthBlock` call. The bindings outside a statement run are `if let`, + `while let`, match arms, the `for` variable, the `catch` binding and closure params. +- The interpreter: + - `Locals` sites: `put` 45, `get` 5, `getPtr` 2, `clear` 3, `deinit` 20, and the type + named 55 times outside its definition. + - Statement runs: `execStmtRun` has 14 calls and `pushBlockRun` 4. `cloneLocalsInto` + has 3, the timer, branch/spawn and race/sync snapshots. + - The async frame stack: 10 push sites, 18 `popFrame` calls (the only pop), and 71 + accesses to `AsyncTask.frames`. + - `put` releases the value it replaces, so today a shadow destroys the outer value. + Under block scoping, that value stays retained until the inner block exits. +- Tests: + - The scan at `d82633a0` found none relying on a leak, and one that flips if params + share the body scope: `storage_mode_test.zig`'s P3 rebinds a rule param with `let + entity`. + - This gate's 184 new snippets re-`let` no name in one scope. The scan reads one line + at a time and looked for no leaks. +- Three rulings are open: + - the code for a same-scope shadow — `E0101`'s row covers modules, imports and + builtins, not locals; + - whether params share the body scope, which §2.2 implies and which makes P3 an + error; + - whether a closure body is typed where it is written. Today it is typed at each call. + +**Point 2 — an optional carrying a full `ResolvedType`.** +- Current behaviour, seven probes: + - `if let p = some(P { x: 7 })` checks clean and runs to 7: the interpreter carries a + struct payload. So do `o ?? P { … }` (0 / 7) and `let o: P? = none` (0 / 3). + - `fn f(p: P?)` and `-> P?` are refused with `E0102`, as unknown or unsupported. + - `let v: int = some(P { x: 1 }) ?? 0` checks clean and fails at run time. + - **One prediction refuted:** `match o { some(p) => …, none => … }` on `some(P)` is + refused twice, `E0102` on `p` and `E1230` non-exhaustive, while it runs to 5. The gap + refuses valid code as well as admitting invalid code. +- The checker: 26 refused tokens on 24 lines touch the variant. + - 6 lines construct a payload: the `.optional` arms of `namedTypeToResolved` and + `foreignType`, `some(…)`, the `?.method` re-wrap, `pop()` and a map index. + - 10 lines read it: `eql`, `elementsFit`, `optionalPayload`, the `?.method` dispatch, + `match some(v)`, `??` twice and `!`. + - 8 lines test the tag only and would not change. + - `ResolvedType.eql` has 6 callers. +- **The structural choice.** The two constructors that read a type node, + `namedTypeToResolved` and `foreignType`, cannot fail today. There are two ways out: + - **Make them fallible.** 11 functions change signature, and 69 call sites need a + `try`. The closure over the call graph stops at those 11, because every further + caller is fallible already. At the first level there are 27 / 10 / 13 sites for + `namedTypeToResolved`, `typeIn` and `foreignType`, 23 of them in functions that + cannot fail. + - **Keep them infallible.** Intern the payload of every optional type node, of this + file and of each imported file, in a pass before checking, so the arms read a table. +- A pointer into a checker-owned arena keeps `eql` static and recursive, and its 6 + callers unchanged. An index into a list makes `eql` need the store, and all 6 change. +- Beyond the variant: + - `unifyGeneric` and `substituteGeneric` have no optional arm, so `T?` is not inferred; + - `recv?.field` is refused whatever the payload; + - the four "unknown or unsupported parameter/return type" diagnostics lift for `P?`. +- The interpreter needs nothing for the representation, as the probes above measure. + Codegen is outside the build (`M1.D.5`). + +**Where the two meet.** Three constructs bind an optional's payload as a local: `if let`, +`while let` and `match some(v)`. There the binding's type is point 2 and its lifetime is +point 1. `synthMatch` is the one function where a single line does both. + +**Not measured:** the run-time cost of block scoping in the interpreter. It depends on +the representation — an undo log pays per binding and per block exit — and measuring it +needs a prototype. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree From 43c5bd5ecad97b1f87b3f1a1160b26ff7161ae89 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 29 Sep 2026 05:25:01 +0200 Subject: [PATCH 140/141] fix(etch): check a layer or manifest file as a scene file, without E0840 E0840 refused every .layer.etch and .manifest.etch file unparsed, for a layer or world construct the grammar does not have: a data layer and a world manifest each hold a scene (etch-grammar.md 21.1). E0840 is withdrawn with its witnesses, both files are parsed as any other, and E0858 requires of them exactly one scene and its imports, as of a scene file, its messages naming the scene rather than the extension. A layer or manifest holding a scene cooks to the bytes a scene file does, and a scene beside a layer file still cooks. Floor 2818 -> 2825 / 2823, read from the suite. Co-Authored-By: Claude Opus 5.5 --- src/etch/ast.zig | 25 +---- src/etch/diagnostics.zig | 5 - src/etch/project.zig | 15 +-- src/etch/scene_cook.zig | 6 +- src/etch/types.zig | 14 +-- tests/etch/crossfile_scene_prefab_test.zig | 107 ++++++++++++++++++--- tests/scene/import_cook_test.zig | 55 ++++++++++- tools/weld_lint/dead_tests.zig | 4 +- 8 files changed, 159 insertions(+), 72 deletions(-) diff --git a/src/etch/ast.zig b/src/etch/ast.zig index e36445bf..77201e10 100644 --- a/src/etch/ast.zig +++ b/src/etch/ast.zig @@ -95,27 +95,10 @@ pub const ParseMode = enum { declaration_file, }; -/// The extension of the file an arena stands for, for `E0858` and -/// `E0840` (`etch-grammar.md` §21). `unknown` when the parse had no path, -/// which neither judges. -pub const TypedExtension = enum { - unknown, - plain, - scene, - prefab, - layer, - manifest, - - /// The construct a file of this extension holds, when the parser does not - /// implement it (`E0840`). - pub fn unimplementedConstruct(self: TypedExtension) ?[]const u8 { - return switch (self) { - .layer => "layer", - .manifest => "world", - .unknown, .plain, .scene, .prefab => null, - }; - } -}; +/// The extension of the file an arena was parsed from, for `E0858` +/// (`etch-grammar.md` §21). `unknown` when the parse had no path, which +/// `E0858` does not judge. +pub const TypedExtension = enum { unknown, plain, scene, prefab, layer, manifest }; /// Compact 32-bit handle into the `AstArena`: 4-bit `NodeCategory` + /// 28-bit index. Used as the universal pointer between AST nodes. diff --git a/src/etch/diagnostics.zig b/src/etch/diagnostics.zig index b119dec1..a6b87a36 100644 --- a/src/etch/diagnostics.zig +++ b/src/etch/diagnostics.zig @@ -411,7 +411,6 @@ pub const DiagnosticCode = enum { declaration_file_body_not_allowed, // E1900 DeclarationFileBodyNotAllowed (a `fn` carries a body inside a `.d.etch`) construct_not_allowed_in_declaration_file, // E1901 ConstructNotAllowedInDeclarationFile (a behavioural top-level construct appears in a `.d.etch`) typed_extension_mismatch, // E0858 TypedExtensionMismatch (a construct in the wrong typed extension, `etch-grammar.md` §21.2) - construct_not_implemented, // E0840 ConstructNotImplemented (a `.layer.etch` or `.manifest.etch` file, whose `layer` / `world` construct the parser does not implement) declaration_file_implementation_mismatch, // E1902 DeclarationFileImplementationMismatch (a committed `.d.etch` diverges from what the emitter produces on the current Zig `ServiceSpec`) /// Canonical short code, e.g. `"E0001"`. @@ -624,7 +623,6 @@ pub const DiagnosticCode = enum { .declaration_file_body_not_allowed => "E1900", .construct_not_allowed_in_declaration_file => "E1901", .typed_extension_mismatch => "E0858", - .construct_not_implemented => "E0840", .declaration_file_implementation_mismatch => "E1902", }; } @@ -839,7 +837,6 @@ pub const DiagnosticCode = enum { .declaration_file_body_not_allowed => "DeclarationFileBodyNotAllowed", .construct_not_allowed_in_declaration_file => "ConstructNotAllowedInDeclarationFile", .typed_extension_mismatch => "TypedExtensionMismatch", - .construct_not_implemented => "ConstructNotImplemented", .declaration_file_implementation_mismatch => "DeclarationFileImplementationMismatch", }; } @@ -951,6 +948,4 @@ test "DiagnosticCode code and name are stable cross-version" { try std.testing.expectEqualStrings("IllegalStatementInExtensionHook", DiagnosticCode.illegal_statement_in_extension_hook.name()); try std.testing.expectEqualStrings("E1799", DiagnosticCode.prefab_hook_not_allowed.code()); try std.testing.expectEqualStrings("PrefabHookNotAllowed", DiagnosticCode.prefab_hook_not_allowed.name()); - try std.testing.expectEqualStrings("E0840", DiagnosticCode.construct_not_implemented.code()); - try std.testing.expectEqualStrings("ConstructNotImplemented", DiagnosticCode.construct_not_implemented.name()); } diff --git a/src/etch/project.zig b/src/etch/project.zig index 26bd269c..0a363679 100644 --- a/src/etch/project.zig +++ b/src/etch/project.zig @@ -57,21 +57,8 @@ pub const Project = struct { @memset(self.parse_failed, false); try self.arenas.ensureTotalCapacity(gpa, n); for (files, 0..) |f, idx| { - const ext = parser.typedExtensionForPath(f.name); - // The parser does not implement this file's construct, so its - // source is not parsed and E0840 is the one diagnostic it gets. - if (ext.unimplementedConstruct()) |construct| { - var empty = try parser.parseWithMode(gpa, "", parser.modeForPath(f.name)); - gpa.free(empty.diagnostics); - empty.ast.typed_extension = ext; - self.arenas.appendAssumeCapacity(empty.ast); - const msg = try std.fmt.allocPrint(gpa, "a .{s}.etch file holds a '{s}', which is not implemented", .{ @tagName(ext), construct }); - errdefer gpa.free(msg); - try diags_out.append(gpa, .{ .code = .construct_not_implemented, .severity = .error_, .primary_span = .{ .byte_start = 0, .byte_end = 0 }, .primary_message = msg }); - continue; - } var pr = try parser.parseWithMode(gpa, f.source, parser.modeForPath(f.name)); - pr.ast.typed_extension = ext; + pr.ast.typed_extension = parser.typedExtensionForPath(f.name); // Each parse diagnostic moves into `diags_out` (its message // transfers), then only the vacated slice is freed — never // `pr.deinit`, which would free the arena kept below. diff --git a/src/etch/scene_cook.zig b/src/etch/scene_cook.zig index 7effed5e..64a6d60e 100644 --- a/src/etch/scene_cook.zig +++ b/src/etch/scene_cook.zig @@ -132,9 +132,6 @@ pub const CookError = error{ ImportRefused, /// The file holds what its typed extension refuses (E0858). TypedExtensionMismatch, - /// The file's typed extension holds a construct the parser does not - /// implement (E0840). - ConstructNotImplemented, /// `prefab "Y" of "X"` but the base `X.prefab.bin` could not be resolved /// (no resolver, or the resolver returned null for the base name). BasePrefabMissing, @@ -324,9 +321,8 @@ pub const BaseResolver = struct { } }; -/// Refuse a file E0858 or E0840 refuses. +/// Refuse a file E0858 refuses. fn checkTypedExtension(gpa: std.mem.Allocator, ast: *AstArena, diag_out: ?*[]const u8) CookError!void { - if (ast.typed_extension.unimplementedConstruct() != null) return fail(diag_out, error.ConstructNotImplemented, "the file holds a construct the parser does not implement"); var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; defer { for (diags.items) |*d| d.deinit(gpa); diff --git a/src/etch/types.zig b/src/etch/types.zig index d8cc31c0..4e569d14 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -1221,12 +1221,14 @@ pub const TypeChecker = struct { /// typed file. fn checkTypedExtension(self: *TypeChecker) !void { const ext = self.arena.typed_extension; - if (ext == .unknown or ext.unimplementedConstruct() != null) return; + if (ext == .unknown) return; + // A data layer and a world manifest each hold a scene (§21.1). const main: ?ast_mod.ItemKind = switch (ext) { - .scene => .scene_decl, + .scene, .layer, .manifest => .scene_decl, .prefab => .prefab_decl, - .layer, .manifest, .plain, .unknown => null, + .plain, .unknown => null, }; + const main_name: []const u8 = if (ext == .prefab) "prefab" else "scene"; const kinds = self.arena.items.items(.kind); var mains: u32 = 0; for (kinds, 0..) |k, i| { @@ -1245,13 +1247,13 @@ pub const TypeChecker = struct { if (k == .import_decl) continue; if (main != null and k == main.?) { mains += 1; - if (mains > 1) try self.emit(.typed_extension_mismatch, .error_, span, "a .{s}.etch file holds exactly one {s}", .{ @tagName(ext), @tagName(ext) }); + if (mains > 1) try self.emit(.typed_extension_mismatch, .error_, span, "a .{s}.etch file holds exactly one {s}", .{ @tagName(ext), main_name }); continue; } - try self.emit(.typed_extension_mismatch, .error_, span, "'{s}' is not allowed in a .{s}.etch file, which holds one {s} and its imports", .{ @tagName(k), @tagName(ext), @tagName(ext) }); + try self.emit(.typed_extension_mismatch, .error_, span, "'{s}' is not allowed in a .{s}.etch file, which holds one {s} and its imports", .{ @tagName(k), @tagName(ext), main_name }); } if (ext != .plain and mains == 0) - try self.emit(.typed_extension_mismatch, .error_, .{ .byte_start = 0, .byte_end = 0 }, "a .{s}.etch file holds exactly one {s}", .{ @tagName(ext), @tagName(ext) }); + try self.emit(.typed_extension_mismatch, .error_, .{ .byte_start = 0, .byte_end = 0 }, "a .{s}.etch file holds exactly one {s}", .{ @tagName(ext), main_name }); } /// Index every `service` this check can see. With a diff --git a/tests/etch/crossfile_scene_prefab_test.zig b/tests/etch/crossfile_scene_prefab_test.zig index 5c7d66d9..dddcd85f 100644 --- a/tests/etch/crossfile_scene_prefab_test.zig +++ b/tests/etch/crossfile_scene_prefab_test.zig @@ -214,27 +214,106 @@ test "no E0858 on a scene file holding one scene and its imports" { })); } -/// Check `files`, requiring exactly one diagnostic: E0840. -fn expectOnlyE0840(files: []const etch.ProjectFile) !void { +test "no E0858 on a layer file holding one scene and its imports" { + try std.testing.expectEqual(@as(usize, 0), try e0858Count(&.{ + .{ .name = "src/marker.etch", .source = "component Marker { id: int = 0 }" }, + .{ .name = "src/gameplay.layer.etch", .source = + \\import marker { Marker } + \\scene "Gameplay" { entity "e" { Marker { id: 1 } } } + }, + })); +} + +test "no E0858 on a manifest file holding one scene and its imports" { + try std.testing.expectEqual(@as(usize, 0), try e0858Count(&.{ + .{ .name = "src/settings.etch", .source = "resource Clock { hz: int = 60 }\ncomponent Marker { id: int = 0 }" }, + .{ .name = "src/village.manifest.etch", .source = + \\import settings { Clock, Marker } + \\scene "Village" { resources { Clock { hz: 30 } } entity "e" { Marker { id: 1 } } } + }, + })); +} + +test "E0858 on a type declared in a layer file" { + try std.testing.expectEqual(@as(usize, 1), try e0858Count(&.{ + .{ .name = "src/gameplay.layer.etch", .source = + \\component Marker { id: int = 0 } + \\scene "Gameplay" { entity "e" { Marker { id: 1 } } } + }, + })); +} + +test "E0858 twice on a layer file holding a component and no scene" { + try std.testing.expectEqual(@as(usize, 2), try e0858Count(&.{ + .{ .name = "src/gameplay.layer.etch", .source = "component Marker { id: int = 0 }" }, + })); +} + +test "E0858 on an empty manifest file" { + try std.testing.expectEqual(@as(usize, 1), try e0858Count(&.{ + .{ .name = "src/village.manifest.etch", .source = "" }, + })); +} + +const MessageCase = struct { name: []const u8, file: etch.ProjectFile, message: []const u8 }; +const e0858_messages = [_]MessageCase{ + .{ .name = "no scene", .file = .{ .name = "src/village.manifest.etch", .source = "" }, .message = "a .manifest.etch file holds exactly one scene" }, + .{ .name = "a second scene", .file = .{ .name = "src/gameplay.layer.etch", .source = "scene \"A\" { entity \"a\" { } }\nscene \"B\" { entity \"b\" { } }" }, .message = "a .layer.etch file holds exactly one scene" }, + .{ .name = "another construct", .file = .{ .name = "src/gameplay.layer.etch", .source = "component Marker { id: int = 0 }\nscene \"A\" { entity \"a\" { } }" }, .message = "'component_decl' is not allowed in a .layer.etch file, which holds one scene and its imports" }, +}; + +test "E0858 on a layer or manifest file names the scene it holds" { const gpa = std.testing.allocator; - var diags: std.ArrayListUnmanaged(etch.Diagnostic) = .empty; - defer deinitDiags(gpa, &diags); - try validate(gpa, files, &diags); - try expectOnly(diags.items, .construct_not_implemented, 1); + var wrong: usize = 0; + for (e0858_messages) |c| { + var diags: std.ArrayListUnmanaged(etch.Diagnostic) = .empty; + defer deinitDiags(gpa, &diags); + try validate(gpa, &.{c.file}, &diags); + var found = false; + for (diags.items) |d| { + if (d.code == .typed_extension_mismatch) { + if (found or !std.mem.eql(u8, d.primary_message, c.message)) { + wrong += 1; + std.debug.print("{s}: '{s}'\n", .{ c.name, d.primary_message }); + } + found = true; + } + } + if (!found) { + wrong += 1; + std.debug.print("{s}: no E0858\n", .{c.name}); + } + } + try std.testing.expectEqual(@as(usize, 0), wrong); } -test "E0840 on a layer file holding its layer" { - try expectOnlyE0840(&.{.{ .name = "src/gameplay.layer.etch", .source = "layer \"Gameplay\" { }" }}); +test "E0858 on two scenes in one layer file" { + try std.testing.expectEqual(@as(usize, 1), try e0858Count(&.{ + .{ .name = "src/marker.etch", .source = "component Marker { id: int = 0 }" }, + .{ .name = "src/gameplay.layer.etch", .source = + \\import marker { Marker } + \\scene "A" { entity "a" { Marker { id: 1 } } } + \\scene "B" { entity "b" { Marker { id: 2 } } } + }, + })); } -test "E0840 on a manifest file holding its world" { - try expectOnlyE0840(&.{.{ .name = "src/village.manifest.etch", .source = "world \"Village\" { }" }}); +/// Check `files`, requiring exactly two diagnostics: the parse error of a +/// construct that does not exist, and E0858 for the scene the file lacks. +fn expectNoSuchConstruct(files: []const etch.ProjectFile) !void { + const gpa = std.testing.allocator; + var diags: std.ArrayListUnmanaged(etch.Diagnostic) = .empty; + defer deinitDiags(gpa, &diags); + try validate(gpa, files, &diags); + try std.testing.expectEqual(@as(usize, 2), diags.items.len); + try std.testing.expectEqual(@as(usize, 1), countCode(diags.items, .parse_error)); + try std.testing.expectEqual(@as(usize, 1), countCode(diags.items, .typed_extension_mismatch)); } -test "E0840 on a layer file holding a component" { - try expectOnlyE0840(&.{.{ .name = "src/gameplay.layer.etch", .source = "component Marker { id: int = 0 }" }}); +test "a layer construct in a layer file is a parse error" { + try expectNoSuchConstruct(&.{.{ .name = "src/gameplay.layer.etch", .source = "layer \"Gameplay\" { }" }}); } -test "E0840 on an empty manifest file" { - try expectOnlyE0840(&.{.{ .name = "src/village.manifest.etch", .source = "" }}); +test "a world construct in a manifest file is a parse error" { + try expectNoSuchConstruct(&.{.{ .name = "src/village.manifest.etch", .source = "world \"Village\" { }" }}); } diff --git a/tests/scene/import_cook_test.zig b/tests/scene/import_cook_test.zig index c191f113..7863d2bd 100644 --- a/tests/scene/import_cook_test.zig +++ b/tests/scene/import_cook_test.zig @@ -644,17 +644,62 @@ test "a prefab variant cooks without naming its base's components, as etch check try std.testing.expectEqualSlices(u8, reference_bytes, bytes); } -test "a layer file refuses the cook, as E0840 refuses it" { - const files = [_]ProjectFile{.{ .name = "src/gameplay.layer.etch", .source = "layer \"Gameplay\" { }" }}; - try expectCheckReports(&files, .construct_not_implemented); - try std.testing.expectError(error.ConstructNotImplemented, scene_cook.cookSceneInProject(std.testing.allocator, &files, 0, null, null)); +test "a layer file holding a component refuses the cook, as E0858 refuses it" { + const files = [_]ProjectFile{.{ .name = "src/gameplay.layer.etch", .source = "component Marker { id: int = 0 }" }}; + try expectCheckReports(&files, .typed_extension_mismatch); + try std.testing.expectError(error.TypedExtensionMismatch, scene_cook.cookSceneInProject(std.testing.allocator, &files, 0, null, null)); +} + +/// The bytes `source` cooks to, as the file `name` beside `game.etch`. +fn sceneBytesAs(name: []const u8, source: []const u8) ![]u8 { + const gpa = std.testing.allocator; + const files = [_]ProjectFile{ + .{ .name = "src/game.etch", .source = game }, + .{ .name = name, .source = source }, + }; + var cooked = try scene_cook.cookSceneInProject(gpa, &files, 1, null, null); + defer cooked.deinit(gpa); + return written(&cooked); +} + +test "a layer file holding a scene cooks as a scene file does" { + const gpa = std.testing.allocator; + const source = + \\import game { Health } + \\scene "Gameplay" { + \\ entity "npc" { uuid: "00000000-0000-0000-0000-000000000002" Health { max: 40 } } + \\} + ; + const layer = try sceneBytesAs("src/gameplay.layer.etch", source); + defer gpa.free(layer); + const reference = try sceneBytesAs("src/gameplay.scene.etch", source); + defer gpa.free(reference); + try std.testing.expectEqualSlices(u8, reference, layer); +} + +test "a manifest file holding a scene of resources cooks as a scene file does" { + const gpa = std.testing.allocator; + const source = + \\import game { GameMode } + \\scene "Village" { resources { GameMode { max_players: 8, title: "village" } } } + ; + const manifest = try sceneBytesAs("src/village.manifest.etch", source); + defer gpa.free(manifest); + const reference = try sceneBytesAs("src/village.scene.etch", source); + defer gpa.free(reference); + try std.testing.expectEqualSlices(u8, reference, manifest); } test "a layer file beside a scene does not refuse the scene's cook" { const gpa = std.testing.allocator; const files = [_]ProjectFile{ .{ .name = "src/combat.etch", .source = combat }, - .{ .name = "src/gameplay.layer.etch", .source = "layer \"Gameplay\" { }" }, + .{ .name = "src/gameplay.layer.etch", .source = + \\import combat { Health } + \\scene "Gameplay" { + \\ entity "guard" { uuid: "00000000-0000-0000-0000-000000000003" Health { max: 60 } } + \\} + }, .{ .name = "src/level.scene.etch", .source = \\import combat { Health } \\scene "Level" { diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 704b145d..7846f12d 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -244,8 +244,8 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // line of `zig build test --summary all`, skipped tests included. Windows is // two lower, by the two `only_on = .windows` entries above. return switch (os) { - .windows => 2816, - else => 2818, + .windows => 2823, + else => 2825, }; } From 3117dbfa8ef7e18e0de2a7c99b0016b3b76a1276 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 29 Sep 2026 05:25:49 +0200 Subject: [PATCH 141/141] docs(brief): record the withdrawal of E0840 S5/G9 quinvicies: layer and manifest files hold a scene, checked and cooked as a scene file, with the probes, witnesses, counter-factuals, the review's findings and the spec conflicts raised untouched. Co-Authored-By: Claude Opus 5.5 --- briefs/m1.d-phase-1-debt.md | 71 +++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/briefs/m1.d-phase-1-debt.md b/briefs/m1.d-phase-1-debt.md index 0b9ad369..92a7ee3b 100644 --- a/briefs/m1.d-phase-1-debt.md +++ b/briefs/m1.d-phase-1-debt.md @@ -7670,6 +7670,77 @@ point 1. `synthMatch` is the one function where a single line does both. the representation — an undo log pays per binding and per block exit — and measuring it needs a prototype. +### S5/G9 quinvicies — E0840 withdrawn: a data layer and a world manifest hold a scene + +Guy withdrew `E0840`. `layer` and `world` are no constructs: `declaration_body` does not +list them and no production defines them. A `.layer.etch` holds a `scene`, the entities of +a data layer, whose layer is the file that declares them. A `.manifest.etch` holds a +`scene` without entities, whose world settings are typed resources. The corrected spec: +`etch-grammar.md` (`sha256 fab80282…`), `engine-scene-serialization.md` (`514919c9…`, +then `6c9091b7…`), `etch-style-guide.md` (`fb97659d…`, E0840 removed) and +`engine-directory-structure.md` (`25ba9017…`). + +- **The change, `43c5bd5`.** `E0840` is removed with its five witnesses and its two + stability-test lines. A `.layer.etch` and a `.manifest.etch` are parsed as any other + file, and the cook's `ConstructNotImplemented` refusal is gone. +- **E0858** requires of a `.layer.etch` and a `.manifest.etch` exactly one `scene` and its + imports, as of a `.scene.etch`. Its three messages now name the construct the file holds + (`holds exactly one scene`), not its extension. +- **Probes, on the base and after.** On the base, each of the seven probe files got + `E0840` alone, whatever it held. After the change: + - a component in a layer gets `E0858` twice; + - an empty manifest gets `E0858` once, and so does a second scene in a layer; + - the former `layer "X" { }` and `world "X" { }` get the parser's `E0001` and `E0858`; + - a manifest scene of resources gets `E1780` alone, the conflict raised below. + - **One prediction was refuted by my fixture:** the layer probe's entity carried no + component, so it got `W1780`. +- **Witnesses.** Nine check witnesses, among them a table of the three E0858 messages, and + three cook witnesses: a layer and a manifest holding a scene each cook to the bytes of the + same source as a `.scene.etch`, and a layer file holding a component refuses the cook as + E0858 refuses it. + - **Two witnesses of mine discriminated nothing, and were fixed before the red run.** + The manifest witness counted E0858 alone, so it was green on the base, where the file + got E0840. It now carries an entity and requires no other diagnostic, which also keeps + it out of the E1780 conflict. + - I wrote CF-a's prediction wrong, and rewrote it before any run. + - **Red run on the base:** 9 check and 3 cook witnesses red. The neighbour test stays + green, being the preservation witness of point 3. +- **Counter-factuals, alone, as predicted:** + - layer and manifest mapped to no main construct: four check and both cook-as-scene + witnesses red; + - each of the three messages given the extension back in place of the construct: its own + case of the table red, and only that one. +- **An adversarial review, read-only, three lenses and two skeptics per finding.** 8 + findings, 2 surviving: + - E1780 on a manifest, below; + - two of the three messages had no witness. That is closed by the table and its three + counter-factuals. +- **Changed tests, declared:** + - the four check witnesses and the cook witness of E0840 are removed, and the stability + test loses its two E0840 lines; + - the neighbour test's layer file held `layer "Gameplay" { }`, which no longer parses and + would refuse every cook of its project by `scene_cook.zig`'s project-wide gate. It now + holds a scene. +- **Raised, untouched:** + - **E1780 contradicts §21.1.** `etch-validation-ecs.md` §23 and `etch-diagnostics.md` + §18.2 make E1780 unconditional, a scene needing an entity or instance. So a manifest + written as §21.1 describes cooks and fails `check`. The diff makes this reachable: + E0840 short-circuited the file before. + - An entity in a manifest's scene has no diagnostic. + - `scene_cook.zig:262` refuses a scene's cook when any file of the project fails to + parse, so a malformed layer file beside it blocks it. + - Spec text still stating the old model: + - `etch-style-guide.md:336-337` gives one `layer` / `world` top-level; + - `etch-style-guide.md:487` describes E0858 as a `scene` outside a `.scene.etch`; + - `etch-reference-part3.md:1847-1848` requires exactly one data layer / `world`; + - `engine-scene-serialization.md:163` still has a `layer:` field in the entity schema. + - `engine-directory-structure.md:305` names `.layer.bin` and `.manifest.bin` as cooked + outputs, while the cook writes the image of a scene for both, its output named by + `--output`. + +The floor moves 2818 → 2825, read from the suite: 2808 / 2825 (17 skipped), 329/329 steps, +and the closure agrees. Windows is 2823, derived until CI reads it. + ## Recorded deviations ### RD-1 — the branch base is `2006ed0`, and three frozen statements are false against the tree