// SPDX-License-Identifier: CC-BY-SA-4.0
// SPDX-FileCopyrightText: 2025-2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
= deno-esm-spike — proving the AffineScript -> Deno-ESM -> TypeScript-0 path
:toc: macro

[IMPORTANT]
====
*Status: SPIKE / PROPOSAL — TRIALED, GREEN.* This directory is a trial of
*nextgen-languages-evangelist roadmap ask #3*: can an AffineScript source be
compiled to a Deno ES module and run under Deno, so that *parity / scan
harnesses could be authored in AffineScript instead of TypeScript* (the
"TypeScript-0" goal)?

*Verdict: WORKS.* The `--deno-esm` backend (issue #122) emits real ESM
named exports that import cleanly from a plain `.mjs` Deno driver and are
directly callable. All five driver checks pass; exit 0; zero TypeScript.
====

toc::[]

== What this proves (and why it matters for TS-0)

The evangelist's parity harness (sibling `affine-parity/`) and any future
scan/test harness need to *call AffineScript-compiled code from a script*.
The estate language policy bans new TypeScript and prefers AffineScript. If
AffineScript compiles to an importable ES module, those harnesses can be
written in AffineScript (compiled to `.js`) + a thin `.mjs` glue driver,
with *no TypeScript anywhere* in the loop.

This spike is the minimal end-to-end demonstration of that path.

== Files

[cols="1,3"]
|===
| `sample.affine` | Minimal AffineScript: two `pub fn`s (`add`, `clamp_band`), pure integer arithmetic, no `extern`/host calls — so the only thing under test is the *export shape*.
| `sample.js`     | Generated by `--deno-esm` (Step 1). *Not committed* (gitignored): it is compiler output, and its prelude tripped a Semgrep non-literal-RegExp finding. Regenerate it before running the driver.
| `driver.mjs`    | Plain-JavaScript (`.mjs`, NOT TypeScript) Deno consumer: `import { add, clamp_band } from "./sample.js"` then asserts five results.
|===

== Step 0 — confirm the invocation (the flag is real)

`compile --help` documents the flag exactly:

----
$ /home/user/affinescript/_build/default/bin/main.exe compile --help
...
OPTIONS
       --deno-esm
           Emit a standalone Deno/Node ES module directly from the AST (issue
           #122): `export class` for struct+impl, `export` for public
           fns/consts, and `extern fn` lowered to direct host calls
           (Deno.*Sync / JSON / WebAssembly). No wasm, no require, no handle
           table — the output is a drop-in importable ESM. A `.deno.js`
           output extension selects this backend implicitly.
...
----

So the invocation is `compile --deno-esm -o <out>.js <in>.affine` (or simply
name the output `<out>.deno.js` to select the backend implicitly).

== Step 1 — compile the sample with --deno-esm

----
$ /home/user/affinescript/_build/default/bin/main.exe compile --deno-esm -o sample.js sample.affine
Compiled sample.affine -> sample.js (Deno-ESM)
exit=0
----

== Step 2 — what the generated `.js` looks like

The file is ~453 lines: an inlined AffineScript Deno-ESM runtime prelude
(Option/Result constructors, `print`/`println`, and a large bank of host
shims for Deno FS, pixi.js, motion, sqlite, http, json, etc. — all unused
by this pure sample but always emitted), followed by the actual exports. The
load-bearing tail is the exports themselves:

[source,javascript]
----
// Generated by AffineScript compiler (Deno-ESM target, issue #122)
// SPDX-License-Identifier: CC-BY-SA-4.0
// ---- AffineScript Deno-ESM runtime ----
const Some = (value) => ({ tag: "Some", value });
const None = { tag: "None" };
const Ok   = (value) => ({ tag: "Ok",  value });
const Err  = (error) => ({ tag: "Err", error });
const Unit = null;
const print   = (s) => { Deno.stdout.writeSync(new TextEncoder().encode(String(s))); };
const println = (s) => { console.log(String(s)); };
// ... (~440 lines of host shims for FS/pixi/motion/sqlite/http/json) ...
// ---- end runtime ----

export function add(a, b) {
  return (a + b);
}

export function clamp_band(x) {
  return ((x < 0) ? (() => { return 0; })() : (() => { return ((x > 3) ? (() => { return 3; })() : (() => { return x; })()); })());
}
----

Observations, honestly:

* The exports are *real ESM named exports* (`export function add`), not a
  default-export blob, not a wasm handle table — exactly the "drop-in
  importable ESM" the `--help` text promises.
* `if/else` lowers to an IIFE-per-branch ternary
  (`(cond ? (() => {...})() : (() => {...})())`). Verbose, but semantically
  correct and fully callable (the driver exercises both branches of
  `clamp_band`).
* The runtime prelude is emitted unconditionally (all host shims, even
  when the sample uses none). Cosmetic only — it does not affect the export
  contract — but a future size-conscious iteration could tree-shake it.

== Step 3 — run it under Deno

[source]
----
$ # sample.js is gitignored compiler output -- regenerate it first (Step 1):
$ /home/user/affinescript/_build/default/bin/main.exe compile --deno-esm -o sample.js sample.affine
$ deno run ./driver.mjs
deno-esm-spike driver: importing AffineScript-compiled ESM
  typeof add        = function
  typeof clamp_band = function
  ok   add(2, 3): 5
  ok   add(-4, 4): 0
  ok   clamp_band(-7) [below band]: 0
  ok   clamp_band(2)  [in band]: 2
  ok   clamp_band(99) [above band]: 3

result: 5 passed, 0 failed
exit=0
----

(Runs identically with or without `--allow-read`; the module read is implicit
in Deno's module loader, and the sample needs no further permissions.)

`deno --version` at trial time: `deno 2.8.2 (stable) / typescript 6.0.3`.

== Verdict

*WORKS — the TS-0 path is open.* An AffineScript source compiles via
`--deno-esm` to a drop-in ES module whose `pub fn`s become callable named
exports, importable from a plain `.mjs` Deno driver, returning correct values
across both arms of a branch. No TypeScript was authored or needed at any
step.

*Implication for the evangelist:* parity and scan harnesses (sibling
`affine-parity/`, `affine-assail/`) can be rewritten with their *logic in
AffineScript* (compiled to `.js`) and only a thin `.mjs` runner — satisfying
the estate "no new TypeScript" policy end to end.

*Known rough edges (non-blocking, flagged for the roadmap):*

. The runtime prelude (~440 lines of host shims) is always emitted, even for
  a pure module that calls none of it. Tree-shaking / on-demand emission
  would shrink artefacts.
. `if/else` -> IIFE-ternary is correct but verbose; a future codegen pass
  could emit flat `if` statements for statement-position branches.
. Only pure integer fns were trialed here. Struct/`impl` -> `export class`
  and `extern fn` -> host-call lowering are documented by `--help` but NOT
  exercised by this spike — a follow-up spike should confirm those export
  shapes too before the harness migration relies on them.
. The generated runtime prelude calls `new RegExp(<non-literal>)`, which
  Semgrep flags as a potential ReDoS (`detect-non-literal-regexp`). Because
  this is *generated* code, the fix belongs in the AffineScript codegen
  (hardcode the prelude's regexes, or omit the unused shim) — which is also
  why `sample.js` is gitignored here rather than committed.
