Skip to content

Commit b7fbe5f

Browse files
hyperpolymathclaude
andcommitted
fix(affinescript-deno-test): document codegen-bug resolution + regression tests
Complements upstream affinescript codegen fix (nextgen-languages/ affinescript 35c476d). Adds a 4-test regression file exercising: - enum-in-match returning zero-arity + arg constructors across arms - struct field reads through function parameters - struct field reads on let-bound function-call results The smoke-test suite now runs 7/7 green (was 4/4) and the sibling double-track-browser extension_lifecycle_test.affine pilot reaches 10/10 without the tagged-struct workaround. README.adoc is updated to flip the "known codegen bugs" section to RESOLVED with root-cause + fix summary retained for history. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b5eb24f commit b7fbe5f

2 files changed

Lines changed: 107 additions & 22 deletions

File tree

affinescript-ecosystem/affinescript-deno-test/README.adoc

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,9 @@ deno run --allow-read --allow-run --allow-env cli.ts ./tests
116116
[[constraints]]
117117
== Constraints and planned follow-ups
118118

119-
One constraint was resolved same-day. Two remain, both resolvable with
120-
small upstream changes.
119+
Two constraints were resolved same-day (one for v0.2.0, plus the
120+
three codegen bugs below). Two remain, both resolvable with small
121+
upstream changes.
121122

122123
=== RESOLVED (v0.2.0): one test per file
123124

@@ -157,26 +158,36 @@ return is unmarshalled by hand (AffineScript compiles `Bool` → i32, 0/1).
157158
Record<string, Record<string, ImportValue>>` option, then unify both paths
158159
behind `AffineModule`.
159160

160-
=== Known AffineScript codegen bugs blocking richer test idioms
161-
162-
Discovered 2026-04-19 while writing the double-track-browser lifecycle
163-
test pilot. Both are compiler bugs, not harness bugs — fixing them is
164-
upstream OCaml work on `developer-ecosystem/nextgen-languages/affinescript/
165-
lib/codegen.ml`.
166-
167-
* **Enum-in-match codegen**: a `match` on an enum that returns distinct
168-
zero-arity constructors across arms produces invalid WASM (stack
169-
imbalance). The ideal form `match s { Uninitialised => Initialised(),
170-
... }` must currently be downgraded to a tagged struct.
171-
* **Parameter struct-field reads**: `s.field_1_or_later` where `s` is a
172-
function parameter of struct type returns 0 regardless of the actual
173-
value. First-field reads (`s.tag`) work. Workaround: pass scalars
174-
instead of structs when possible, or read fields from let-bound locals
175-
before calling into helpers.
176-
177-
Scaling the harness to richer application-state test suites is gated
178-
on both. Estate-wide tracking in
179-
`~/Desktop/AI-WORK-todo.md` §11.
161+
=== RESOLVED (2026-04-19): codegen bugs blocking richer test idioms
162+
163+
Both bugs discovered while writing the double-track-browser lifecycle
164+
test pilot were fixed upstream in `developer-ecosystem/nextgen-languages/
165+
affinescript/lib/codegen.ml` the same day. Regression coverage lives in
166+
`example/codegen_regression_test.affine` and the full
167+
double-track-browser `extension_lifecycle_test.affine` (10/10 green).
168+
169+
* **Enum-in-match stack imbalance** — `PatCon`-with-args pushed the
170+
tag-test boolean onto the stack twice (`LocalTee` + a trailing
171+
`LocalGet` on the match-result local), so any arm body that produced
172+
an i32 blew up WASM validation with "expected 1 elements on the stack
173+
for fallthru, found 2". Fixed by removing the save/restore pair; the
174+
field-binding code between them is stack-neutral by construction.
175+
* **Bare zero-arity variant as an expression** — `Initialised` (no
176+
parens) fell through to the `ExprVar` lookup and failed with
177+
`UnboundVariable` unless written as `State::Initialised`. Fixed by
178+
falling back to `ctx.variant_tags` when `lookup_local` misses, mirroring
179+
the existing `ExprCall`-with-variant-tag branch.
180+
* **Non-first struct-field read from parameter/call-result** — the
181+
per-variable `field_layouts` map was only populated for let-bound
182+
`ExprRecord` literals, so every other binding path defaulted offsets
183+
to 0 and `.field_1_or_later` read the tag byte. Fixed by registering
184+
struct layouts globally from `TopType(TyStruct)` and propagating them
185+
to (a) function parameters via `p_ty`, (b) call-result lets via a new
186+
`fn_ret_structs` map, (c) let-bindings with an explicit type
187+
annotation, and (d) let-bindings whose RHS is another tracked variable.
188+
189+
The harness now supports idiomatic enum-plus-match application-state
190+
test suites without fallback tagged-struct workarounds.
180191

181192
== Why AffineScript rather than ReScript?
182193

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// Regression tests for the two AffineScript codegen bugs discovered
3+
// 2026-04-19 and fixed in lib/codegen.ml on the same day:
4+
//
5+
// 1. Enum-in-match stack imbalance — PatCon-with-args left the tag-test
6+
// boolean on the stack twice, so any match arm whose body produced an
7+
// i32 value broke WASM validation with "expected 1 elements on the
8+
// stack for fallthru, found 2". Fixed by removing the redundant
9+
// LocalTee/LocalGet around the match_result local.
10+
//
11+
// 2. Non-first struct field read from a function parameter returned 0.
12+
// field_layouts was only populated for let-bound ExprRecord literals,
13+
// so every other binding path defaulted the offset to 0. Fixed by
14+
// registering struct layouts globally from TopType(TyStruct) and
15+
// propagating them to function parameters (via p_ty), call-result
16+
// lets (via fn_ret_structs), and let-annotated bindings.
17+
18+
enum Lifecycle {
19+
LUninit,
20+
LInit,
21+
LWithProfile(Int),
22+
LRunning(Int, Int),
23+
LTerminated
24+
}
25+
26+
struct Counters {
27+
tag: Int,
28+
profile_id: Int,
29+
activity_count: Int
30+
}
31+
32+
fn advance(s: Lifecycle) -> Lifecycle {
33+
return match s {
34+
LUninit => LInit(),
35+
LInit => LWithProfile(0),
36+
LWithProfile(p) => LRunning(p, 0),
37+
LRunning(p, c) => LRunning(p, c + 1),
38+
LTerminated => LTerminated()
39+
};
40+
}
41+
42+
fn make_counters() -> Counters {
43+
{ tag: 1, profile_id: 42, activity_count: 7 }
44+
}
45+
46+
fn read_profile_id(c: Counters) -> Int {
47+
c.profile_id
48+
}
49+
50+
fn read_activity_count(c: Counters) -> Int {
51+
c.activity_count
52+
}
53+
54+
pub fn test_enum_match_mixed_constructors() -> Bool {
55+
// Previously: "expected 1 elements on the stack" during instantiate.
56+
let s0 = LUninit();
57+
let s1 = advance(s0);
58+
return true;
59+
}
60+
61+
pub fn test_param_struct_field_offsets() -> Bool {
62+
// Previously: both helpers returned 0 because field_layouts defaulted
63+
// the offset. Now the declared `c: Counters` annotation pulls in the
64+
// struct's layout, so each field reads the right memory slot.
65+
let c = make_counters();
66+
return read_profile_id(c) == 42 && read_activity_count(c) == 7;
67+
}
68+
69+
pub fn test_let_from_call_struct_field() -> Bool {
70+
// Previously: `let c = make_counters()` did not register a layout
71+
// because RHS wasn't a record literal. Now fn_ret_structs kicks in.
72+
let c = make_counters();
73+
return c.activity_count == 7;
74+
}

0 commit comments

Comments
 (0)