Skip to content

Commit 9d312f9

Browse files
feat(cli): #260 S3 — @hyperpolymath/affinescript compiler shim (ADR-019) (#285)
ADR-019 slice S3 — the ergonomic Deno front door over the Releases-canonical compiler artifact (#260 S2). packages/affinescript-cli (`@hyperpolymath/affinescript`): - `hostTarget()` maps Deno.build → linux-x64/macos-x64/macos-arm64 (the ADR-019 release targets; windows rejected with a clear message, tracked follow-up). - `resolveCompiler()` — cache-checked download of the pinned Release asset; streaming SHA-256 verify against `pins.js` (FAIL-CLOSED: an unpinned or mismatched binary is NEVER executed — the ADR-019 supply-chain rule); caches under $AFFINESCRIPT_CACHE/XDG/HOME with the exec bit; a corrupt cache is detected (hash) and re-fetched. - `run()` execs it with argv passthrough + exit-code passthrough, stdio inherited. `pins.js` = one compiler version + one sha256 per target per shim release (filled at `v*` release-cut; empty sha ⇒ refuse). `fetchImpl`/`pins` test seams. - JavaScript (Deno host APIs only — the documented "JS where ReScript cannot" carve-out, same basis as packages/affine-js). Wired into `publish-jsr.yml` (owner-gated manual publish; new `affinescript-cli` choice → packages/affinescript-cli). `deno publish --dry-run` green (tests excluded). docs/PACKAGING.adoc truthed (compiler distribution now decided, not an open question). Tests: packages/affinescript-cli/mod_test.js — 6 Deno tests, network- free (fake fetch + temp cache + a shell-script "binary"): host mapping incl. windows-reject; download+verify+cache w/ exec bit; checksum-mismatch fail-closed + no cache write; cache-hit skips fetch + corrupt-cache recovery; exec argv + exit-code passthrough. All green. Gate: `dune test --force` 295/295 (no compiler/source change; zero regression). Refs #260 #181 #282. Not Closes — S4 (wire INT-10 onto the shim) is gated on a real `v*` Release cut + pins.js finalisation; owner closes per ISSUE-CLOSURE.
1 parent e90a828 commit 9d312f9

7 files changed

Lines changed: 331 additions & 10 deletions

File tree

.github/workflows/publish-jsr.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ on:
2323
options:
2424
- affine-js
2525
- affinescript-tea
26+
- affinescript-cli
2627
dry_run:
2728
description: "Dry run only (no publish)"
2829
required: true
@@ -45,8 +46,9 @@ jobs:
4546
id: pkg
4647
run: |
4748
case "${{ inputs.package }}" in
48-
affine-js) echo "dir=packages/affine-js" >> "$GITHUB_OUTPUT" ;;
49-
affinescript-tea) echo "dir=affinescript-tea" >> "$GITHUB_OUTPUT" ;;
49+
affine-js) echo "dir=packages/affine-js" >> "$GITHUB_OUTPUT" ;;
50+
affinescript-tea) echo "dir=affinescript-tea" >> "$GITHUB_OUTPUT" ;;
51+
affinescript-cli) echo "dir=packages/affinescript-cli" >> "$GITHUB_OUTPUT" ;;
5052
esac
5153
- name: Dry run
5254
working-directory: ${{ steps.pkg.outputs.dir }}

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,4 @@ htmlcov/
9595
/tests/codegen-deno/*.deno.js
9696
# Local-only build workaround (see file header); never committed.
9797
/dune-workspace
98+
packages/affinescript-cli/deno.lock

docs/PACKAGING.adoc

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,30 @@ this prep. If/when needed it follows the existing owner-sanctioned npm
5454
pattern (`.github/workflows/affine-vscode-publish.yml`, `NPM_TOKEN`
5555
scoped automation token) — a deliberate exception to Deno-first.
5656

57-
== Open design questionthe compiler itself
57+
== The compiler itselfdecided (ADR-019 / #260)
5858

5959
#181 says "publish *compiler* + runtime". The runtime is the JS
60-
packages above. The **compiler is a native OCaml binary** — it is *not*
61-
a JSR or npm package. "Publishing the compiler" therefore needs a
62-
release-binary strategy decision (GitHub Releases artifacts, Guix/Nix
63-
channel, or a thin JSR/npm shim that fetches a pinned binary). This is
64-
a one-way-door design choice and is filed as a separate issue rather
65-
than guessed here. `affinescript-lsp` distribution (INT-10) depends on
66-
that decision.
60+
packages above. The **compiler is a native OCaml binary** — not a
61+
JSR/npm package. The one-way-door fork (#260) was decided
62+
(**ADR-019**): *Releases-canonical, dual-channel*.
63+
64+
* *Canonical artifact.* `.github/workflows/release.yml` (#260 S2), on a
65+
`v*` tag, builds `affinescript-{linux-x64,macos-x64,macos-arm64}`
66+
raw binaries + a `SHA256SUMS` manifest attached to the GitHub
67+
Release. (`windows-x64` is a tracked follow-up.) Asset names are the
68+
ADR-019 contract — do not rename without amending the ADR + the shim.
69+
* *Ergonomic front door.* `packages/affinescript-cli`
70+
(`@hyperpolymath/affinescript`, #260 S3) — a thin Deno shim that
71+
downloads the host-triple binary from the Release pinned in
72+
`pins.js`, verifies it against the embedded SHA-256 (fail-closed: an
73+
unpinned/mismatched binary is never executed), caches it under
74+
`$AFFINESCRIPT_CACHE`/XDG, and execs it with the caller's argv. One
75+
compiler version + checksum per shim release (no floating fetch).
76+
Publish is owner-gated via `publish-jsr.yml` (the INT-04 manual
77+
workflow; choice `affinescript-cli`).
78+
* *Later, additive over the same Release artifact (not separate
79+
producers):* Guix/Nix fetch-derivations; an npm tail only if an
80+
npm-native consumer needs it.
81+
82+
`affinescript-lsp` distribution (INT-10, #282) consumes the shim;
83+
unblocked once a `v*` Release is cut and `pins.js` is finalised.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"name": "@hyperpolymath/affinescript",
3+
"version": "0.1.0",
4+
"exports": {
5+
".": "./mod.js"
6+
},
7+
"license": "PMPL-1.0-or-later",
8+
"publish": {
9+
"exclude": ["mod_test.js"]
10+
},
11+
"tasks": {
12+
"test": "deno test --allow-read --allow-write --allow-env --allow-run mod_test.js"
13+
}
14+
}

packages/affinescript-cli/mod.js

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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+
// @hyperpolymath/affinescript — the thin compiler shim (ADR-019 / #260 S3).
5+
//
6+
// The AffineScript compiler is a native OCaml binary, not a JS package.
7+
// Per ADR-019 the GitHub Release (cut by .github/workflows/release.yml,
8+
// #260 S2) is the CANONICAL artifact: per-platform `affinescript-<target>`
9+
// binaries + a `SHA256SUMS` manifest. This package is the ergonomic Deno
10+
// front door: it downloads the binary for the host triple from the
11+
// Release pinned by THIS package version, verifies it against the
12+
// checksum embedded here (no floating fetch — one version+checksum per
13+
// shim release, the ADR-019 supply-chain rule), caches it, and execs it
14+
// with the caller's argv.
15+
//
16+
// Deno-first (CLAUDE.md). JavaScript, not ReScript, because this is
17+
// entirely Deno host APIs (Deno.Command / fetch / crypto.subtle / fs) —
18+
// the documented "JS only where ReScript cannot" carve-out, same as
19+
// packages/affine-js.
20+
21+
import { PINS } from "./pins.js";
22+
23+
/** Map the host to an ADR-019 release target triple. */
24+
export function hostTarget(os = Deno.build.os, arch = Deno.build.arch) {
25+
if (os === "linux" && arch === "x86_64") return "linux-x64";
26+
if (os === "darwin" && arch === "x86_64") return "macos-x64";
27+
if (os === "darwin" && arch === "aarch64") return "macos-arm64";
28+
throw new Error(
29+
`@hyperpolymath/affinescript: unsupported host ${os}/${arch}. ` +
30+
`Supported: linux-x64, macos-x64, macos-arm64 (windows-x64 is a ` +
31+
`tracked follow-up). Build from source: hyperpolymath/affinescript.`,
32+
);
33+
}
34+
35+
/** Lower-case hex of the SHA-256 of `bytes`. */
36+
export async function sha256Hex(bytes) {
37+
const digest = await crypto.subtle.digest("SHA-256", bytes);
38+
return Array.from(new Uint8Array(digest))
39+
.map((b) => b.toString(16).padStart(2, "0"))
40+
.join("");
41+
}
42+
43+
/** Cache path for a pinned binary (XDG, then HOME, then a temp dir). */
44+
export function cachePath(version, target) {
45+
const base = Deno.env.get("AFFINESCRIPT_CACHE") ??
46+
Deno.env.get("XDG_CACHE_HOME") ??
47+
(Deno.env.get("HOME") ? `${Deno.env.get("HOME")}/.cache` : null) ??
48+
Deno.env.get("TMPDIR") ?? "/tmp";
49+
return `${base}/affinescript/${version}/affinescript-${target}`;
50+
}
51+
52+
/**
53+
* Resolve a runnable compiler binary path for the host, downloading +
54+
* checksum-verifying from the pinned Release on a cache miss.
55+
*
56+
* @param {{ pins?: object, fetchImpl?: typeof fetch }} [opts]
57+
* `pins`/`fetchImpl` are test seams; production uses the embedded
58+
* PINS and global fetch.
59+
* @returns {Promise<string>} absolute path to the verified binary
60+
*/
61+
export async function resolveCompiler(opts = {}) {
62+
const pins = opts.pins ?? PINS;
63+
const doFetch = opts.fetchImpl ?? fetch;
64+
const target = hostTarget();
65+
const entry = pins.targets?.[target];
66+
if (!entry || !entry.sha256 || !entry.url) {
67+
throw new Error(
68+
`@hyperpolymath/affinescript: no pinned binary for ${target} at ` +
69+
`version ${pins.version} (pins.js not finalised for this release).`,
70+
);
71+
}
72+
const path = cachePath(pins.version, target);
73+
74+
// Cache hit only counts if the cached bytes still match the pin
75+
// (defends against a corrupted/tampered cache).
76+
try {
77+
const cached = await Deno.readFile(path);
78+
if ((await sha256Hex(cached)) === entry.sha256) return path;
79+
} catch { /* miss — fall through to download */ }
80+
81+
const res = await doFetch(entry.url);
82+
if (!res.ok) {
83+
throw new Error(
84+
`@hyperpolymath/affinescript: download failed for ${target} ` +
85+
`(${entry.url}): HTTP ${res.status}`,
86+
);
87+
}
88+
const bytes = new Uint8Array(await res.arrayBuffer());
89+
const got = await sha256Hex(bytes);
90+
if (got !== entry.sha256) {
91+
throw new Error(
92+
`@hyperpolymath/affinescript: checksum mismatch for ${target} — ` +
93+
`expected ${entry.sha256}, got ${got}. Refusing to run (the ` +
94+
`Release artifact does not match this shim version's pin).`,
95+
);
96+
}
97+
await Deno.mkdir(path.slice(0, path.lastIndexOf("/")), { recursive: true });
98+
await Deno.writeFile(path, bytes, { mode: 0o755 });
99+
// writeFile mode is pre-umask; ensure it is executable.
100+
await Deno.chmod(path, 0o755).catch(() => {});
101+
return path;
102+
}
103+
104+
/**
105+
* Resolve then exec the compiler with `args`, inheriting stdio.
106+
* Returns the child's exit code (caller decides whether to exit).
107+
*/
108+
export async function run(args = Deno.args, opts = {}) {
109+
const bin = await resolveCompiler(opts);
110+
const cmd = new Deno.Command(bin, {
111+
args,
112+
stdin: "inherit",
113+
stdout: "inherit",
114+
stderr: "inherit",
115+
});
116+
const { code } = await cmd.output();
117+
return code;
118+
}
119+
120+
if (import.meta.main) {
121+
Deno.exit(await run());
122+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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+
// @hyperpolymath/affinescript shim tests (ADR-019 / #260 S3).
5+
// Network-free: a fake `fetchImpl` serves bytes; AFFINESCRIPT_CACHE is
6+
// redirected to a temp dir. The "binary" is a tiny shell script so the
7+
// exec/argv path is exercised for real without a built compiler.
8+
//
9+
// Run: deno test --allow-read --allow-write --allow-env --allow-run mod_test.js
10+
11+
import { assertEquals, assertRejects } from "jsr:@std/assert@1";
12+
import { hostTarget, resolveCompiler, run, sha256Hex } from "./mod.js";
13+
14+
Deno.test("hostTarget maps the three supported triples", () => {
15+
assertEquals(hostTarget("linux", "x86_64"), "linux-x64");
16+
assertEquals(hostTarget("darwin", "x86_64"), "macos-x64");
17+
assertEquals(hostTarget("darwin", "aarch64"), "macos-arm64");
18+
});
19+
20+
Deno.test("hostTarget rejects unsupported hosts (incl. windows)", () => {
21+
for (const [os, arch] of [["windows", "x86_64"], ["linux", "aarch64"]]) {
22+
let threw = false;
23+
try {
24+
hostTarget(os, arch);
25+
} catch (e) {
26+
threw = e.message.includes("unsupported host");
27+
}
28+
assertEquals(threw, true, `${os}/${arch} should be rejected`);
29+
}
30+
});
31+
32+
// A fake "compiler": a POSIX script echoing argv and exiting 7.
33+
const FAKE = new TextEncoder().encode(
34+
'#!/bin/sh\necho "args:$*"\nexit 7\n',
35+
);
36+
37+
async function pinsFor() {
38+
return {
39+
version: "vtest",
40+
targets: {
41+
[hostTarget()]: {
42+
url: "https://example.invalid/affinescript",
43+
sha256: await sha256Hex(FAKE),
44+
},
45+
},
46+
};
47+
}
48+
49+
const okFetch = () => ({
50+
ok: true,
51+
arrayBuffer: async () => FAKE.buffer.slice(0),
52+
});
53+
54+
function withTempCache(fn) {
55+
return async () => {
56+
const dir = await Deno.makeTempDir();
57+
const prev = Deno.env.get("AFFINESCRIPT_CACHE");
58+
Deno.env.set("AFFINESCRIPT_CACHE", dir);
59+
try {
60+
await fn(dir);
61+
} finally {
62+
if (prev === undefined) Deno.env.delete("AFFINESCRIPT_CACHE");
63+
else Deno.env.set("AFFINESCRIPT_CACHE", prev);
64+
await Deno.remove(dir, { recursive: true });
65+
}
66+
};
67+
}
68+
69+
Deno.test(
70+
"resolveCompiler downloads, checksum-verifies, caches (executable)",
71+
withTempCache(async () => {
72+
const pins = await pinsFor();
73+
const p = await resolveCompiler({ pins, fetchImpl: okFetch });
74+
const stat = await Deno.stat(p);
75+
assertEquals(stat.isFile, true);
76+
// mode is masked on some FSs; just assert the owner-exec bit is set.
77+
assertEquals((stat.mode & 0o100) !== 0, true);
78+
assertEquals(await sha256Hex(await Deno.readFile(p)), pins.targets[hostTarget()].sha256);
79+
}),
80+
);
81+
82+
Deno.test(
83+
"resolveCompiler refuses on checksum mismatch and does not cache",
84+
withTempCache(async (dir) => {
85+
const pins = await pinsFor();
86+
pins.targets[hostTarget()].sha256 = "0".repeat(64); // wrong pin
87+
await assertRejects(
88+
() => resolveCompiler({ pins, fetchImpl: okFetch }),
89+
Error,
90+
"checksum mismatch",
91+
);
92+
// Nothing written under the cache root.
93+
let entries = 0;
94+
for await (const _ of Deno.readDir(dir)) entries++;
95+
assertEquals(entries, 0);
96+
}),
97+
);
98+
99+
Deno.test(
100+
"resolveCompiler cache-hit skips fetch; corrupt cache re-downloads",
101+
withTempCache(async () => {
102+
const pins = await pinsFor();
103+
await resolveCompiler({ pins, fetchImpl: okFetch });
104+
// Cache hit: a throwing fetch must NOT be called.
105+
const p = await resolveCompiler({
106+
pins,
107+
fetchImpl: () => {
108+
throw new Error("fetch must not run on a valid cache hit");
109+
},
110+
});
111+
// Corrupt the cache → must re-fetch (okFetch) and restore.
112+
await Deno.writeFile(p, new TextEncoder().encode("tampered"));
113+
const p2 = await resolveCompiler({ pins, fetchImpl: okFetch });
114+
assertEquals(p2, p);
115+
assertEquals(
116+
await sha256Hex(await Deno.readFile(p2)),
117+
pins.targets[hostTarget()].sha256,
118+
);
119+
}),
120+
);
121+
122+
Deno.test(
123+
"run execs the resolved binary with argv passthrough + exit code",
124+
withTempCache(async () => {
125+
const pins = await pinsFor();
126+
const code = await run(["compile", "x.affine"], {
127+
pins,
128+
fetchImpl: okFetch,
129+
});
130+
assertEquals(code, 7); // the fake binary always exits 7
131+
}),
132+
);

packages/affinescript-cli/pins.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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+
// ADR-019 / #260: the pin table. ONE compiler version + ONE sha256 per
5+
// target, per shim release — the supply-chain rule (no floating fetch).
6+
//
7+
// Filled when a `v*` tag is cut: the `release.yml` (#260 S2) job
8+
// publishes `affinescript-<target>` + `SHA256SUMS` to the GitHub
9+
// Release. Copy each line's hex into the matching `sha256` below, set
10+
// `version` to the tag, and bump THIS package's deno.json version in
11+
// lockstep. Empty `sha256` ⇒ `resolveCompiler` refuses to run for that
12+
// target (fail-closed: an unpinned binary is never executed).
13+
//
14+
// URL contract (must match release.yml asset names; do not rename
15+
// without amending ADR-019): the per-target raw executable asset on the
16+
// Release for `version`.
17+
18+
const REPO = "https://github.com/hyperpolymath/affinescript";
19+
20+
function assetUrl(version, target) {
21+
return `${REPO}/releases/download/${version}/affinescript-${target}`;
22+
}
23+
24+
export const VERSION = "v0.1.0";
25+
26+
export const PINS = {
27+
version: VERSION,
28+
targets: {
29+
"linux-x64": { url: assetUrl(VERSION, "linux-x64"), sha256: "" },
30+
"macos-x64": { url: assetUrl(VERSION, "macos-x64"), sha256: "" },
31+
"macos-arm64": { url: assetUrl(VERSION, "macos-arm64"), sha256: "" },
32+
},
33+
};

0 commit comments

Comments
 (0)