Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,10 @@
# `dune runtest` — not an optional extra that merely widens
# coverage. Dropping it turns the walker suite red, which is
# the intended behaviour: see that file's header comment.
run: npm install -g tree-sitter-cli@^0.25.0
# Pin exact version + --ignore-scripts: Sonar githubactions:S8543
# (unlocked versions) and S6505 (lifecycle scripts). Same binary
# the grammar's package.json asks for (^0.25.0 floor).
run: npm install -g --ignore-scripts tree-sitter-cli@0.25.0
- name: Build pinned tree-sitter-rescript grammar
run: ./editors/tree-sitter-rescript/scripts/install.sh
- name: Build
Expand Down Expand Up @@ -238,7 +241,10 @@
# so this job needs the same grammar prerequisites as `build`.
# Before the skip was removed, this job was green while running
# zero walker tests.
run: npm install -g tree-sitter-cli@^0.25.0
# Pin exact version + --ignore-scripts: Sonar githubactions:S8543
# (unlocked versions) and S6505 (lifecycle scripts). Same binary
# the grammar's package.json asks for (^0.25.0 floor).
run: npm install -g --ignore-scripts tree-sitter-cli@0.25.0
- name: Build pinned tree-sitter-rescript grammar
run: ./editors/tree-sitter-rescript/scripts/install.sh
- name: Run tests with bisect_ppx instrumentation
Expand Down Expand Up @@ -303,7 +309,7 @@
# deps. `vscode` itself is provided by the test runner at launch.
# The #381 SKIP-when-missing guard remains downstream as a safety
# net but is now expected to find the adapter present.
run: npm install --no-audit --no-fund
run: npm install --no-audit --no-fund --ignore-scripts

Check warning on line 312 in .github/workflows/ci.yml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Using dependencies without locking resolved versions is security-sensitive.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_affinescript&issues=AaDDFuIT2jUss_VbsDGo&open=AaDDFuIT2jUss_VbsDGo&pullRequest=758
- name: Report adapter availability
working-directory: editors/vscode
# Surface the @hyperpolymath/affine-vscode publish state in the
Expand Down Expand Up @@ -348,7 +354,10 @@
# the install script invokes via `command -v`. The version
# tracks `tree-sitter-rescript`'s package.json devDependency
# range.
run: npm install -g tree-sitter-cli@^0.25.0
# Pin exact version + --ignore-scripts: Sonar githubactions:S8543
# (unlocked versions) and S6505 (lifecycle scripts). Same binary
# the grammar's package.json asks for (^0.25.0 floor).
run: npm install -g --ignore-scripts tree-sitter-cli@0.25.0
- name: Build pinned tree-sitter-rescript grammar
# Direct script invocation rather than `just install-grammar` —
# GitHub Actions runners do not ship `just` preinstalled, and
Expand Down
5 changes: 3 additions & 2 deletions docs/CAPABILITY-MATRIX.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -294,8 +294,9 @@ their own ledger (link:SOUNDNESS.adoc[SOUNDNESS.adoc]), gated by
`affinescript-dom` (INT-08/#183) shipped runtimes; `affinescript-cadre`
(INT-09) closed 2026-07-25. `affinescript-pixijs` in-tree bindings
(`stdlib/Pixi.affine` + UI/Sound) plus 56-A `stdlib/Dom.affine` /
`stdlib/Console.affine` landed 2026-09-21; full idaptik surface is
still open (#56). The `-dom-loader` satellite was folded into
`stdlib/Console.affine` and 56 PR 4 Window/Document/utility externs
landed 2026-09-21; record-literal attrs + remaining idaptik Pixi
decls are still open (#56). The `-dom-loader` satellite was folded into
`affine-js` (#489).

== Change control
Expand Down
3 changes: 2 additions & 1 deletion docs/ECOSYSTEM.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ remaining original #175 children INT-07 (#182) and INT-08 (#183) closed
(socket recv/send/accept) have preview1 lowerings + gated smokes in
tree; #486 flipped the wrap default to `--command` without the one-way
compiler re-target. Satellite remainder is #56 (56-A Dom/Console + Pixi
gap-fill landed). Stage E is now the frontier.
gap-fill + Window/Document/utility PR 4 landed; record-literal attrs
remain). Stage E is now the frontier.

|*E* |typed-wasm convergence hardening (the transition runway): the
AffineScript↔typed-wasm contract widened from L7+L10 toward full
Expand Down
9 changes: 6 additions & 3 deletions docs/TECH-DEBT.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ index. Residual WASI follow-ups #485 (S5 filesystem) and #487
(socket recv/send/accept) have preview1 lowerings + gated smokes in
tree; #486 flipped the *wrap* default to `--command` without the
one-way compiler re-target (native-preview2 cleanup still open).
Satellite remainder is #56 (56-A Dom/Console + Pixi gap-fill landed;
full idaptik surface still open).
Satellite remainder is #56 (56-A Dom/Console + Pixi gap-fill +
Window/Document/utility PR 4 landed; record-literal attrs + remaining
idaptik Pixi decls still open).
====

toc::[]
Expand Down Expand Up @@ -486,7 +487,9 @@ Proven + locked (see INT-02)
#182 CLOSED 2026-07-25)
|SAT-04 |`affinescript-cadre` router runtime |S2 |**DONE** (=INT-09,
CLOSED 2026-07-25)
|SAT-05 |`affinescript-pixijs` migration prerequisite |S3 |open #56
|SAT-05 |`affinescript-pixijs` migration prerequisite |S3 |open #56 —
56-A + Pixi gap-fill + Window/Document/utility (PR 4) landed; remainder
is record-literal attrs + the rest of idaptik's Pixi.res decls
|===

== Stage E — typed-wasm convergence runway
Expand Down
68 changes: 68 additions & 0 deletions lib/codegen_deno.ml
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,50 @@ const __as_dbMaxInt = (h, sql, params) => Number(globalThis.__as_sqlite.agg
const __as_dbAvg = (h, sql, params) => Number(globalThis.__as_sqlite.aggAvg(h, sql, params));
const __as_dbGroupBy = (h, sql, params) => String(globalThis.__as_sqlite.groupBy(h, sql, params));
const __as_dbGroupCount = (h, table, keyCol) => String(globalThis.__as_sqlite.groupCount(h, table, keyCol));
// ---- Dom (#56 PR 4): Window / Document / utility ----
// Host is `globalThis.window` when present (browser, jsdom, idaptik
// WebView); otherwise `globalThis` so a Deno/Node harness can install
// document/window mocks on the global. No consumer-side init required
// beyond providing those web-platform objects.
const __as_domWin = () =>
(typeof globalThis.window !== "undefined" ? globalThis.window : globalThis);
const __as_domDoc = () => {
const d = __as_domWin().document;
if (!d) throw new Error("DOM document host is unavailable");
return d;
};
const __as_addEventListener = (event, handler) => {
__as_domWin().addEventListener(event, handler); return 0;
};
const __as_removeEventListener = (event, handler) => {
__as_domWin().removeEventListener(event, handler); return 0;
};
const __as_matchMedia = (query) => __as_domWin().matchMedia(query);
const __as_windowOpen = (url, target) => {
__as_domWin().open(url, target); return 0;
};
const __as_innerWidth = () => Number(__as_domWin().innerWidth) | 0;
const __as_innerHeight = () => Number(__as_domWin().innerHeight) | 0;
const __as_devicePixelRatio = () => Number(__as_domWin().devicePixelRatio) || 1;
const __as_setTimeout = (handler, ms) => __as_domWin().setTimeout(handler, ms);
const __as_setInterval = (handler, ms) => __as_domWin().setInterval(handler, ms);
const __as_clearInterval = (id) => { __as_domWin().clearInterval(id); return 0; };
const __as_clearTimeout = (id) => { __as_domWin().clearTimeout(id); return 0; };
const __as_createElement = (tag) => __as_domDoc().createElement(tag);
const __as_getElementById = (id) => __as_domDoc().getElementById(id);
const __as_bodyAppendChild = (child) => { __as_domDoc().body.appendChild(child); return 0; };
const __as_bodyRemoveChild = (child) => { __as_domDoc().body.removeChild(child); return 0; };
const __as_bodyFirstChild = () => __as_domDoc().body.firstChild;
const __as_bodySetInnerHTML = (html) => { __as_domDoc().body.innerHTML = html; return 0; };
const __as_bodyInnerHTML = () => String(__as_domDoc().body.innerHTML);
const __as_elementAsNode = (el) => el;
const __as_domDateNow = () => Date.now();
const __as_numberIsFinite = (n) => Number.isFinite(Number(n));
const __as_numberIsNaN = (n) => Number.isNaN(Number(n));
const __as_btoa = (s) => {
if (typeof globalThis.btoa === "function") return globalThis.btoa(String(s));
return Buffer.from(String(s), "binary").toString("base64");
};
const __as_httpFetch = async (url, method, headers, bodyOpt) => {
const init = { method, headers: __as_httpHeadersToObject(headers) };
if (bodyOpt && bodyOpt.tag === "Some") init.body = bodyOpt.value;
Expand Down Expand Up @@ -909,6 +953,30 @@ let () =
b "canvasMeasureText" (fun a -> Printf.sprintf "__as_canvasMeasureText(%s, %s)" (arg 0 a) (arg 1 a));
b "canvasDrawImage" (fun a -> Printf.sprintf "__as_canvasDrawImage(%s, %s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a) (arg 3 a));
b "canvasDrawImageScaled" (fun a -> Printf.sprintf "__as_canvasDrawImageScaled(%s, %s, %s, %s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a) (arg 3 a) (arg 4 a) (arg 5 a));
(* ---- Dom (#56 PR 4): Window / Document / utility ---- *)
b "addEventListener" (fun a -> Printf.sprintf "__as_addEventListener(%s, %s)" (arg 0 a) (arg 1 a));
b "removeEventListener" (fun a -> Printf.sprintf "__as_removeEventListener(%s, %s)" (arg 0 a) (arg 1 a));
b "matchMedia" (fun a -> Printf.sprintf "__as_matchMedia(%s)" (arg 0 a));
b "windowOpen" (fun a -> Printf.sprintf "__as_windowOpen(%s, %s)" (arg 0 a) (arg 1 a));
b "innerWidth" (fun _ -> "__as_innerWidth()");
b "innerHeight" (fun _ -> "__as_innerHeight()");
b "devicePixelRatio" (fun _ -> "__as_devicePixelRatio()");
b "setTimeout" (fun a -> Printf.sprintf "__as_setTimeout(%s, %s)" (arg 0 a) (arg 1 a));
b "setInterval" (fun a -> Printf.sprintf "__as_setInterval(%s, %s)" (arg 0 a) (arg 1 a));
b "clearInterval" (fun a -> Printf.sprintf "__as_clearInterval(%s)" (arg 0 a));
b "clearTimeout" (fun a -> Printf.sprintf "__as_clearTimeout(%s)" (arg 0 a));
b "createElement" (fun a -> Printf.sprintf "__as_createElement(%s)" (arg 0 a));
b "getElementById" (fun a -> Printf.sprintf "__as_getElementById(%s)" (arg 0 a));
b "bodyAppendChild" (fun a -> Printf.sprintf "__as_bodyAppendChild(%s)" (arg 0 a));
b "bodyRemoveChild" (fun a -> Printf.sprintf "__as_bodyRemoveChild(%s)" (arg 0 a));
b "bodyFirstChild" (fun _ -> "__as_bodyFirstChild()");
b "bodySetInnerHTML" (fun a -> Printf.sprintf "__as_bodySetInnerHTML(%s)" (arg 0 a));
b "bodyInnerHTML" (fun _ -> "__as_bodyInnerHTML()");
b "elementAsNode" (fun a -> Printf.sprintf "__as_elementAsNode(%s)" (arg 0 a));
b "domDateNow" (fun _ -> "__as_domDateNow()");
b "numberIsFinite" (fun a -> Printf.sprintf "__as_numberIsFinite(%s)" (arg 0 a));
b "numberIsNaN" (fun a -> Printf.sprintf "__as_numberIsNaN(%s)" (arg 0 a));
b "btoa" (fun a -> Printf.sprintf "__as_btoa(%s)" (arg 0 a));
(* Generic JS array push helper (returns the array, fluent). *)
b "arrayPush" (fun a -> Printf.sprintf "(%s.push(%s), %s)" (arg 0 a) (arg 1 a) (arg 0 a));
(* ---- honest string/number primitives underpinning the
Expand Down
44 changes: 44 additions & 0 deletions stdlib/Dom.affine
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

module Dom;

use Deno::{Json};

pub enum VNode {
VText(String),
VElem(String, [(String, String)], [VNode])
Expand Down Expand Up @@ -59,3 +61,45 @@ pub fn ul(attrs: [(String, String)], children: [VNode]) -> VNode =

pub fn li(attrs: [(String, String)], children: [VNode]) -> VNode =
h("li", attrs, children);

// ── #56 PR 4 — Window / Document / utility (idaptik legacy surface) ──
//
// These are the remaining bindings from the original #56 inventory that
// are not VNode constructors: Window (11), Document (7), utility (4;
// `fetch` already lives in Http.affine). Callbacks cross as opaque
// `Json` — the same host-function shape Pixi/Canvas use. Deno-ESM
// lowerings live in lib/codegen_deno.ml (`__as_dom*` helpers).

pub extern type Element;
pub extern type Node;
pub extern type MediaQueryList;
pub extern type TimerId;

// Window
pub extern fn addEventListener(event: String, handler: Json) -> Int;
pub extern fn removeEventListener(event: String, handler: Json) -> Int;
pub extern fn matchMedia(query: String) -> MediaQueryList;
pub extern fn windowOpen(url: String, target: String) -> Int;
pub extern fn innerWidth() -> Int;
pub extern fn innerHeight() -> Int;
pub extern fn devicePixelRatio() -> Float;
pub extern fn setTimeout(handler: Json, ms: Int) -> TimerId;
pub extern fn setInterval(handler: Json, ms: Int) -> TimerId;
pub extern fn clearInterval(id: TimerId) -> Int;
pub extern fn clearTimeout(id: TimerId) -> Int;

// Document
pub extern fn createElement(tag: String) -> Element;
pub extern fn getElementById(id: String) -> Element;
pub extern fn bodyAppendChild(child: Node) -> Int;
pub extern fn bodyRemoveChild(child: Node) -> Int;
pub extern fn bodyFirstChild() -> Node;
pub extern fn bodySetInnerHTML(html: String) -> Int;
pub extern fn bodyInnerHTML() -> String;
pub extern fn elementAsNode(el: Element) -> Node;

// Utility (Date.now / Number.isFinite / Number.isNaN / btoa)
pub extern fn domDateNow() -> Int;
pub extern fn numberIsFinite(n: Float) -> Bool;
pub extern fn numberIsNaN(n: Float) -> Bool;
pub extern fn btoa(s: String) -> String;
50 changes: 50 additions & 0 deletions tests/codegen-deno/dom_window_smoke.affine
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// SPDX-License-Identifier: MPL-2.0
// #56 PR 4 — Window / Document / utility smoke.
//
// The harness installs a jsdom-style `window`/`document` mock on
// globalThis before importing the generated module.

use Deno::{Json};
use Dom::{Element, Node, MediaQueryList, TimerId, addEventListener, removeEventListener, matchMedia, windowOpen, innerWidth, innerHeight, devicePixelRatio, setTimeout, setInterval, clearInterval, clearTimeout, createElement, getElementById, bodyAppendChild, bodyRemoveChild, bodyFirstChild, bodySetInnerHTML, bodyInnerHTML, elementAsNode, domDateNow, numberIsFinite, numberIsNaN, btoa};

/// Window geometry + media + open.
pub fn smokeWindow(handler: Json) -> Int {
addEventListener("resize", handler);
removeEventListener("resize", handler);
let _mq = matchMedia("(min-width: 600px)");
windowOpen("https://example.com", "_blank");
let _w = innerWidth();
let _h = innerHeight();
let _dpr = devicePixelRatio();
0
}

/// Timers: set + clear both timeout and interval.
pub fn smokeTimers(handler: Json) -> Int {
let t = setTimeout(handler, 16);
clearTimeout(t);
let i = setInterval(handler, 1000);
clearInterval(i);
0
}

/// Document: create, lookup, body child list + innerHTML.
pub fn smokeDocument() -> String {
let el = createElement("div");
let node = elementAsNode(el);
bodyAppendChild(node);
let _found = getElementById("root");
let _first = bodyFirstChild();
bodySetInnerHTML("<p>hi</p>");
let html = bodyInnerHTML();
bodyRemoveChild(node);
html
}

/// Date.now / Number.isFinite / Number.isNaN / btoa.
pub fn smokeUtil(n: Float, s: String) -> String {
let _now = domDateNow();
let _fin = numberIsFinite(n);
let _nan = numberIsNaN(n);
btoa(s)
}
72 changes: 72 additions & 0 deletions tests/codegen-deno/dom_window_smoke.harness.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// SPDX-License-Identifier: MPL-2.0
// #56 PR 4 — Node ESM harness for Window / Document / utility bindings.

import assert from "node:assert/strict";

const log = [];
const kids = [];
const body = {
get firstChild() { return kids[0] || null; },
get innerHTML() { return this._html; },
set innerHTML(v) { this._html = String(v); log.push(["innerHTML", v]); },
_html: "",
appendChild(n) { kids.push(n); log.push(["append", n.tag]); return n; },
removeChild(n) {
const i = kids.indexOf(n);
if (i >= 0) kids.splice(i, 1);
log.push(["remove", n.tag]);
return n;
},
};
const elements = { root: { tag: "div", id: "root" } };
const document = {
body,
createElement(tag) { const el = { tag }; log.push(["create", tag]); return el; },
getElementById(id) { log.push(["byId", id]); return elements[id] || null; },
};
const windowMock = {
innerWidth: 1280,
innerHeight: 720,
devicePixelRatio: 2,
document,
addEventListener(event, handler) { log.push(["on", event, handler]); },
removeEventListener(event, handler) { log.push(["off", event, handler]); },
matchMedia(q) { log.push(["mq", q]); return { media: q, matches: true }; },
open(url, target) { log.push(["open", url, target]); return null; },
setTimeout(handler, ms) { log.push(["timeout", ms]); return 11; },
setInterval(handler, ms) { log.push(["interval", ms]); return 22; },
clearTimeout(id) { log.push(["clearTimeout", id]); },
clearInterval(id) { log.push(["clearInterval", id]); },
};
globalThis.window = windowMock;
globalThis.document = document;

const {
smokeWindow, smokeTimers, smokeDocument, smokeUtil,
} = await import("./dom_window_smoke.deno.js");

const handler = () => 0;
assert.equal(smokeWindow(handler), 0);
assert.deepEqual(log.filter((x) => x[0] === "on")[0].slice(0, 2), ["on", "resize"]);

Check warning on line 50 in tests/codegen-deno/dom_window_smoke.harness.mjs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `.find(…)` over `.filter(…)[0]`.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_affinescript&issues=AaDDFuLa2jUss_VbsDGp&open=AaDDFuLa2jUss_VbsDGp&pullRequest=758
assert.equal(log.find((x) => x[0] === "mq")[1], "(min-width: 600px)");
assert.deepEqual(log.find((x) => x[0] === "open"), ["open", "https://example.com", "_blank"]);

log.length = 0;
assert.equal(smokeTimers(handler), 0);
assert.deepEqual(log.find((x) => x[0] === "timeout"), ["timeout", 16]);
assert.deepEqual(log.find((x) => x[0] === "interval"), ["interval", 1000]);
assert.deepEqual(log.find((x) => x[0] === "clearTimeout"), ["clearTimeout", 11]);
assert.deepEqual(log.find((x) => x[0] === "clearInterval"), ["clearInterval", 22]);

log.length = 0;
const html = smokeDocument();
assert.equal(html, "<p>hi</p>");
assert.equal(log.find((x) => x[0] === "create")[1], "div");
assert.equal(log.find((x) => x[0] === "byId")[1], "root");
assert.equal(log.find((x) => x[0] === "innerHTML")[1], "<p>hi</p>");

const encoded = smokeUtil(1.5, "hi");
assert.equal(encoded, "aGk=");
assert.equal(Buffer.from(encoded, "base64").toString(), "hi");

console.log("dom_window_smoke.harness.mjs OK");
Loading