From c2f2427b1877695f6955d2ab5c788ad2226a5e83 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" Date: Mon, 21 Sep 2026 08:30:00 +0000 Subject: [PATCH] fix(ci,dom): pin tree-sitter-cli for Sonar; land #56 Window/Document/utility SonarCloud QG on main failed C-security on new code because `.github/workflows/ci.yml` ran `npm install -g tree-sitter-cli@^0.25.0` (unlocked version + lifecycle scripts). Pin 0.25.0 and pass `--ignore-scripts` on every tree-sitter-cli install; same flag on the vscode-smoke `npm install`. #56 PR 4 (the original Window/Document/utility inventory): - stdlib/Dom.affine externs for addEventListener/matchMedia/windowOpen/ innerWidth/Height/devicePixelRatio/setTimeout/Interval/clear*, createElement/getElementById/body* / Date.now / Number.isFinite/isNaN / btoa - Deno-ESM `__as_dom*` lowerings + builtins - tests/codegen-deno/dom_window_smoke.{affine,harness.mjs} Native-preview2 (#486 one-way door) is not in this commit. Refs #56 --- .github/workflows/ci.yml | 17 +++-- docs/CAPABILITY-MATRIX.adoc | 5 +- docs/ECOSYSTEM.adoc | 3 +- docs/TECH-DEBT.adoc | 9 ++- lib/codegen_deno.ml | 68 ++++++++++++++++++ stdlib/Dom.affine | 44 ++++++++++++ tests/codegen-deno/dom_window_smoke.affine | 50 +++++++++++++ .../codegen-deno/dom_window_smoke.harness.mjs | 72 +++++++++++++++++++ 8 files changed, 258 insertions(+), 10 deletions(-) create mode 100644 tests/codegen-deno/dom_window_smoke.affine create mode 100644 tests/codegen-deno/dom_window_smoke.harness.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 356ae92e..512c85ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,7 +77,10 @@ jobs: # `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 @@ -238,7 +241,10 @@ jobs: # 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 @@ -303,7 +309,7 @@ jobs: # 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 - name: Report adapter availability working-directory: editors/vscode # Surface the @hyperpolymath/affine-vscode publish state in the @@ -348,7 +354,10 @@ jobs: # 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 diff --git a/docs/CAPABILITY-MATRIX.adoc b/docs/CAPABILITY-MATRIX.adoc index ae40678f..9986c6d8 100644 --- a/docs/CAPABILITY-MATRIX.adoc +++ b/docs/CAPABILITY-MATRIX.adoc @@ -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 diff --git a/docs/ECOSYSTEM.adoc b/docs/ECOSYSTEM.adoc index 41a6f413..19bce1b3 100644 --- a/docs/ECOSYSTEM.adoc +++ b/docs/ECOSYSTEM.adoc @@ -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 diff --git a/docs/TECH-DEBT.adoc b/docs/TECH-DEBT.adoc index bc90c4ba..7404b2dd 100644 --- a/docs/TECH-DEBT.adoc +++ b/docs/TECH-DEBT.adoc @@ -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::[] @@ -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 diff --git a/lib/codegen_deno.ml b/lib/codegen_deno.ml index 06e5011f..1f3a4640 100644 --- a/lib/codegen_deno.ml +++ b/lib/codegen_deno.ml @@ -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; @@ -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 diff --git a/stdlib/Dom.affine b/stdlib/Dom.affine index 278b840a..40e673cf 100644 --- a/stdlib/Dom.affine +++ b/stdlib/Dom.affine @@ -14,6 +14,8 @@ module Dom; +use Deno::{Json}; + pub enum VNode { VText(String), VElem(String, [(String, String)], [VNode]) @@ -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; diff --git a/tests/codegen-deno/dom_window_smoke.affine b/tests/codegen-deno/dom_window_smoke.affine new file mode 100644 index 00000000..ae2e8520 --- /dev/null +++ b/tests/codegen-deno/dom_window_smoke.affine @@ -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("

hi

"); + 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) +} diff --git a/tests/codegen-deno/dom_window_smoke.harness.mjs b/tests/codegen-deno/dom_window_smoke.harness.mjs new file mode 100644 index 00000000..6df85549 --- /dev/null +++ b/tests/codegen-deno/dom_window_smoke.harness.mjs @@ -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"]); +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, "

hi

"); +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], "

hi

"); + +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");