Skip to content

Commit e4f8fbf

Browse files
feat(stdlib): Deno scripting surface — walk/args/exit/iso/regex/stderr (step #242) (#445)
## Summary Adds six externs to `stdlib/Deno.affine` to unblock the TS→AffineScript estate migration: - `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 by surveying the campaign #239 step 2 tail-batch-1 candidates: 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. ## Lowering - `walkRecursive`, `regexMatch` get tiny prelude shims (`__as_walkRecursive` recurses through `Deno.readDirSync`; `__as_regexMatch` wraps `RegExp.test`). - `args`, `exit`, `dateNowIso`, `consoleError` lower directly to host expressions. ## Test plan - [x] `dune build` — green - [x] New fixture `tests/codegen-deno/deno_scripting.{affine,harness.mjs}` — in-memory FS for walk, captured stderr, intercepted exit. New harness passes. - [x] All 12 codegen-deno harnesses pass - [x] `dune runtest` — 346 tests green ## Refs - Refs hyperpolymath/standards#242 (STEP 3 — STDLIB FILL) - Refs hyperpolymath/standards#239 (campaign umbrella) ## Notes This is the first-cut for step 3. Other gaps not addressed here (HTTP server, `fetch`, `crypto.subtle`, `Deno.Command`, recursive-stat with `isFile`/`isDirectory` flags) remain on the step-3 docket — they block heavier ports (mock-echidna, civic-connect services, dotfiles health host) which are out of scope for tail-batch-1. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 9f12643 commit e4f8fbf

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)