Skip to content

Commit 11485aa

Browse files
committed
Consolidate ecosystem projects into affinescript repo
1 parent 710904b commit 11485aa

1,138 files changed

Lines changed: 41161 additions & 335 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

affinescript-deno-test/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
# Build output — compiled .wasm files are regenerated by compileToWasm
3+
*.wasm

affinescript-deno-test/README.adoc

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
= affinescript-deno-test
3+
:toc:
4+
:icons: font
5+
6+
A Deno test-framework integration for AffineScript. Compiles `.affine` test
7+
files to WebAssembly via the standard `affinescript` compiler, loads them
8+
through the existing `@hyperpolymath/affine-js` bridge, and registers each
9+
as a `Deno.test()` case so `deno test` reports pass/fail in the usual way.
10+
11+
Part of the https://github.com/hyperpolymath/developer-ecosystem[developer-ecosystem]
12+
monorepo. Sibling tools in the AffineScript ecosystem live alongside this
13+
component: `affinescript/` (the compiler — actually mirrored from upstream),
14+
`affinescriptiser/`, `affinescript-vite/`, `rattlescript/`.
15+
16+
== Status
17+
18+
*v0.2.0.* Multi-test-per-file harness. The "one test per file" MVP
19+
constraint was resolved same-day via an upstream compiler change (see
20+
<<constraints>>).
21+
22+
== Quick start
23+
24+
[source,bash]
25+
----
26+
# Compile + smoke-test the bundled example:
27+
deno task smoke:test
28+
# → hello ... ok (1 passed, 0 failed)
29+
----
30+
31+
== Writing a test
32+
33+
Each test case is a top-level `pub fn test_<name>() -> Bool`. Return `true`
34+
to pass, `false` to fail. One `.affine` file may contain as many test cases
35+
as you like. Non-`pub` helpers stay internal to the module. Filename ends
36+
in `_test.affine` or `.test.affine`.
37+
38+
[source,affine]
39+
----
40+
// example/hello_test.affine
41+
// SPDX-License-Identifier: PMPL-1.0-or-later
42+
43+
pub fn test_addition() -> Bool {
44+
let result = 2 + 3;
45+
result == 5
46+
}
47+
48+
pub fn test_identity() -> Bool {
49+
let x = 42;
50+
x == 42
51+
}
52+
53+
fn helper_not_exported() -> Int {
54+
99
55+
}
56+
57+
pub fn test_uses_helper() -> Bool {
58+
helper_not_exported() == 99
59+
}
60+
----
61+
62+
The harness reports each test as `<filestem> / <casename>` — e.g. the file
63+
above produces `hello / addition`, `hello / identity`, `hello / uses_helper`
64+
under `deno test`.
65+
66+
== Running tests
67+
68+
The public API is `runAll(root)` in `mod.ts`:
69+
70+
[source,typescript]
71+
----
72+
// driver.ts
73+
import { runAll } from "@hyperpolymath/affinescript-deno-test";
74+
75+
await runAll("./tests");
76+
----
77+
78+
Invoke with `deno test`:
79+
80+
[source,bash]
81+
----
82+
deno test --allow-read --allow-run --allow-env driver.ts
83+
----
84+
85+
The runner:
86+
87+
. Walks `root` for `*_test.affine` / `*.test.affine` files
88+
. Compiles each via `affinescript compile ... -o ....wasm`
89+
. Loads the WASM (with a minimal WASI `fd_write` stub — AffineScript codegen
90+
imports this unconditionally)
91+
. Registers a `Deno.test()` case named after the file (stripped of the
92+
`_test` suffix) that invokes `main` and checks the Bool return
93+
94+
== CLI (smoke-check)
95+
96+
`cli.ts` is a thin smoke-check that prints discovery + compile results
97+
without running assertions. Useful for CI pipelines that want to fail fast
98+
on compile errors before invoking `deno test`.
99+
100+
[source,bash]
101+
----
102+
deno run --allow-read --allow-run --allow-env cli.ts ./tests
103+
# Discovered 3 test file(s):
104+
# ✓ tests/foo_test.affine → tests/foo_test.wasm
105+
# ✓ tests/bar_test.affine → tests/bar_test.wasm
106+
# ...
107+
----
108+
109+
== Configuration
110+
111+
* `AFFINESCRIPT_BIN` env var — absolute path to the `affinescript` compiler.
112+
Default: `/var/mnt/eclipse/repos/developer-ecosystem/nextgen-languages/affinescript/_build/install/default/bin/affinescript`
113+
(the local dev build). Override when the compiler is installed elsewhere
114+
or on `$PATH`.
115+
116+
[[constraints]]
117+
== Constraints and planned follow-ups
118+
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.
122+
123+
=== RESOLVED (v0.2.0): one test per file
124+
125+
The AffineScript codegen previously hardcoded an exportable-name allowlist
126+
to `["main"; "init_state"; "step_state"; "get_state"; "mission_active"]`
127+
with no `pub fn` / `@export` keyword — so only those five names appeared
128+
in the WASM export list, no matter the source.
129+
130+
The fix (AffineScript commit `ce324fa`) makes both `codegen.ml` and
131+
`codegen_gc.ml` honour the AST's `fd_vis = Public` field in addition to
132+
the game-hook allowlist. The allowlist is retained verbatim for backward
133+
compatibility (pre-`pub` programs and the IDApTIK CharacterSelect bridge
134+
continue to work unchanged).
135+
136+
The harness now uses the expected multi-test convention: any top-level
137+
`pub fn test_<name>() -> Bool` becomes a separate `Deno.test()` case.
138+
139+
=== WASI `fd_write` imported unconditionally
140+
141+
Every AffineScript WASM output imports `wasi_snapshot_preview1.fd_write`
142+
even when the program uses no IO. The harness provides a minimal no-op
143+
stub so tests instantiate cleanly.
144+
145+
*Planned follow-up:* upstream compiler change so pure programs (no `effect
146+
IO`) skip the WASI import entirely. Until then, the stub is correct and
147+
harmless.
148+
149+
=== `@hyperpolymath/affine-js` only supplies `env` imports
150+
151+
The existing bridge's `AffineModule.fromBytes` merges custom imports under
152+
the `env` module key only. For WASM modules that import from a non-`env`
153+
module (e.g. `wasi_snapshot_preview1`), the harness bypasses `AffineModule`
154+
and uses raw `WebAssembly.instantiate` plus its own WASI stub. The Bool
155+
return is unmarshalled by hand (AffineScript compiles `Bool` → i32, 0/1).
156+
157+
*Planned follow-up:* extend `affine-js` to accept a `namespacedImports:
158+
Record<string, Record<string, ImportValue>>` option, then unify both paths
159+
behind `AffineModule`.
160+
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.
191+
192+
== Why AffineScript rather than ReScript?
193+
194+
ReScript is the estate's default TypeScript replacement and has a mature
195+
Deno integration (see `fireflag`). The AffineScript path exists because
196+
AffineScript has stronger semantics for:
197+
198+
* affine / quantitative types (resource lifecycle tracking)
199+
* algebraic effects (explicit side-effect boundaries)
200+
* row polymorphism (extensible records)
201+
202+
Test suites that want to *demonstrate* these properties — e.g. "this
203+
resource is consumed exactly once", "this protocol sequence is honoured" —
204+
are a natural fit for AffineScript. Plain behavioural tests can continue
205+
in ReScript; there's no migration pressure either direction.
206+
207+
The trade-off today: AffineScript's compiler matures while ReScript's is
208+
mature, so this harness carries the MVP constraints above. As the compiler
209+
gains `pub fn` / effect-conditional WASI imports / better bridge ergonomics,
210+
the harness sheds them.
211+
212+
== License
213+
214+
PMPL-1.0-or-later. See the repository root LICENSE file.
215+
Legal fallback: MPL-2.0 (automatic, per the hyperpolymath License Policy
216+
Rule 2 in `~/.claude/CLAUDE.md`).

affinescript-deno-test/cli.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
//
4+
// affinescript-deno-test: CLI entry
5+
//
6+
// Usage:
7+
// deno run --allow-read --allow-run --allow-env cli.ts <root>
8+
// deno run --allow-read --allow-run --allow-env cli.ts ./tests
9+
//
10+
// The CLI is a thin wrapper — the actual test registration happens via
11+
// runAll() which calls Deno.test(). To see reporting, invoke via `deno test`
12+
// on a driver script that imports runAll, rather than running this module
13+
// standalone.
14+
//
15+
// This file is primarily here so `deno run cli.ts <root>` works as a
16+
// smoke-test of discovery + compile (it prints the source list + compile
17+
// results without actually running assertions — that requires deno test).
18+
19+
import { compileToWasm } from "./lib/compile.ts";
20+
import { discoverTestFiles } from "./lib/discover.ts";
21+
22+
function usage(): never {
23+
console.error(
24+
"Usage: deno run --allow-read --allow-run --allow-env cli.ts <root>\n" +
25+
"\n" +
26+
"Discovers *_test.affine files under <root>, compiles each to .wasm,\n" +
27+
"and prints the compile results. To actually run the tests, invoke\n" +
28+
"`deno test` on a driver script that imports runAll() from mod.ts.",
29+
);
30+
Deno.exit(2);
31+
}
32+
33+
if (import.meta.main) {
34+
const root = Deno.args[0];
35+
if (!root) usage();
36+
37+
const sources = await discoverTestFiles(root);
38+
if (sources.length === 0) {
39+
console.error(`No *_test.affine files found under ${root}`);
40+
Deno.exit(1);
41+
}
42+
43+
console.log(`Discovered ${sources.length} test file(s):`);
44+
for (const source of sources) {
45+
try {
46+
const wasm = await compileToWasm(source);
47+
console.log(` ✓ ${source}${wasm}`);
48+
} catch (error) {
49+
const message = error instanceof Error ? error.message : String(error);
50+
console.error(` ✗ ${source}\n ${message}`);
51+
Deno.exit(1);
52+
}
53+
}
54+
}

affinescript-deno-test/deno.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"//": "SPDX-License-Identifier: PMPL-1.0-or-later",
3+
"name": "@hyperpolymath/affinescript-deno-test",
4+
"version": "0.2.0",
5+
"exports": {
6+
".": "./mod.ts",
7+
"./cli": "./cli.ts"
8+
},
9+
"license": "PMPL-1.0-or-later",
10+
"imports": {
11+
"@hyperpolymath/affine-js": "../../nextgen-languages/affinescript/packages/affine-js/mod.js"
12+
},
13+
"tasks": {
14+
"smoke:compile": "deno run --allow-read --allow-run --allow-env cli.ts example",
15+
"smoke:test": "deno test --allow-read --allow-run --allow-env example/smoke_driver.ts",
16+
"fmt": "deno fmt",
17+
"lint": "deno lint"
18+
}
19+
}

affinescript-deno-test/deno.lock

Lines changed: 25 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
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)