From ab5ded050777b6bbaa83c594fbe4a865a7b20dfa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 21:36:00 +0000 Subject: [PATCH] feat(bun): pathExists, a total existence check for the Bun host profile statIsFile and statIsDirectory throw ENOENT on a missing path, and host_profile pins that contract, so a program has no way to ask "is it there?" without crashing on the answer "no". kyX (metadatastician/kyx) needs exactly that before reading its inputs and writing its outputs, to exit with its documented usage status instead of an uncaught stack trace. pathExists lowers to fs.existsSync: true for any existing path, false for a missing one, never a throw. The stat predicates are unchanged. It is declared in stdlib/Deno.affine beside them (the builtins<->stdlib consistency test requires the declaration), and host_profile now asserts pathExists is false for the removed file and true for a directory. Tests: tools/run_codegen_bun_tests.sh fails without the builtin and passes with it; dune build @test/runtest is green (570 tests). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0136P7ZnvMV5Ghe7LUf3oc6N --- lib/codegen_deno.ml | 5 +++++ stdlib/Deno.affine | 4 ++++ tests/codegen-bun/host_profile.affine | 2 ++ tests/codegen-bun/host_profile.harness.mjs | 3 +++ 4 files changed, 14 insertions(+) diff --git a/lib/codegen_deno.ml b/lib/codegen_deno.ml index dabbe4fc..09239957 100644 --- a/lib/codegen_deno.ml +++ b/lib/codegen_deno.ml @@ -1326,6 +1326,11 @@ let () = (fun a -> Printf.sprintf "__as_fs().statSync(%s).isFile()" (arg 0 a)); b "statIsDirectory" (fun a -> Printf.sprintf "__as_fs().statSync(%s).isDirectory()" (arg 0 a)); + (* Total existence test: false for a missing path, never a throw. The stat + predicates above keep their ENOENT contract (host_profile pins it); this + is the check to make before them (added for kyX). *) + b "pathExists" + (fun a -> Printf.sprintf "__as_fs().existsSync(%s)" (arg 0 a)); b "args" (fun _ -> "(globalThis.process?.argv?.slice(2) ?? [])"); b "exit" (fun a -> Printf.sprintf "__as_process().exit(%s)" (arg 0 a)); diff --git a/stdlib/Deno.affine b/stdlib/Deno.affine index 15a6cf8f..28693fdc 100644 --- a/stdlib/Deno.affine +++ b/stdlib/Deno.affine @@ -78,6 +78,10 @@ pub extern fn statIsFile(path: String) -> Bool; /// Throws on a missing path (pair with `isNotFound` for the absent case). pub extern fn statIsDirectory(path: String) -> Bool; +/// True if `path` exists (file, directory or other), false if it does not. +/// Never throws: the total check to make before the stat predicates above. +pub extern fn pathExists(path: String) -> Bool; + /// Recursive walk under `root` — every file path beneath it, depth-first. /// Mirrors `std/fs/walk` for the common case (no glob filter; callers /// filter by extension). Throws on a missing root via `Deno.readDirSync`. diff --git a/tests/codegen-bun/host_profile.affine b/tests/codegen-bun/host_profile.affine index 144eba72..c9d3ad60 100644 --- a/tests/codegen-bun/host_profile.affine +++ b/tests/codegen-bun/host_profile.affine @@ -13,6 +13,7 @@ extern fn mkdirRecursive(path: String) -> Int; extern fn statSize(path: String) -> Int; extern fn statIsFile(path: String) -> Bool; extern fn statIsDirectory(path: String) -> Bool; +extern fn pathExists(path: String) -> Bool; extern fn bytesLength(value: Bytes) -> Int; extern fn bytesByteAt(value: Bytes, offset: Int) -> Int; extern fn args() -> [String]; @@ -27,6 +28,7 @@ pub fn make_directory(path: String) -> Int { mkdirRecursive(path) } pub fn file_size(path: String) -> Int { statSize(path) } pub fn is_file(path: String) -> Bool { statIsFile(path) } pub fn is_directory(path: String) -> Bool { statIsDirectory(path) } +pub fn exists(path: String) -> Bool { pathExists(path) } pub fn argument_count() -> Int { len(args()) } pub fn environment_value(name: String) -> String { diff --git a/tests/codegen-bun/host_profile.harness.mjs b/tests/codegen-bun/host_profile.harness.mjs index b8566078..c31aa5e1 100644 --- a/tests/codegen-bun/host_profile.harness.mjs +++ b/tests/codegen-bun/host_profile.harness.mjs @@ -46,6 +46,9 @@ try { missingPathThrew = true; } if (!missingPathThrew) throw new Error("remove did not make path absent"); + // pathExists is the total check: false for the removed file, never a throw. + if (subject.exists(file) !== false) throw new Error("pathExists on a missing path is not false"); + if (subject.exists(nested) !== true) throw new Error("pathExists on a directory is not true"); } finally { rmSync(root, { recursive: true, force: true }); }