Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 42 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ All notable changes to EigenScript are documented here.

## [Unreleased]

### Breaking changes

- **File resolution is independent of the process working directory (#1056).**
`load_file` and `import` search the containing file's directory, the existing
`eigs_modules` walk, the nearest `eigs.json` project root, then the stdlib
locations. Absolute paths remain as-is. The bare cwd step and one-parent
fallback are removed; consumers using root-relative paths from subdirectory
files need an `eigs.json` at their project root. Nested loads and functions
retain the containing file's directory. Errors identify the roots tried.
Imported top-level `for` bodies now retain their plain `is` bindings in the
module, matching main and load_file; the loop binder remains loop-scoped.
`tools/road_diff.sh` gates all three roads and proves its own failure paths.

### Added

- **`is_file of path` (#1058).** 1 iff the path names a REGULAR file
Expand All @@ -28,7 +41,7 @@ All notable changes to EigenScript are documented here.
hand-written expectations (the one-sided-verifiability class), which is how
#1063's JIT half — an inline `SET_LOCAL` that recorded no history — went
unnoticed. Every corpus program now runs with the JIT off recording a tape,
then replays that tape under the default JIT and under forced OSR
then replays that tape under the default JIT and with the OSR threshold lowered
(`EIGS_JIT_OSR_THRESHOLD=1`); stdout, stderr and exit code must be
byte-identical. A divergence is adjudicated by determinism first (both sides
rerun; a stable pair is the JIT's) and by tape replay second (the
Expand All @@ -42,6 +55,32 @@ All notable changes to EigenScript are documented here.

### Fixed

- **Imported loop locals and runtime `eval` keep their scope and file (#1056).**
A plain `is` inside an imported module's `for` first updates an existing
loop-local, while fresh bindings remain in the module. The original f29
fixtures stopped native compilation at `LOOP_ENV_CLEAR`; their lowered-threshold
runs did not test native writes. Six new `native_*` road fixtures now run
under interpreter, default JIT, and a lowered OSR threshold on x86-64.
The same compiled chunk is exercised with the OSR threshold lowered; both
native arms compile code and must agree. Statistics do not count OSR entries
or prove that the two native arms use distinct entry mechanisms.
For `native_inline` on x86-64: interpreter `scanned=0 compiled=0`, both
native tiers `scanned=2 compiled=1`. Creating a local mid-thunk previously
yielded 19999 instead of 14999; imported inline stores now use the helper
whenever an intervening scope can hold a nearer binding.
Runtime `eval` in a function retains the function's defining directory
during another module's import and `eigs_eval_file`. The embed API now
scopes its directory override to compilation, matching import and load_file.
`tools/embed_roads.py` checks both embed eval APIs and the override during
execution. Its C test reuses a uniquely identified make variant, or builds
from the plain source list when the CLI is standalone (build.sh) or its
variant is ambiguous; ASAN_OPTIONS requests an ASan fallback. It never
relinks the CLI. Selftests cover these layouts, an execution-scope override,
invalid/duplicate native headers, and the lowered threshold reaching the child.
The oracle requires completion after readback and names malformed metadata;
its selftest rejects early-exit snapshot forgery. Generated test modules use
their test file's canonical directory in both in-tree and installed layouts.

- **A descriptor reading observer state of an unrecorded host binding raises
instead of answering a rest value (#1027).** With the #915 gate closed for
the host program (nothing compiled into it reads the observer), a chunk run
Expand Down Expand Up @@ -136,7 +175,7 @@ All notable changes to EigenScript are documented here.
(leaving the call-site ip there resumed the interpreter misaligned — a
constants[-1] read after an OSR'd loop called `adler32`). Pinned by
`tests/test_host_frame_line.eigs`. #1071 was the same stale read seen from
the JIT side (interpreter, JIT and forced-OSR each printed a different wrong
the JIT side (interpreter, default JIT and lowered-threshold JIT each printed a different wrong
host line for `test_sandbox_budget`); the three tiers now agree, and its row
leaves `tests/jit_diff_expected.txt`, which is empty. Found by the AOT's
byte-exact corpus: the compiled program printed the correct line and the VM
Expand Down Expand Up @@ -6479,7 +6518,7 @@ that don't shadow your model, and a `menu_bar` that owns its own z-order.
the *enclosing* loop's body, compiled against the enclosing loop's stack
frame — but the thunk had entered mid-nest at the inner header, so running
that code natively read the wrong (reserved-null / stale) stack slots and
corrupted execution. It surfaced only under forced OSR
corrupted execution. It surfaced only with the OSR threshold lowered
(`EIGS_JIT_OSR_THRESHOLD=1`) on nested-loop, index-heavy programs (e.g. the
dynamics lab's Gauss–Seidel solver) as a nondeterministic `cannot index
num` (a null `INDEX_GET` target), `index must be an integer`, or double
Expand Down
10 changes: 9 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ define AUX_REFRESH
done
endef

.PHONY: all build full http net gfx zlib lib amalgamation tsan test sandbox-intern-test install install-gfx clean coverage coverage-clean fuzz fuzz-run lsp dap jit-smoke embed-smoke embed-smoke-gfx embed-concurrent asan valgrind pgo poison freestanding-check freestanding-libc-diff asan-http asan-gfx nativefn-test print-%
.PHONY: all build full http net gfx zlib lib amalgamation tsan test sandbox-intern-test install install-gfx clean coverage coverage-clean fuzz fuzz-run lsp dap jit-smoke embed-smoke embed-smoke-gfx embed-concurrent asan valgrind pgo poison freestanding-check freestanding-libc-diff asan-http asan-gfx nativefn-test embed-roads print-%

# ---- Per-variant objdir engine (#740) -------------------------------------
# The engine's rules are defined before `all`, so pin the default goal.
Expand Down Expand Up @@ -212,6 +212,14 @@ $(NATIVEFN_TEST): $(NATIVEFN_TEST_OBJ) $(filter-out build/release/main.o build/r
nativefn-test: $(NATIVEFN_TEST)
@echo "Native-fn identity test built: $(NATIVEFN_TEST)"

# #1056: use the same variant as the CLI under test, without relinking it.
ROAD_VARIANT ?= release
EMBED_ROADS_OBJ := $(filter-out build/$(ROAD_VARIANT)/main.o,$(OBJ_$(ROAD_VARIANT)))
build/$(ROAD_VARIANT)/embed_roads: $(SRC_DIR)/embed_roads.c $(EMBED_ROADS_OBJ) $(wildcard $(SRC_DIR)/*.h) Makefile
$(CC) $(FLAGS_$(ROAD_VARIANT)) -I$(SRC_DIR) -o $@ $< $(EMBED_ROADS_OBJ) $(LIBS_$(ROAD_VARIANT))
embed-roads: build/$(ROAD_VARIANT)/embed_roads
@echo "Embed road test built: $<"

full: build/full/eigenscript
$(call RELINK,full)
@echo "EigenScript $(VERSION) (full) built. Binary: $$(du -sh build/full/eigenscript | cut -f1)"
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,15 @@ define double as: # functions take one argument, n
doubled is map of [[1, 2, 3], double] # [2, 4, 6]
```

File loading is relative to the containing file, then the `eigs_modules` walk,
then the nearest `eigs.json` project root, then stdlib locations; absolute paths
are used as-is. There is no process cwd search. The REPL (including piped input)
and the embed API without a file path use their working directory as the base.
Add `eigs.json` at the root
when subdirectory files use root-relative paths. See the exact
[shared import/load_file resolution chain](docs/SPEC.md#modules).


See [docs/STDLIB.md](docs/STDLIB.md) for the full library guide — start at
its **"Finding Things"** index ("I need to..." → module) so you reach for
`stats.median` or `data.df_group_by` instead of hand-rolling it.
Expand Down
11 changes: 7 additions & 4 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,10 +256,13 @@ The minimal build (`make build`) sets all flags to 0. The full build
## Standard Library

The 77 modules in `lib/` are pure EigenScript — no C code. They are loaded at
runtime via `load_file of "lib/module.eigs"`. Path resolution searches in
order: the current working directory, the script file's directory, the script's
parent directory, directories relative to the executable (`exe_dir/..` and the
installed stdlib beside it), then `~/.local/lib/eigenscript`.
runtime via `load_file of "lib/module.eigs"`. Both loaders use absolute paths
as-is; relative paths search the containing file's directory, the `eigs_modules`
walk, the nearest `eigs.json` project root, then the executable-relative and
HOME stdlib roots. Nested loads and deferred functions retain their own file's
directory. There is no process cwd or one-parent fallback; only the REPL
(including piped input) and embedding without a file path use their working
directory as the base. See [the ordered chain](SPEC.md#modules).

The meta-circular interpreter (`lib/eigen.eigs`) implements tokenization,
parsing, and evaluation of EigenScript source code in EigenScript itself.
15 changes: 14 additions & 1 deletion docs/BUILTINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ Boolean keywords that check the most recently observed value:

| Name | Signature | Description |
|------|-----------|-------------|
| `load_file` | `load_file of "path.eigs"` | Load and execute EigenScript file. A missing/unreadable path raises a catchable `io` error (matching `import`); a parse/compile failure in the file raises `parse`. |
| `load_file` | `load_file of "path.eigs"` | Execute a file in the current scope, yielding its top-level return value. Uses the same file-based resolution chain as `import` (below). A missing/unreadable path raises a catchable `io` error naming the roots tried; a parse/compile failure raises `parse`. |
| `file_exists` | `file_exists of "path"` | 1 if the path exists (any kind: file, directory, device, fifo), 0 otherwise. A `stat` probe — never blocks (#1070: the old `fopen` probe hung on a reader-less fifo). Trace-recorded, so replay is deterministic (#585) |
| `is_dir` | `is_dir of "path"` | 1 if the path names a directory, 0 for a plain file / missing path (#576 — replaces the `file_exists of "path/."` probe). Trace-recorded, so replay is deterministic |
| `is_file` | `is_file of "path"` | 1 iff the path names a REGULAR file (`S_ISREG`); 0 for a directory, a device/fifo/socket, a missing path, or a non-string. `read_file_util` admits only regular files, so this is the probe a driver uses to match that contract (#1058). Trace-recorded, so replay is deterministic |
Expand Down Expand Up @@ -344,6 +344,19 @@ producing tensors too large to materialise in memory.
| `stream_write` | `stream_write of value` | Append one float64 to the open stream. 1 on success, 0 on failure |
| `stream_close` | `stream_close of null` | Close the stream. 1 on success, 0 on failure |

`load_file` and `import` resolve an absolute path as-is; otherwise they try the
containing file's directory, the `eigs_modules` walk (stopping at `eigs.json`),
the nearest `eigs.json` project root, then `<exe>/../<path>`,
`<exe>/../lib/eigenscript/<path>` and its leading-`lib/`-stripped form, then
`$HOME/.local/lib/eigenscript/<path>` and its leading-`lib/`-stripped form.
`<exe>` is the executable's directory. There is no process cwd lookup or
one-parent fallback. The REPL (including piped input) and the embed API without
a file path use their working directory as the containing directory. Loaded files
and their functions retain their own directory. Consumers using root-relative
paths from subdirectory files need an `eigs.json` at their root. Errors name
the containing directory, project root (or `no eigs.json above <dir>`), and
stdlib roots. See [Modules](SPEC.md#modules) for import collision handling.

## Path Manipulation

| Name | Signature | Description |
Expand Down
25 changes: 25 additions & 0 deletions docs/COMPARISON.md
Original file line number Diff line number Diff line change
Expand Up @@ -503,3 +503,28 @@ make the argument a single value, so the literal list arrives whole. A
bare `of ["ada", "grace"]` would also work here — two arguments to a
one-parameter function pack back into a list — but the parenthesised
form says "one list" directly and works for any arity.)

## File loading and block scope

Unlike a working-directory-based include path, EigenScript resolves imports
and loads from the file containing the call, then the `eigs_modules` walk,
then the nearest `eigs.json` project root, then stdlib locations. Absolute
paths are used as-is. There is no process cwd search; the REPL (including piped
input) and the embed API without a file path use their working directory as the
containing directory. A function retains its defining file's directory through
`eval`, including calls made while another module is being imported or an
embedding host is executing `eigs_eval_file`. The complete chain is in
[SPEC, Modules](SPEC.md#modules).

Main, import and load_file share these rules: a `for` binder is loop-scoped
and never writes an outer binding. Plain `is` in its body updates the nearest
existing binding, including a loop-local; otherwise it creates a binding in
the enclosing scope, like other blocks. In an imported module this search
stops at the module boundary, preserving the importer's bindings. Top-level `return` ends the file:
load_file yields its value, import finishes its namespace, and main discards
its value.

The existing function-slot exception remains: a binder with no prior binding
inside a function retains its final value after the loop on every road. A
pre-existing parameter or local is restored. This change preserves that
exception; see the scope notes in LANGUAGE_CONTRACT.md.
10 changes: 7 additions & 3 deletions docs/EMBEDDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,13 @@ accumulate across calls and are visible to `eigs_get_global`.

Returns a counted ref to the script's last expression value, or `NULL`
on parse / runtime error. On error, `eigs_last_error_message()` returns
the most recent message. `eigs_eval_file` also updates `script_dir` so
`import` / `load_file` inside the source resolves relative paths against
the file's directory.
the most recent message. `eigs_eval_file` compiles with the named file's
canonical containing directory.
The override ends before execution; each helper, including a helper that calls
`eval`, retains its own defining file's directory. Subsequent `eigs_eval_string`
calls without a file use the working directory when no script frame is
executing, and can call previously loaded functions without changing those functions' provenance. The shared search chain
is documented in [SPEC, Modules](SPEC.md#modules).

```c
EigsValue *r = eigs_eval_string("greeting is \"hi\"\n3 * 14");
Expand Down
36 changes: 31 additions & 5 deletions docs/LANGUAGE_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,9 @@ bound only its message string.)
**Promise:** `import name` executes the module once and binds its
top-level definitions as a **dict named `name`** — nothing enters the
importing scope besides that one binding, and module names starting
with `_` are private (omitted from the dict). Resolution order:
`lib/name.eigs` (the standard library) first, then `name.eigs`
script-relative and the other standard locations; the not-found error
names both tried paths. `load_file of "path.eigs"` is the
with `_` are private (omitted from the dict). Import tries `name.eigs`
before `lib/name.eigs`, warns on a project/stdlib collision, and chooses
the project file. `load_file of "path.eigs"` is the
non-namespaced form: it executes the file directly in the current
scope. **Module functions never write the loader's bindings** (issue
#373): a module function's bare assignment to a name that isn't its
Expand All @@ -92,7 +91,34 @@ fields. A **parse error** in a loaded file (via `import`, `load_file`, or
`eval`) raises a catchable runtime error rather than silently executing a
partial AST — consistent with the **Errors** promise.

**Status:** Enforced — `tests/test_import.eigs`,
**One file, three roads (main / import / load_file, #1056):**

- Resolution belongs to the file containing the call, including nested loads
and `eval` inside functions, even when called during another module's
import, load, or `eigs_eval_file`. The defining file remains the base. The shared chain is: absolute
path as-is; containing directory; the `eigs_modules` walk; project root
(nearest ancestor, including that directory, with `eigs.json`); executable
and HOME stdlib locations. There is no process cwd search or one-parent
fallback. The REPL (including piped input) and the embed API without a file
path use their working directory as the containing directory. The full ordered
stdlib chain and error contract are in [SPEC, Modules](SPEC.md#modules).
- A `for` binder is loop-scoped everywhere and never writes a same-named
outer binding. A `for` body's plain `is` updates the nearest existing
binding, including a loop-local; otherwise it creates in the enclosing scope
like `if`, `loop while`, and `try`, on every road. An imported module's
search stops at its boundary, so fresh names appear in its namespace and
never write through to the importer. No function write boundary changes.
- A top-level `return value` ends the current file and yields its value,
skipping later statements. `load_file` returns it to the caller, who
continues; import finishes the module; the main program discards the value
and exits successfully.

The existing function-slot exception remains: a binder with no prior binding
inside a function retains its final value after the loop on every road. A
pre-existing parameter or local is restored. This change preserves that
exception; see the scope notes in LANGUAGE_CONTRACT.md.

**Status:** Enforced — `tools/road_diff.sh` and `tests/roads/`, `tests/test_import.eigs`,
`tests/test_import_errors.eigs` (parse-error surfacing for `import` /
`load_file` / `eval`) (stdlib + user modules,
namespacing, `_` privacy, missing-module error), docs/SPEC.md Modules
Expand Down
12 changes: 8 additions & 4 deletions docs/OBSERVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -506,10 +506,14 @@ this program's earlier assignments ran — they have no recorded history...
```

You will see this if a program **rewrites a module between the compile and the
load**, or creates a file that **shadows** the one the compile-time scan
resolved (resolution tries the cwd before the script directory), or `chdir`s so
the same literal path resolves elsewhere. All three are the same shape: the file
the gate inspected is not the file that ran.
load**, or creates a nearer file that **shadows** the one the compile-time scan
resolved. Both loaders search the containing file's directory, the
`eigs_modules` walk, the nearest `eigs.json` project root, then the executable
and HOME stdlib roots (absolute paths are used as-is). For example, a newly
created sibling can replace a project-root or stdlib target. Changing the
process working directory does not redirect a file's loads. The failure is
that the file the gate inspected is not the file that ran; see the
[shared resolution chain](SPEC.md#modules).

Re-run with `EIGS_OBS_FORCE=1` to disable the gate for that program. That is
always safe — it restores the pre-gate behaviour exactly.
Expand Down
Loading
Loading