Skip to content

Commit 4e2d263

Browse files
feat(stdlib/bun): bun_run_capture — captured child-process run (#781)
<!-- DRAFT (2026-10-08). PR body for hyperpolymath/affinescript, branch feat/bun-run-capture, worktree developer/worktrees/affinescript-run-capture. Uncommitted until the owner affirms with `git commit -S`. --> ## Summary Adds a **captured** child-process run to the native Bun-ESM host profile. `bun_run` inherits stdio, so it can return only an exit code. Tools that need a program's output (git, scanners) have had no way to read it. - `stdlib/Bun.affine`: new record `RunResult { status: Int, stdout: String, stderr: String }` and new extern `bun_run_capture(command, arguments, cwd: Option<String>) -> RunResult`. `bun_run` is unchanged. - `lib/codegen_deno.ml` (`bun_builtins`): lowers the call to `spawnSync(cmd, args, { encoding: "utf8", stdio: ["ignore","pipe","pipe"], cwd })`. - Stdin is closed, so a child that reads stdin cannot hang the caller. - A host spawn error has no status. It maps to status 1, with the host's message appended to `stderr`. This matches `bun_run`'s `?? 1`. - `tests/codegen-bun/host_profile.{affine,harness.mjs}`: three planted known-answer controls. This is the first stdlib gap blocking SemGrip, an AffineScript CLI around Semgrep and Opengrep (design: `dev-notes/projects/semgrip/PLAN.adoc`, step A1). Closes # <!-- no issue filed; findings go to the inbox, not new issues (AGENTS.md §5e) --> ## Type of change - [ ] 🐛 Bug fix: not applicable - [x] ✨ New feature (non-breaking; a new extern and type, with `bun_run` untouched) - [ ] 💥 Breaking change: no existing signature changes - [ ] 🕳️ Soundness fix: not applicable - [ ] 📖 Documentation: only the doc comments on the new items - [ ] 🧹 Refactor: not applicable - [ ] ⚡ Performance: not applicable - [ ] 🔧 Build / CI / tooling: no workflow changes ## 📌 New pins Head SHA: **<fill in after the owner's signed commit>** (base `e8f6a71e7ead38396ee651f351533bd5789c813a`). This PR adds and changes no pins: no `uses:` refs, `actions.lock` entries, lockfiles or digests. ## How has this been verified? All runs were in `worktrees/affinescript-run-capture`, with dune in opam switch `as-sys` (OCaml 5.3.0) and Bun 1.4.2. - `dune build bin/main.exe` succeeded; the only output was the existing Menhir conflict warnings. - `bash tools/run_codegen_bun_tests.sh` reported `All native Bun-ESM tests passed (1 harnesses)`. It checks compilation, absence of legacy runtime references, `bun --check`, reproducible emission, and the harness, which includes the new controls: - `sh -c 'echo hi; echo err >&2; exit 3'` gives status `3`, stdout `"hi\n"` and stderr `"err\n"`. - `pwd` run with `cwd = Some(dir)` prints `dir`. - A missing program gives status `1`, with stderr naming the program. Bun's text is `Executable not found in $PATH: "…"`, not Node's `ENOENT`. - **Mutant killed:** forcing the lowering's `stdout` to `""` made the harness fail with `capture stdout failed: ""`. Restoring it turned the suite green again. - `dune test`: the compiler's alcotest suite reported 562 tests, all OK. `tools/res-to-affine/test` fails in a fresh worktree because `tools/vendor/tree-sitter-rescript/src/parser.c` is absent (`just install-grammar` was not run). That failure has nothing to do with this change. ## Checklist - [ ] My commits are **signed** (`git commit -S`). The owner signs on affirmation; the agent did not commit. - [x] I ran the project's own checks/tests locally and they pass (see above, including the res-to-affine grammar caveat). - [x] New files carry the correct SPDX identifier. No new files: all four edits are to existing MPL-2.0 files, and no file was relicensed. - [x] Docs are updated, and no public claim overstates what the code does. Doc comments describe the status-1 mapping and closed stdin. - [x] I have not introduced a soundness hole. The extern is host-only and typed by its declaration. One limitation: as with every extern (#245), a host mismatch is caught only at runtime. ## Notes for reviewers - `RunResult` is a plain JS object, the same shape as the `Http.Response` record. `cwd` uses the standard `{ tag, value }` Option shape. - Output size is bounded by the host `spawnSync` buffer. Exceeding it surfaces as status 1 with an `ENOBUFS`-style message in `stderr`, never as silent truncation. Callers with large output should write it to a file, as SemGrip does with `--json-output=`. - Only the Bun profile gets a lowering. Under other backends, `bun_`-prefixed externs are rejected exactly as `bun_run` is. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01GVm3eGp5kZNPBDfd1tVJVD
1 parent e8f6a71 commit 4e2d263

4 files changed

Lines changed: 54 additions & 1 deletion

File tree

‎lib/codegen_deno.ml‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1339,6 +1339,18 @@ let () =
13391339
Printf.sprintf
13401340
"(__as_childProcess().spawnSync(%s, %s, { stdio: \"inherit\" }).status ?? 1)"
13411341
(arg 0 a) (arg 1 a));
1342+
(* Captured run: stdin closed, stdout/stderr piped as UTF-8. [cwd] is an
1343+
AffineScript Option ({ tag, value }). A host spawn error (ENOENT,
1344+
ENOBUFS) has no status, so it maps to 1 with its message on stderr. *)
1345+
b "bun_run_capture"
1346+
(fun a ->
1347+
Printf.sprintf
1348+
"((__r) => ({ status: __r.status ?? 1, stdout: __r.stdout ?? \"\", \
1349+
stderr: (__r.stderr ?? \"\") + (__r.error ? String(__r.error.message) : \"\") }))\
1350+
(__as_childProcess().spawnSync(%s, %s, { encoding: \"utf8\", \
1351+
stdio: [\"ignore\", \"pipe\", \"pipe\"], \
1352+
cwd: ((__c) => __c.tag === \"Some\" ? __c.value : undefined)(%s) }))"
1353+
(arg 0 a) (arg 1 a) (arg 2 a));
13421354
b "bun_stdin_text"
13431355
(fun _ -> "__as_fs().readFileSync(0, \"utf8\")");
13441356
b "bun_stdout_write"

‎stdlib/Bun.affine‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,23 @@ pub extern fn bun_env_get(name: String) -> Option<String>;
1616
/// its exit status (or 1 when the host supplies no status).
1717
pub extern fn bun_run(command: String, arguments: [String]) -> Int;
1818

19+
/// The captured outcome of a child process: its exit status (1 when the host
20+
/// supplies none, e.g. on a signal or a spawn failure) and its standard output
21+
/// and standard error decoded as UTF-8.
22+
pub type RunResult = {
23+
status: Int,
24+
stdout: String,
25+
stderr: String
26+
}
27+
28+
/// Run a child process synchronously and capture its standard output and
29+
/// standard error instead of inheriting them. Standard input is closed.
30+
/// `cwd` sets the child's working directory; `None` keeps the caller's. A
31+
/// spawn failure (missing program, output over the host buffer limit) yields
32+
/// status 1 with the host's error message appended to `stderr`.
33+
pub extern fn bun_run_capture(command: String, arguments: [String],
34+
cwd: Option<String>) -> RunResult;
35+
1936
/// Read standard input to EOF as UTF-8.
2037
pub extern fn bun_stdin_text() -> String;
2138

‎tests/codegen-bun/host_profile.affine‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// SPDX-License-Identifier: MPL-2.0
22
// Bun-ESM host-profile acceptance fixture (issue #734).
33

4-
use Bun::{ bun_env_get, bun_run, bun_stdin_text, bun_stdout_write, bun_stderr_write, bun_exit };
4+
use Bun::{ RunResult, bun_env_get, bun_run, bun_run_capture, bun_stdin_text, bun_stdout_write, bun_stderr_write, bun_exit };
55
use prelude::{Some, None};
66

77
extern type Bytes;
@@ -49,3 +49,14 @@ pub fn first_byte(path: String) -> Int {
4949
let value = readFileBytes(path);
5050
if bytesLength(value) == 0 { 0 } else { bytesByteAt(value, 0) }
5151
}
52+
53+
/// Run `sh -c script` in `cwd` (empty string = inherit) and capture the result.
54+
pub fn run_captured(script: String, cwd: String) -> RunResult {
55+
let dir = if cwd == "" { None } else { Some(cwd) };
56+
bun_run_capture("sh", ["-c", script], dir)
57+
}
58+
59+
/// Capture-run a program that does not exist, to exercise the spawn-error path.
60+
pub fn run_missing_program() -> RunResult {
61+
bun_run_capture("affinescript-no-such-program", [], None)
62+
}

‎tests/codegen-bun/host_profile.harness.mjs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,19 @@ try {
2424
throw new Error("missing environment value failed");
2525
}
2626
if (subject.run_successful_child() !== 0) throw new Error("subprocess lowering failed");
27+
// Captured run (bun_run_capture): planted controls with known answers.
28+
const captured = subject.run_captured("echo hi; echo err >&2; exit 3", "");
29+
if (captured.status !== 3) throw new Error(`capture status failed: ${captured.status}`);
30+
if (captured.stdout !== "hi\n") throw new Error(`capture stdout failed: ${JSON.stringify(captured.stdout)}`);
31+
if (captured.stderr !== "err\n") throw new Error(`capture stderr failed: ${JSON.stringify(captured.stderr)}`);
32+
const inDir = subject.run_captured("pwd", nested);
33+
if (inDir.status !== 0 || inDir.stdout.trim() !== nested) {
34+
throw new Error(`capture cwd failed: ${JSON.stringify(inDir)}`);
35+
}
36+
const missing = subject.run_missing_program();
37+
if (missing.status !== 1 || !missing.stderr.includes("affinescript-no-such-program")) {
38+
throw new Error(`capture spawn-error failed: ${JSON.stringify(missing)}`);
39+
}
2740
subject.remove_path(file);
2841
let missingPathThrew = false;
2942
try {

0 commit comments

Comments
 (0)