Skip to content

Commit a4d7175

Browse files
hyperpolymathclaude
andcommitted
feat(affinescript-deno-test): AffineScript→WASM→Deno test harness MVP
Adds a new component at affinescript-ecosystem/affinescript-deno-test/ that wires AffineScript .affine test files into Deno's test framework. Pipeline: 1. discover.ts — walk a root for *_test.affine / *.test.affine 2. compile.ts — shell out to `affinescript compile ... -o ....wasm` 3. runner.ts — load the WASM and register a Deno.test() case Built on top of the existing @hyperpolymath/affine-js bridge (700 LOC). This component is ~300 LOC of Deno-test-framework glue only. Smoke test green: `deno task smoke:test` → 1 passed / 0 failed. MVP v0.1.0 constraints — all resolvable by small upstream changes to the AffineScript compiler: - One test per file (compiler hardcodes exportable-name allowlist in lib/codegen.ml:1725; no pub fn / @export keyword yet). - WASI fd_write stub required (compiler imports it unconditionally even for pure programs; no-op stub is harmless). - Namespaced-imports path bypasses AffineModule (affine-js only supplies "env" imports today). Documented in README.adoc under the "MVP constraints and planned follow- ups" section so future contributors can see exactly which knobs upstream needs to turn. Unblocks the §3c TS→AffineScript migrations once the pub-fn keyword lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cd56d72 commit a4d7175

11 files changed

Lines changed: 594 additions & 0 deletions

File tree

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
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
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+
*MVP v0.1.0.* Minimal working harness, one test per file. See
19+
<<mvp-constraints>> below for the specific limits and planned follow-ups.
20+
21+
== Quick start
22+
23+
[source,bash]
24+
----
25+
# Compile + smoke-test the bundled example:
26+
deno task smoke:test
27+
# → hello ... ok (1 passed, 0 failed)
28+
----
29+
30+
== Writing a test
31+
32+
Each test is a single `.affine` file. The entry point is `fn main() -> Bool`;
33+
return `true` to pass, `false` to fail. Filename ends in `_test.affine` or
34+
`.test.affine`.
35+
36+
[source,affine]
37+
----
38+
// example/addition_test.affine
39+
// SPDX-License-Identifier: PMPL-1.0-or-later
40+
fn main() -> Bool {
41+
let result = 2 + 3;
42+
result == 5
43+
}
44+
----
45+
46+
== Running tests
47+
48+
The public API is `runAll(root)` in `mod.ts`:
49+
50+
[source,typescript]
51+
----
52+
// driver.ts
53+
import { runAll } from "@hyperpolymath/affinescript-deno-test";
54+
55+
await runAll("./tests");
56+
----
57+
58+
Invoke with `deno test`:
59+
60+
[source,bash]
61+
----
62+
deno test --allow-read --allow-run --allow-env driver.ts
63+
----
64+
65+
The runner:
66+
67+
. Walks `root` for `*_test.affine` / `*.test.affine` files
68+
. Compiles each via `affinescript compile ... -o ....wasm`
69+
. Loads the WASM (with a minimal WASI `fd_write` stub — AffineScript codegen
70+
imports this unconditionally)
71+
. Registers a `Deno.test()` case named after the file (stripped of the
72+
`_test` suffix) that invokes `main` and checks the Bool return
73+
74+
== CLI (smoke-check)
75+
76+
`cli.ts` is a thin smoke-check that prints discovery + compile results
77+
without running assertions. Useful for CI pipelines that want to fail fast
78+
on compile errors before invoking `deno test`.
79+
80+
[source,bash]
81+
----
82+
deno run --allow-read --allow-run --allow-env cli.ts ./tests
83+
# Discovered 3 test file(s):
84+
# ✓ tests/foo_test.affine → tests/foo_test.wasm
85+
# ✓ tests/bar_test.affine → tests/bar_test.wasm
86+
# ...
87+
----
88+
89+
== Configuration
90+
91+
* `AFFINESCRIPT_BIN` env var — absolute path to the `affinescript` compiler.
92+
Default: `/var/mnt/eclipse/repos/developer-ecosystem/nextgen-languages/affinescript/_build/install/default/bin/affinescript`
93+
(the local dev build). Override when the compiler is installed elsewhere
94+
or on `$PATH`.
95+
96+
[[mvp-constraints]]
97+
== MVP constraints (v0.1.0) and planned follow-ups
98+
99+
Three limits exist only because of the current AffineScript compiler's
100+
state. All are resolvable with small upstream changes.
101+
102+
=== One test per file
103+
104+
The AffineScript codegen (`lib/codegen.ml` line 1725) hardcodes the
105+
exportable-name allowlist to
106+
107+
[source,ocaml]
108+
----
109+
let export_names = ["main"; "init_state"; "step_state"; "get_state"; "mission_active"]
110+
----
111+
112+
with no `pub fn` / `@export` / `#[export]` keyword. Any function not on that
113+
list is internal to the module and cannot be invoked from JS. The harness
114+
therefore uses `main` as the one sanctioned test slot.
115+
116+
*Planned follow-up:* extend the compiler with a `pub fn` keyword (parser +
117+
AST + codegen). Once in place, the harness switches to the original design
118+
of multi-test-per-file via a `test_*` naming convention.
119+
120+
=== WASI `fd_write` imported unconditionally
121+
122+
Every AffineScript WASM output imports `wasi_snapshot_preview1.fd_write`
123+
even when the program uses no IO. The harness provides a minimal no-op
124+
stub so tests instantiate cleanly.
125+
126+
*Planned follow-up:* upstream compiler change so pure programs (no `effect
127+
IO`) skip the WASI import entirely. Until then, the stub is correct and
128+
harmless.
129+
130+
=== `@hyperpolymath/affine-js` only supplies `env` imports
131+
132+
The existing bridge's `AffineModule.fromBytes` merges custom imports under
133+
the `env` module key only. For WASM modules that import from a non-`env`
134+
module (e.g. `wasi_snapshot_preview1`), the harness bypasses `AffineModule`
135+
and uses raw `WebAssembly.instantiate` plus its own WASI stub. The Bool
136+
return is unmarshalled by hand (AffineScript compiles `Bool` → i32, 0/1).
137+
138+
*Planned follow-up:* extend `affine-js` to accept a `namespacedImports:
139+
Record<string, Record<string, ImportValue>>` option, then unify both paths
140+
behind `AffineModule`.
141+
142+
== Why AffineScript rather than ReScript?
143+
144+
ReScript is the estate's default TypeScript replacement and has a mature
145+
Deno integration (see `fireflag`). The AffineScript path exists because
146+
AffineScript has stronger semantics for:
147+
148+
* affine / quantitative types (resource lifecycle tracking)
149+
* algebraic effects (explicit side-effect boundaries)
150+
* row polymorphism (extensible records)
151+
152+
Test suites that want to *demonstrate* these properties — e.g. "this
153+
resource is consumed exactly once", "this protocol sequence is honoured" —
154+
are a natural fit for AffineScript. Plain behavioural tests can continue
155+
in ReScript; there's no migration pressure either direction.
156+
157+
The trade-off today: AffineScript's compiler matures while ReScript's is
158+
mature, so this harness carries the MVP constraints above. As the compiler
159+
gains `pub fn` / effect-conditional WASI imports / better bridge ergonomics,
160+
the harness sheds them.
161+
162+
== License
163+
164+
PMPL-1.0-or-later. See the repository root LICENSE file.
165+
Legal fallback: MPL-2.0 (automatic, per the hyperpolymath License Policy
166+
Rule 2 in `~/.claude/CLAUDE.md`).
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+
}
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.1.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-ecosystem/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: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// Example AffineScript test file for affinescript-deno-test.
3+
//
4+
// Convention (MVP v0.1.0):
5+
// - One test per `.affine` file.
6+
// - The test entry point is the function `main`, returning `Bool`.
7+
// - The harness uses the filename (without the `_test.affine` / `.test.affine`
8+
// suffix) as the Deno.test() case name.
9+
//
10+
// This single-test-per-file convention is an MVP constraint driven by the
11+
// current AffineScript codegen, which hardcodes the exportable-name
12+
// allowlist in lib/codegen.ml (only `main`, `init_state`, `step_state`,
13+
// `get_state`, `mission_active` are exported). Multi-test-per-file support
14+
// is a planned follow-up once the compiler gains a `pub fn` / `@export`
15+
// keyword.
16+
//
17+
// For now: each behaviour you want reported separately needs its own
18+
// `<name>_test.affine` file.
19+
20+
fn main() -> Bool {
21+
let two_plus_three = 2 + 3;
22+
let forty_two = 42;
23+
(two_plus_three == 5) && (forty_two == 42) && (1 != 2)
24+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
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: smoke test driver
5+
//
6+
// Run with:
7+
// deno test --allow-read --allow-run --allow-env example/smoke_driver.ts
8+
//
9+
// Expected output: three green tests (test_addition, test_identity,
10+
// test_inequality) from example/hello_test.affine.
11+
12+
import { runAll } from "../mod.ts";
13+
14+
await runAll(new URL("./", import.meta.url).pathname);
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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: compile.ts
5+
//
6+
// Wraps the `affinescript compile` CLI. Given a `.affine` source file,
7+
// produces a sibling `.wasm` file and returns its absolute path.
8+
//
9+
// The `AFFINESCRIPT_BIN` env var overrides the default path to the compiler.
10+
// Default is the local dev-build at developer-ecosystem/nextgen-languages/
11+
// affinescript/_build/install/default/bin/affinescript (useful while the
12+
// compiler is not on $PATH).
13+
14+
const DEFAULT_BIN =
15+
"/var/mnt/eclipse/repos/developer-ecosystem/nextgen-languages/affinescript/_build/install/default/bin/affinescript";
16+
17+
/** Absolute path to the `affinescript` compiler binary. */
18+
export function resolveCompilerPath(): string {
19+
return Deno.env.get("AFFINESCRIPT_BIN") ?? DEFAULT_BIN;
20+
}
21+
22+
/**
23+
* Compile an AffineScript source file to WebAssembly. Returns the absolute
24+
* path to the emitted `.wasm`. Throws with compiler stderr if compilation
25+
* fails.
26+
*/
27+
export async function compileToWasm(sourcePath: string): Promise<string> {
28+
const absolute = sourcePath.startsWith("/")
29+
? sourcePath
30+
: `${Deno.cwd()}/${sourcePath}`;
31+
32+
const wasmPath = absolute.replace(/\.(affine|afs|rattle|pyaff|jsaff)$/, ".wasm");
33+
if (wasmPath === absolute) {
34+
throw new Error(
35+
`compileToWasm: source file must end in .affine / .afs / .rattle / .pyaff / .jsaff — got ${sourcePath}`,
36+
);
37+
}
38+
39+
const bin = resolveCompilerPath();
40+
const cmd = new Deno.Command(bin, {
41+
args: ["compile", absolute, "-o", wasmPath],
42+
stdout: "piped",
43+
stderr: "piped",
44+
});
45+
46+
const { code, stdout, stderr } = await cmd.output();
47+
if (code !== 0) {
48+
const out = new TextDecoder().decode(stdout);
49+
const err = new TextDecoder().decode(stderr);
50+
throw new Error(
51+
`affinescript compile failed (exit ${code}) for ${sourcePath}\n` +
52+
`STDOUT:\n${out}\nSTDERR:\n${err}`,
53+
);
54+
}
55+
56+
return wasmPath;
57+
}

0 commit comments

Comments
 (0)