Skip to content

Commit b2a1b22

Browse files
committed
feat(stdlib): Deno scripting surface — walk/args/exit/iso/regex/stderr (step #242)
Adds six externs to stdlib/Deno.affine to unblock the TS→AffineScript estate migration (campaign hyperpolymath/standards#239), step 3 (standards#242 — STDLIB FILL): - walkRecursive(root) -> [String] (depth-first file walk) - args() -> [String] (Deno.args) - exit(code) -> Int (Deno.exit; never returns) - dateNowIso() -> String (new Date().toISOString()) - consoleError(s) -> Int (stderr; returns 0) - regexMatch(s, pat) -> Bool (new RegExp(pat).test(s)) These were surfaced as the minimum-viable set by a survey of tail-batch-1 candidates (standards#241): two pure-logic Deno CLI scripts (panic-attack docs/campaigns/2026-05-26/01-triage.ts and standards/scripts/check-ts-allowlist.ts) become portable using only the existing Deno.affine surface plus these six additions. Other tail-batch candidates remain blocked on fetch / HTTP-server / crypto plumbing which is out of scope here. Lowering: - walkRecursive, regexMatch get tiny prelude shims (__as_walkRecursive recurses through Deno.readDirSync; __as_regexMatch wraps RegExp.test). - args, exit, dateNowIso, consoleError lower direct to host expressions (Deno.args / Deno.exit(c) / new Date().toISOString() / (console.error(s), 0)). Test: tests/codegen-deno/deno_scripting.{affine,harness.mjs} stubs the filesystem (in-memory fs for walk), captures stderr writes, and intercepts Deno.exit so the harness completes. All 12 codegen-deno harnesses pass; full dune runtest = 346 green. Refs hyperpolymath/standards#242 Refs hyperpolymath/standards#239
1 parent e2b33d7 commit b2a1b22

4 files changed

Lines changed: 184 additions & 0 deletions

File tree

lib/codegen_deno.ml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,19 @@ const __as_readDirNames = (p) => {
155155
return names;
156156
};
157157
const __as_isNotFound = (e) => (e instanceof Deno.errors.NotFound);
158+
const __as_walkRecursive = (root) => {
159+
const out = [];
160+
const rec = (dir) => {
161+
for (const entry of Deno.readDirSync(dir)) {
162+
const full = (dir.endsWith("/") ? dir : dir + "/") + entry.name;
163+
if (entry.isFile) out.push(full);
164+
else if (entry.isDirectory) rec(full);
165+
}
166+
};
167+
rec(root);
168+
return out;
169+
};
170+
const __as_regexMatch = (s, pat) => new RegExp(pat).test(String(s));
158171
const __as_wasmInstance = (bytes) =>
159172
new WebAssembly.Instance(new WebAssembly.Module(bytes)).exports;
160173
const __as_wasmCall = (exports, name, args) => Number(exports[name](...(args || [])));
@@ -400,6 +413,22 @@ let () =
400413
b "kbString" (fun a -> Printf.sprintf "(Number(%s) / 1024).toFixed(2)" (arg 0 a));
401414
(* ---- misc host ---- *)
402415
b "dateNow" (fun _ -> "Date.now()");
416+
(* `new Date().toISOString()` — UTC ISO-8601 timestamp string. Distinct
417+
from `dateNow()` which returns epoch millis as Int. *)
418+
b "dateNowIso" (fun _ -> "(new Date().toISOString())");
419+
(* `Deno.args` — CLI argument vector (excludes argv[0]). *)
420+
b "args" (fun _ -> "Deno.args");
421+
(* `Deno.exit(code)` — terminate process. Never returns. *)
422+
b "exit" (fun a -> Printf.sprintf "Deno.exit(%s)" (arg 0 a));
423+
(* `console.error(s)` — stderr write. Returns 0 for chaining; the
424+
comma-expression preserves the AffineScript Int contract. *)
425+
b "consoleError" (fun a -> Printf.sprintf "(console.error(%s), 0)" (arg 0 a));
426+
(* Recursive file walk — depth-first, returns every file path under
427+
`root`. Filtering by extension is the caller's responsibility. *)
428+
b "walkRecursive" (fun a -> Printf.sprintf "__as_walkRecursive(%s)" (arg 0 a));
429+
(* `new RegExp(pat).test(s)` — minimal regex surface. Invalid `pat`
430+
throws at call time (RegExp constructor error). *)
431+
b "regexMatch" (fun a -> Printf.sprintf "__as_regexMatch(%s, %s)" (arg 0 a) (arg 1 a));
403432
b "wasmInstance" (fun a -> Printf.sprintf "__as_wasmInstance(%s)" (arg 0 a));
404433
b "wasmCall" (fun a -> Printf.sprintf "__as_wasmCall(%s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a));
405434
(* ---- motion (bindings #4) ---- *)

stdlib/Deno.affine

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,11 @@ pub extern fn readDirNames(path: String) -> [String];
6868
/// `Deno.statSync(path).size` in bytes.
6969
pub extern fn statSize(path: String) -> Int;
7070

71+
/// Recursive walk under `root` — every file path beneath it, depth-first.
72+
/// Mirrors `std/fs/walk` for the common case (no glob filter; callers
73+
/// filter by extension). Throws on a missing root via `Deno.readDirSync`.
74+
pub extern fn walkRecursive(root: String) -> [String];
75+
7176
// ── Path ───────────────────────────────────────────────────────────
7277

7378
/// Single-segment join with a `/` separator (idempotent on a trailing
@@ -111,6 +116,34 @@ pub extern fn kbString(bytes: Int) -> String;
111116
/// `Date.now()` — epoch millis (used for timestamped report names).
112117
pub extern fn dateNow() -> Int;
113118

119+
/// `new Date().toISOString()` — UTC ISO-8601 timestamp string
120+
/// (e.g. `"2026-05-30T12:34:56.789Z"`). Distinct from `dateNow()` which
121+
/// returns epoch millis as `Int`.
122+
pub extern fn dateNowIso() -> String;
123+
124+
// ── CLI ────────────────────────────────────────────────────────────
125+
126+
/// `Deno.args` — command-line arguments (excludes argv[0]).
127+
pub extern fn args() -> [String];
128+
129+
/// `Deno.exit(code)` — terminate the process with `code`. Never returns;
130+
/// the `Int` return type is for type-level compatibility with `if/else`
131+
/// arms that flow through `exit` in their non-returning branch.
132+
pub extern fn exit(code: Int) -> Int;
133+
134+
// ── Diagnostics ────────────────────────────────────────────────────
135+
136+
/// `console.error(s)` — write to stderr. (Use `print`/`println` for
137+
/// stdout.) Returns 0 for chaining.
138+
pub extern fn consoleError(s: String) -> Int;
139+
140+
// ── Regex ──────────────────────────────────────────────────────────
141+
142+
/// `new RegExp(pat).test(s)` — true iff `s` matches the JS regex source
143+
/// `pat`. Minimal regex surface; for extraction or replace, add a
144+
/// specialised extern. Invalid `pat` throws at call time.
145+
pub extern fn regexMatch(s: String, pat: String) -> Bool;
146+
114147
/// `(Number(bytes) / 1024).toFixed(2)` — kilobyte display string.
115148
pub extern fn numToFixed2(bytes: Int) -> String;
116149

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// issue #122 follow-up — Deno-scripting surface (campaign #239 STEP 3 first-cut).
3+
//
4+
// Exercises the new externs added to stdlib/Deno.affine to unblock
5+
// estate TS-script ports: recursive walk, args, exit, ISO timestamp,
6+
// stderr, and minimal regex. The harness stubs `Deno.readDirSync` (for
7+
// walkRecursive), `Deno.args`, `Deno.exit`, and captures `console.error`
8+
// so nothing touches the real process or filesystem.
9+
//
10+
// Signatures stay in primitives (String/[String]/Int/Bool) so the
11+
// fixture is self-contained and consumes only the new lowerings.
12+
13+
use Deno::{ walkRecursive, args, exit, dateNowIso, consoleError, regexMatch };
14+
15+
pub fn count_walked(root: String) -> Int {
16+
let entries = walkRecursive(root);
17+
len(entries)
18+
}
19+
20+
pub fn first_walked(root: String) -> String {
21+
let entries = walkRecursive(root);
22+
if len(entries) == 0 {
23+
return "";
24+
}
25+
return entries[0];
26+
}
27+
28+
pub fn arg_count() -> Int {
29+
let av = args();
30+
len(av)
31+
}
32+
33+
pub fn iso_starts_with_year() -> Bool {
34+
let s = dateNowIso();
35+
// ISO-8601 timestamps lead with a four-digit year, then `-`.
36+
regexMatch(s, "^[0-9]{4}-")
37+
}
38+
39+
pub fn is_pa_code(cat: String) -> Bool {
40+
regexMatch(cat, "^PA[0-9]{3}")
41+
}
42+
43+
pub fn warn_then_zero(msg: String) -> Int {
44+
let _ = consoleError(msg);
45+
return 0;
46+
}
47+
48+
pub fn exit_with(code: Int) -> Int = exit(code);
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// issue #122 follow-up — Node-ESM harness for new Deno-scripting externs.
3+
//
4+
// Stubs only what the new surface needs: a tiny in-memory FS for
5+
// `Deno.readDirSync` (so `walkRecursive` traverses it deterministically),
6+
// `Deno.args` / `Deno.exit`, and captures `console.error`.
7+
8+
import assert from "node:assert/strict";
9+
10+
// ── In-memory FS stub for walkRecursive ─────────────────────────────
11+
// Shape: { "/root": ["a", "b/", ".hidden"], "/root/b": ["c"] }
12+
const fs = {
13+
"/root": [{ name: "a.txt", isFile: true, isDirectory: false },
14+
{ name: "sub", isFile: false, isDirectory: true }],
15+
"/root/sub": [{ name: "b.txt", isFile: true, isDirectory: false },
16+
{ name: "deeper", isFile: false, isDirectory: true }],
17+
"/root/sub/deeper": [{ name: "c.txt", isFile: true, isDirectory: false }],
18+
"/empty": [],
19+
};
20+
21+
globalThis.Deno = globalThis.Deno || {};
22+
globalThis.Deno.readDirSync = (path) => {
23+
const entries = fs[path];
24+
if (!entries) throw new Error(`stub: no such dir ${path}`);
25+
return entries;
26+
};
27+
28+
// args / exit stubs — exit captures the code instead of terminating.
29+
let lastExit = null;
30+
globalThis.Deno.args = ["alpha", "beta", "gamma"];
31+
globalThis.Deno.exit = (code) => { lastExit = code; return code; };
32+
33+
// Capture stderr writes from consoleError.
34+
const stderrLog = [];
35+
const origError = console.error;
36+
console.error = (...a) => { stderrLog.push(a.join(" ")); };
37+
38+
const {
39+
count_walked,
40+
first_walked,
41+
arg_count,
42+
iso_starts_with_year,
43+
is_pa_code,
44+
warn_then_zero,
45+
exit_with,
46+
} = await import("./deno_scripting.deno.js");
47+
48+
// walkRecursive — depth-first across nested dirs.
49+
assert.equal(count_walked("/root"), 3, "walkRecursive finds 3 files (a.txt + b.txt + c.txt)");
50+
assert.equal(first_walked("/root"), "/root/a.txt", "walkRecursive depth-first leading entry");
51+
assert.equal(count_walked("/empty"), 0, "walkRecursive on empty dir returns []");
52+
53+
// Deno.args
54+
assert.equal(arg_count(), 3, "Deno.args lowered as [String]");
55+
56+
// ISO timestamp shape — real new Date(), so just check leading year.
57+
assert.equal(iso_starts_with_year(), true, "dateNowIso starts with 4-digit year");
58+
59+
// Regex
60+
assert.equal(is_pa_code("PA001"), true, "regexMatch matches PA001");
61+
assert.equal(is_pa_code("PA42"), false, "regexMatch rejects PA42 (needs 3 digits)");
62+
assert.equal(is_pa_code("UnsafeCode"), false, "regexMatch rejects bare category name");
63+
64+
// consoleError captured to stderr
65+
assert.equal(warn_then_zero("test warning"), 0, "consoleError returns 0");
66+
assert.equal(stderrLog.length, 1, "consoleError went to stderr");
67+
assert.equal(stderrLog[0], "test warning", "consoleError payload preserved");
68+
69+
// exit captured (doesn't actually terminate the harness because we stubbed it)
70+
exit_with(2);
71+
assert.equal(lastExit, 2, "Deno.exit lowered correctly");
72+
73+
console.error = origError;
74+
console.log("deno_scripting.harness.mjs OK");

0 commit comments

Comments
 (0)