From 13ed98ff3e0875abd6177dbd44a2fdb3cba0369c Mon Sep 17 00:00:00 2001 From: Anton Date: Thu, 10 Sep 2026 17:01:37 +0200 Subject: [PATCH 1/2] fix(load): name the file and the OS cause when a script cannot be loaded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ray_load_file_fn returned a bare `io` for every failure — open, stat, map — so a missing dependency in a startup script printed `error: io`, which reads like an IPC, journal or storage failure and sends diagnosis the wrong way. Each failure now says which step failed on which file and why: `error: io: load "missing/x.rfl": No such file or directory`, `... : is a directory`, `... : cannot map N bytes: ...`. Both the mmap and the Windows fread branches. Closes #505 Claude-Session: https://claude.ai/code/session_01J4QKJW3RbRP8TQoquJtARg --- src/ops/builtins.c | 19 +++++++++++++------ test/rfl/system/load_errors.rfl | 15 +++++++++++++++ 2 files changed, 28 insertions(+), 6 deletions(-) create mode 100644 test/rfl/system/load_errors.rfl diff --git a/src/ops/builtins.c b/src/ops/builtins.c index 7e0c4c17..07f32199 100644 --- a/src/ops/builtins.c +++ b/src/ops/builtins.c @@ -2090,11 +2090,11 @@ ray_t* ray_load_file_fn(ray_t* path_obj) { #if defined(RAY_OS_WINDOWS) /* Windows: fall back to fread */ FILE* fp = fopen(path, "r"); - if (!fp) return ray_error("io", NULL); + if (!fp) return ray_error("io", "load \"%s\": %s", path, strerror(errno)); fseek(fp, 0, SEEK_END); long sz = ftell(fp); fseek(fp, 0, SEEK_SET); - if (sz < 0) { fclose(fp); return ray_error("io", NULL); } + if (sz < 0) { int e = errno; fclose(fp); return ray_error("io", "load \"%s\": cannot determine size: %s", path, strerror(e)); } if (sz == 0) { fclose(fp); return ray_i64(0); } char* buf = (char*)ray_alloc_raw((size_t)sz + 1); if (!buf) { fclose(fp); return ray_error("oom", NULL); } @@ -2116,15 +2116,22 @@ ray_t* ray_load_file_fn(ray_t* path_obj) { ray_free_raw(buf); return result; #else + /* Every failure names the file and the OS cause: a bare `io` from a + * script's load is indistinguishable from an IPC, journal or storage + * failure and sends diagnosis the wrong way (#505). */ int fd = open(path, O_RDONLY); - if (fd < 0) return ray_error("io", NULL); + if (fd < 0) return ray_error("io", "load \"%s\": %s", path, strerror(errno)); struct stat st; - if (fstat(fd, &st) < 0 || st.st_size < 0) { close(fd); return ray_error("io", NULL); } + if (fstat(fd, &st) < 0 || st.st_size < 0) { + int e = errno; close(fd); + return ray_error("io", "load \"%s\": cannot stat: %s", path, strerror(e)); + } + if (S_ISDIR(st.st_mode)) { close(fd); return ray_error("io", "load \"%s\": is a directory", path); } size_t sz = (size_t)st.st_size; if (sz == 0) { close(fd); return ray_i64(0); } char* map = (char*)ray_vm_map_fd_ro(fd, sz); - close(fd); - if (!map) return ray_error("io", NULL); + { int e = errno; close(fd); errno = e; } + if (!map) return ray_error("io", "load \"%s\": cannot map %zu bytes: %s", path, sz, strerror(errno)); /* Copy to NUL-terminated buffer -- mmap region may not have a trailing NUL */ char* buf = (char*)ray_alloc_raw(sz + 1); if (!buf) { ray_vm_unmap_file(map, sz); return ray_error("oom", NULL); } diff --git a/test/rfl/system/load_errors.rfl b/test/rfl/system/load_errors.rfl new file mode 100644 index 00000000..f2fdcf82 --- /dev/null +++ b/test/rfl/system/load_errors.rfl @@ -0,0 +1,15 @@ +;; load_errors.rfl — a failing `load` names the file and the OS cause (#505). +;; +;; Regression: ray_load_file_fn returned a bare `io` for every failure, so +;; a missing dependency in a startup script printed `error: io`, which reads +;; like an IPC, journal or storage failure and sent diagnosis the wrong way. +;; The harness compares error codes only, so the message is checked on a +;; subprocess's stderr. +(load "missing/persistent_connection.rfl") !- io +(load "/") !- io +(.sys.exec "rm -rf /tmp/rfl_load_err && mkdir -p /tmp/rfl_load_err && printf '(load \"missing/persistent_connection.rfl\")\n' > /tmp/rfl_load_err/main.rfl") +(.sys.exec "./rayforce /tmp/rfl_load_err/main.rfl &1 | grep -q 'error: io: load \"missing/persistent_connection.rfl\": No such file or directory'") -- 0 +(.sys.exec "printf '(load \"/tmp/rfl_load_err\")\n' > /tmp/rfl_load_err/dir.rfl; ./rayforce /tmp/rfl_load_err/dir.rfl &1 | grep -q 'error: io: load \"/tmp/rfl_load_err\": is a directory'") -- 0 +;; a script that fails to load still exits non-zero +(.sys.exec "./rayforce /tmp/rfl_load_err/main.rfl /dev/null 2>&1; [ $? -ne 0 ]") -- 0 +(.sys.exec "rm -rf /tmp/rfl_load_err") From 4281317cc6ffef4d9b7690a0140d3fc599b23bdf Mon Sep 17 00:00:00 2001 From: Anton Date: Thu, 10 Sep 2026 17:32:52 +0200 Subject: [PATCH 2/2] feat(load): RAYFORCE_HOME fallback for relative paths, and `source` in .sys.args MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `load` resolved a relative path against the working directory only, so a script invoked from its project root and from its own directory could not both find a shared file, and piped or heredoc input — which has no file of its own — had no anchor at all. q answers this with QHOME. A relative load path is now tried against the working directory first and, when that does not exist and RAYFORCE_HOME is set, below the home; an absolute path is used as given; nested loads follow the same rule (relative to the working directory, not to the loading file, as q). A failure names every path tried. The command-line script itself is not subject to the fallback. (.sys.args) gains `source`: the file currently being evaluated — the innermost load, or the command-line script — by the path that was actually opened, so a script can locate its neighbours from any working directory; empty at the REPL, under a pipe, or in a hook or timer outside any file. `file` is the command line's script ($0); `source` follows nested loads ($BASH_SOURCE). The REPL's pseudo-file name is now a shared constant so `source` can recognise it. Closes #506 Claude-Session: https://claude.ai/code/session_01J4QKJW3RbRP8TQoquJtARg --- docs/docs/language/functions.md | 2 +- docs/docs/namespaces/sys.md | 7 +++++ docs/docs/reference/all-functions.md | 2 +- src/app/repl.c | 2 +- src/lang/eval.c | 2 +- src/lang/nfo.h | 4 +++ src/ops/builtins.c | 41 +++++++++++++++++++++++++++- src/ops/system.c | 30 ++++++++++++++++++-- test/rfl/system/load_home.rfl | 40 +++++++++++++++++++++++++++ test/test_runtime.c | 12 ++++++-- 10 files changed, 132 insertions(+), 10 deletions(-) create mode 100644 test/rfl/system/load_home.rfl diff --git a/docs/docs/language/functions.md b/docs/docs/language/functions.md index 9ed26fca..02f76647 100644 --- a/docs/docs/language/functions.md +++ b/docs/docs/language/functions.md @@ -442,7 +442,7 @@ Cross-temporal comparisons are supported: dates, times, and timestamps are all c | `read-bytes` | unary | Read file contents as a `U8` byte vector | `(read-bytes "file.bin")` | | `write` | binary | Write a string to a file | `(write "file.txt" "content")` | | `write-bytes` | binary | Write a `U8` byte vector to a file | `(write-bytes "file.bin" bytes)` | -| `load` | unary | Load and evaluate a Rayfall script | `(load "lib.rfl")` | +| `load` | unary | Load and evaluate a Rayfall script; a relative path is tried against the working directory, then below `RAYFORCE_HOME` | `(load "lib.rfl")` | ## Control Flow diff --git a/docs/docs/namespaces/sys.md b/docs/docs/namespaces/sys.md index 2e50c6ef..3e687f30 100644 --- a/docs/docs/namespaces/sys.md +++ b/docs/docs/namespaces/sys.md @@ -36,10 +36,17 @@ Signature: `(.sys.args)`. Returns the process's command-line arguments as a dict | `querylog` | bool | `-Q` | Query-statistics logging enabled at startup. | | `interactive` | bool | `-i` | Force the REPL after a script. | | `log` | str | `-l` / `-L` | Journal base path; empty if none. | +| `source` | str | — | The file currently being evaluated: the innermost `load`, or the command-line script, by the path that was actually opened. Empty at the REPL, under a pipe, or in a hook or timer that runs outside any file. `file` is the script named on the command line (`$0`); `source` follows nested loads (`$BASH_SOURCE`). | | `user` | dict | after `--` | The application's own arguments. | The top-level schema is **stable** — every launcher key is always present with its effective value (the default when the flag wasn't passed), so `(get (.sys.args) 'port)` never misses. Auth passwords (`-u` / `-U`) are deliberately **not** exposed. +```lisp +;; load a sibling of the running file, whichever directory it was started from +(set here (str-join (drop -1 (split (at (.sys.args) 'source) "/")) "/")) +(load (concat here "/lib.rfl")) +``` + **`user` parsing.** Tokens after `--` are paired `-key value` / `--key value`: a token starting with `-` is a key (leading dashes stripped to a symbol), and the next token is its value — unless that token also starts with `-`, in which case the value is the empty string (a bare flag). Duplicate keys keep the last value. ```lisp diff --git a/docs/docs/reference/all-functions.md b/docs/docs/reference/all-functions.md index 30689f96..a58c4e72 100644 --- a/docs/docs/reference/all-functions.md +++ b/docs/docs/reference/all-functions.md @@ -616,7 +616,7 @@ Printing, file I/O, CSV loading, and script execution. | `read-bytes` | unary | restricted | Read file contents as a `U8` byte vector | `(read-bytes "file.bin")` | | `write` | binary | restricted | Write a string to a file | `(write "file.txt" "content")` | | `write-bytes` | binary | restricted | Write a `U8` byte vector to a file | `(write-bytes "file.bin" bytes)` | -| `load` | unary | restricted | Load and evaluate a Rayfall script file | `(load "lib.rfl")` | +| `load` | unary | restricted | Load and evaluate a Rayfall script file. A relative path is tried against the working directory, then below `RAYFORCE_HOME` when set; absolute paths are used as given; a failure names every path tried | `(load "lib.rfl")` | | `exit` | unary | restricted | Exit the process with status code | `(exit 0)` | | `resolve` | variadic | special | Resolve a symbol in the current scope | `(resolve 'x)` | | `timeit` | variadic | special | Benchmark an expression (prints elapsed time) | `(timeit (sum (til 1000000)))` | diff --git a/src/app/repl.c b/src/app/repl.c index 35cbf4e7..9b386204 100644 --- a/src/app/repl.c +++ b/src/app/repl.c @@ -819,7 +819,7 @@ static void eval_and_print(ray_term_t* term, const char* input, ray_eval_clear_interrupt(); if (term) ray_term_eval_begin(term); - ray_t* nfo = ray_nfo_create("repl", 4, input, strlen(input)); + ray_t* nfo = ray_nfo_create(RAY_NFO_REPL_NAME, strlen(RAY_NFO_REPL_NAME), input, strlen(input)); ray_clear_error_trace(); ray_t* parsed = ray_parse_with_nfo(input, nfo); diff --git a/src/lang/eval.c b/src/lang/eval.c index 81c5ae38..3029e5c0 100644 --- a/src/lang/eval.c +++ b/src/lang/eval.c @@ -4028,7 +4028,7 @@ ray_t* ray_eval(ray_t* obj) { ray_t* ray_eval_str(const char* source) { ray_clear_error_trace(); - ray_t* nfo = ray_nfo_create("repl", 4, source, strlen(source)); + ray_t* nfo = ray_nfo_create(RAY_NFO_REPL_NAME, strlen(RAY_NFO_REPL_NAME), source, strlen(source)); ray_t* parsed = ray_parse_with_nfo(source, nfo); if (RAY_IS_ERR(parsed)) { ray_release(nfo); return parsed; } diff --git a/src/lang/nfo.h b/src/lang/nfo.h index fe42050d..8dbcf77c 100644 --- a/src/lang/nfo.h +++ b/src/lang/nfo.h @@ -49,6 +49,10 @@ typedef union ray_span_t { * [3] vals (RAY_I64 vector — span ids) */ +/* Filename an nfo carries for input that came from no file — the REPL, + * ray_eval_str, a pipe. (.sys.args) reports `source` as empty for it. */ +#define RAY_NFO_REPL_NAME "repl" + #define NFO_FILENAME(nfo) ray_list_get((nfo), 0) #define NFO_SOURCE(nfo) ray_list_get((nfo), 1) #define NFO_KEYS(nfo) ray_list_get((nfo), 2) diff --git a/src/ops/builtins.c b/src/ops/builtins.c index 07f32199..dac15134 100644 --- a/src/ops/builtins.c +++ b/src/ops/builtins.c @@ -2080,16 +2080,50 @@ ray_t* ray_read_bytes_fn(ray_t* path_obj) { return read_file_bytes(path_obj, "read-bytes"); } -/* (load path) — read and evaluate a Rayfall script file via mmap */ +static bool load_path_is_absolute(const char* p) { + if (p[0] == '/' || p[0] == '\\') return true; +#if defined(RAY_OS_WINDOWS) + if (((p[0] >= 'A' && p[0] <= 'Z') || (p[0] >= 'a' && p[0] <= 'z')) && p[1] == ':') return true; +#endif + return false; +} + +/* Where a relative load path is looked for after the working directory: + * below $RAYFORCE_HOME, q's QHOME fallback (#506). Fills `alt` with the + * candidate and returns true when a home is set and the path is relative. */ +static bool load_home_candidate(const char* path, char* alt, size_t cap) { + if (load_path_is_absolute(path)) return false; + const char* home = getenv("RAYFORCE_HOME"); + if (!home || !*home) return false; + size_t hl = strlen(home); + while (hl > 1 && (home[hl - 1] == '/' || home[hl - 1] == '\\')) hl--; + int n = snprintf(alt, cap, "%.*s/%s", (int)hl, home, path); + return n > 0 && (size_t)n < cap; +} + +/* (load path) — read and evaluate a Rayfall script file via mmap. + * + * A relative path is resolved against the working directory first and, + * when that does not exist and RAYFORCE_HOME is set, below the home; + * an absolute path is used as given. Nested loads follow the same rule + * (relative to the working directory, not to the loading file, as q + * does). The path that was actually opened is what (.sys.args) + * reports as `source` while the file runs. */ ray_t* ray_load_file_fn(ray_t* path_obj) { if (path_obj->type != -RAY_STR) return ray_error("type", "load: path must be str, got %s", ray_type_name(path_obj->type)); const char* path = ray_str_ptr(path_obj); if (!path) return ray_error("domain", "load: empty path"); size_t path_len = ray_str_len(path_obj); + char alt[4096]; #if defined(RAY_OS_WINDOWS) /* Windows: fall back to fread */ FILE* fp = fopen(path, "r"); + if (!fp && errno == ENOENT && load_home_candidate(path, alt, sizeof(alt))) { + fp = fopen(alt, "r"); + if (!fp) return ray_error("io", "load \"%s\": %s (also tried \"%s\")", path, strerror(ENOENT), alt); + path = alt; path_len = strlen(alt); + } if (!fp) return ray_error("io", "load \"%s\": %s", path, strerror(errno)); fseek(fp, 0, SEEK_END); long sz = ftell(fp); @@ -2120,6 +2154,11 @@ ray_t* ray_load_file_fn(ray_t* path_obj) { * script's load is indistinguishable from an IPC, journal or storage * failure and sends diagnosis the wrong way (#505). */ int fd = open(path, O_RDONLY); + if (fd < 0 && errno == ENOENT && load_home_candidate(path, alt, sizeof(alt))) { + fd = open(alt, O_RDONLY); + if (fd < 0) return ray_error("io", "load \"%s\": %s (also tried \"%s\")", path, strerror(ENOENT), alt); + path = alt; path_len = strlen(alt); + } if (fd < 0) return ray_error("io", "load \"%s\": %s", path, strerror(errno)); struct stat st; if (fstat(fd, &st) < 0 || st.st_size < 0) { diff --git a/src/ops/system.c b/src/ops/system.c index 6341127e..3f02642e 100644 --- a/src/ops/system.c +++ b/src/ops/system.c @@ -24,6 +24,7 @@ #include "lang/internal.h" #include "lang/env.h" #include "lang/eval.h" /* LAMBDA_PARAMS */ +#include "lang/nfo.h" /* NFO_FILENAME */ #include "lang/parse.h" #include "ops/ops.h" /* ray_is_lazy, ray_lazy_materialize */ #include "ops/internal.h" /* ray_group_perpart_runs — (.sys.mem) counter */ @@ -1372,12 +1373,37 @@ ray_t* ray_build_sys_args(int argc, char** argv) { } /* (.sys.args) -- return the application-arguments dict (empty if unset) */ +/* The launcher dict is built once at startup; `source` is the one key + * that changes while the process runs — the file currently being + * evaluated (the innermost `load`, or the command-line script), by the + * path that was actually opened, so a script can locate its neighbours + * from any working directory (#506). Empty at the REPL, under a pipe, + * or in a hook or timer outside any file — the same convention as + * `file`. bash's $BASH_SOURCE next to $0. */ ray_t* ray_sys_args_fn(ray_t** args, int64_t n) { (void)args; if (n != 0) return ray_error("domain", ".sys.args takes no arguments"); ray_t* d = (ray_t*)ray_runtime_get_sys_args(); - if (d) { ray_retain(d); return d; } - return ray_dict_new(ray_sym_vec_new(RAY_SYM_W64, 0), ray_list_new(0)); + ray_t* keys; ray_t* vals; + if (d) { + keys = ray_dict_keys(d); ray_retain(keys); /* append COWs the shared vectors */ + vals = ray_dict_vals(d); ray_retain(vals); + } else { + keys = ray_sym_vec_new(RAY_SYM_W64, 1); + vals = ray_list_new(1); + } + ray_t* nfo = ray_eval_get_nfo(); + ray_t* src = (nfo && !RAY_IS_ERR(nfo)) ? NFO_FILENAME(nfo) : NULL; + bool is_file = src && !RAY_IS_ERR(src) && src->type == -RAY_STR && + !(ray_str_len(src) == strlen(RAY_NFO_REPL_NAME) && + memcmp(ray_str_ptr(src), RAY_NFO_REPL_NAME, ray_str_len(src)) == 0); + ray_t* sv = is_file ? (ray_retain(src), src) : ray_str("", 0); + int64_t k = ray_sym_intern("source", 6); + keys = ray_vec_append(keys, &k); + if (RAY_IS_ERR(keys)) { ray_release(vals); ray_release(sv); return keys; } + vals = ray_list_append(vals, sv); ray_release(sv); + if (RAY_IS_ERR(vals)) { ray_release(keys); return vals; } + return ray_dict_new(keys, vals); } /* ══════════════════════════════════════════ diff --git a/test/rfl/system/load_home.rfl b/test/rfl/system/load_home.rfl new file mode 100644 index 00000000..4ce8575e --- /dev/null +++ b/test/rfl/system/load_home.rfl @@ -0,0 +1,40 @@ +;; load_home.rfl — relative `load` paths fall back to RAYFORCE_HOME, and +;; (.sys.args) reports the file being evaluated as `source` (#506). +;; +;; Layout: a project at /tmp/rfl_lh/proj and a home at /tmp/rfl_lh/home. +;; proj/main.rfl loads "lib/a.rfl" (only in home) and "lib/b.rfl" +;; (in both: the working directory must win) +;; home/lib/a.rfl records its own `source`, then loads "lib/c.rfl" +;; home/lib/c.rfl records its `source` +;; proj/lib/b.rfl sets b_from 'proj; home/lib/b.rfl sets b_from 'home +;; Each case runs a subprocess from the project directory. +(.sys.exec "rm -rf /tmp/rfl_lh && mkdir -p /tmp/rfl_lh/proj/lib /tmp/rfl_lh/home/lib") +(.sys.exec "printf '%s\n' '(set a_src (at (.sys.args) (quote source)))' '(load \"lib/c.rfl\")' '(set a_src_after (at (.sys.args) (quote source)))' > /tmp/rfl_lh/home/lib/a.rfl") +(.sys.exec "printf '%s\n' '(set c_src (at (.sys.args) (quote source)))' > /tmp/rfl_lh/home/lib/c.rfl") +(.sys.exec "printf '%s\n' '(set b_from (quote home))' > /tmp/rfl_lh/home/lib/b.rfl") +(.sys.exec "printf '%s\n' '(set b_from (quote proj))' > /tmp/rfl_lh/proj/lib/b.rfl") +(.sys.exec "printf '%s\n' '(load \"lib/a.rfl\")' '(load \"lib/b.rfl\")' '(println (str-join (list a_src c_src a_src_after (as (quote str) b_from) (at (.sys.args) (quote source)) (at (.sys.args) (quote file))) \"|\"))' > /tmp/rfl_lh/proj/main.rfl") + +;; home fallback, nested load, source restored after a nested load, +;; working directory wins for b, top-level source == file +(.sys.exec "cd /tmp/rfl_lh/proj && RAYFORCE_HOME=/tmp/rfl_lh/home $OLDPWD/rayforce main.rfl &1 | grep -qx '/tmp/rfl_lh/home/lib/a.rfl|/tmp/rfl_lh/home/lib/c.rfl|/tmp/rfl_lh/home/lib/a.rfl|proj|main.rfl|main.rfl'") -- 0 + +;; a trailing slash on the home is fine +(.sys.exec "cd /tmp/rfl_lh/proj && RAYFORCE_HOME=/tmp/rfl_lh/home/ $OLDPWD/rayforce main.rfl &1 | grep -q '^/tmp/rfl_lh/home/lib/a.rfl|'") -- 0 + +;; without a home the same script fails, naming only the working-directory path +(.sys.exec "cd /tmp/rfl_lh/proj && $OLDPWD/rayforce main.rfl &1 | grep -q 'error: io: load \"lib/a.rfl\": No such file or directory$'") -- 0 + +;; missing in both places: both attempts are named +(.sys.exec "printf '(load \"lib/zz.rfl\")\n' > /tmp/rfl_lh/proj/miss.rfl; cd /tmp/rfl_lh/proj && RAYFORCE_HOME=/tmp/rfl_lh/home $OLDPWD/rayforce miss.rfl &1 | grep -q 'error: io: load \"lib/zz.rfl\": No such file or directory (also tried \"/tmp/rfl_lh/home/lib/zz.rfl\")'") -- 0 + +;; an absolute path never consults the home +(.sys.exec "printf '(load \"/tmp/rfl_lh/nowhere.rfl\")\n' > /tmp/rfl_lh/proj/abs.rfl; cd /tmp/rfl_lh/proj && RAYFORCE_HOME=/tmp/rfl_lh/home $OLDPWD/rayforce abs.rfl &1 | grep -q 'error: io: load \"/tmp/rfl_lh/nowhere.rfl\": No such file or directory$'") -- 0 + +;; under a pipe there is no file: source is empty, and so is file +(.sys.exec "printf '(println (count (at (.sys.args) (quote source))))\n' | ./rayforce -i 2>&1 | grep -qx 0") -- 0 + +;; in-process: this harness runs each .rfl through eval, not load, so +;; source is whatever the harness set — only the key's presence is pinned +(type (at (.sys.args) 'source)) -- 'str +(.sys.exec "rm -rf /tmp/rfl_lh") diff --git a/test/test_runtime.c b/test/test_runtime.c index 1a4049b4..3c07b991 100644 --- a/test/test_runtime.c +++ b/test/test_runtime.c @@ -595,15 +595,21 @@ static test_result_t test_build_sys_args_edges(void) { PASS(); } -/* .sys.args builtin: empty when unset; reflects stored dict when set */ +/* .sys.args builtin: only `source` when unset; reflects stored dict when set */ static test_result_t test_sys_args_builtin(void) { - /* unset → empty dict, never NULL */ + /* unset → a dict holding just `source` (empty here: ray_eval_str is + * no file, #506), never NULL */ ray_t* e = ray_eval_str("(.sys.args)"); TEST_ASSERT_NOT_NULL(e); TEST_ASSERT_FALSE(RAY_IS_ERR(e)); TEST_ASSERT_EQ_I(e->type, RAY_DICT); - TEST_ASSERT_EQ_I(ray_dict_len(e), 0); + TEST_ASSERT_EQ_I(ray_dict_len(e), 1); ray_release(e); + ray_t* src = ray_eval_str("(count (at (.sys.args) 'source))"); + TEST_ASSERT_NOT_NULL(src); + TEST_ASSERT_FALSE(RAY_IS_ERR(src)); + TEST_ASSERT_EQ_I(src->i64, 0); + ray_release(src); /* set, then read back through the builtin */ char* argv[] = { "rayforce", "-p", "5000", "--", "-opt", "123" };