From bb80fa5ae0dd679cda10b98a2bdf0f1bac77bb2f Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Fri, 10 Jul 2026 14:58:19 -0500 Subject: [PATCH] =?UTF-8?q?feat(trace):=20tape=20format=20v2=20=E2=80=94?= =?UTF-8?q?=20scope-qualified=20locals=20via=20S=20records=20(#539=20v2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same-named bindings from different scopes merged into one A-record stream, so --step (and any tape consumer) could not tell a function- local i from the top-level one, and trajectory labels fed on the interleaved garbage. v2 adds a scope-transition record S qualifying the A records that follow. is a per-thread monotonically increasing frame-instance id (CallFrame.call_serial, stamped at all four frame-push sites), so two invocations of the same function never merge either. Emission mirrors the L-record dedup: an S is written only when the frame owning the next assignment differs from the last S — cost lands at call boundaries that actually assign. Measured (call-heavy worst case, 20k calls, n=5): +43% tape bytes, ~8% traced runtime; a straight-line module tape carries exactly one S; untraced execution is untouched (the hook stays off). Stripping S records reproduces the v1 tape byte-for-byte. TRACE_FORMAT_VERSION 1→2: v1 tapes refuse on replay and --step per the #411 version-and-reject rule. Replay skips S like A — byte-exact replay determinism is unchanged (verified same-seed). --step folds the S records into a reconstructed call chain (returning into a live serial pops to it; a new serial pops same-or-deeper frames and pushes with parent linkage). `p` shows the live chain innermost- first with {in fn} notes and shadowing — dead frames' locals no longer appear; `p name`/`t name` resolve innermost-first, so trajectory labels are per-invocation. tests/test_step.sh grows six scope checks (22 total); docs/TRACE.md documents the record kind and version history. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GdDAVfZHgoQ2UNP63FYUXm --- CHANGELOG.md | 17 ++++ docs/TRACE.md | 7 +- src/eigenscript.h | 5 ++ src/step.c | 197 ++++++++++++++++++++++++++++++++++++++++----- src/trace.c | 24 ++++++ src/trace.h | 2 +- src/vm.c | 4 + src/vm.h | 5 ++ tests/test_step.sh | 51 ++++++++++++ 9 files changed, 288 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb6d2ca..406e5cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,23 @@ All notable changes to EigenScript are documented here. 5,033ms -> 17.5ms. ### Added +- **Tape format v2: scope-qualified locals (#539 v2).** A new `S + ` scope-transition record qualifies the `A` records + that follow, so a function-local binding and a same-named top-level + one are separate streams — and two invocations of the same function + never merge (`` is a per-thread frame-instance id stamped at + every frame push, `CallFrame.call_serial`). Emitted with the L-record + dedup discipline: byte cost lands only at call boundaries that + actually assign (worst-case call-heavy fixture: +43% tape bytes, ~8% + traced runtime; zero S records in straight-line module code, zero + cost untraced). `TRACE_FORMAT_VERSION` 1→2 — v1 tapes refuse per the + #411 version-and-reject rule; replay skips `S` like `A` (byte-exact + replay determinism unchanged). `--step` reconstructs the call chain + from the serials: `p` shows the live chain's bindings innermost-first + with `{in fn}` notes and shadowing (dead frames' locals no longer + appear), and `t name` resolves to the frame's own stream, so + trajectory labels are per-invocation. `tests/test_step.sh` grows six + scope checks; docs/TRACE.md documents the record and the v2 history. - **Runtime-error carets + token-precise LSP ranges (#407 residual).** Uncaught runtime errors now print the same one-line source excerpt + `^` caret as parse errors, with the column attributed to the failing diff --git a/docs/TRACE.md b/docs/TRACE.md index 7dce367..f9f5bc8 100644 --- a/docs/TRACE.md +++ b/docs/TRACE.md @@ -23,13 +23,14 @@ predicted-not-taken load + branch. ## Tape Format -The tape is plain text, one record per line, four record kinds: +The tape is plain text, one record per line, five record kinds: | Record | Meaning | |--------|---------| -| `V ` | Version header — always the first record (e.g. `V 1 0.26.0`). Stamped once per tape-open; a journal appended across sessions carries one per session. See [Format Versioning](#format-versioning-411). | +| `V ` | Version header — always the first record (e.g. `V 2 0.29.0`). Stamped once per tape-open; a journal appended across sessions carries one per session. See [Format Versioning](#format-versioning-411). | | `L ` | Source-line event (from `OP_LINE`). Adjacent duplicate lines with no `A`/`N` between them are deduped — the compiler emits per-statement LINEs and bare repeats are noise. | -| `A =` | Assignment delta: a binding changed. Fires at **every scope** — function locals included — and carries the name only (no scope qualifier), so same-named bindings from different scopes share one record stream. | +| `S ` | Scope transition (#539 v2): the `A` records that follow belong to this frame instance — `` is the chunk name (``, ``, or the function name), `` the 0-based frame depth, `` a per-thread monotonically increasing frame-instance id stamped at frame push. Emitted lazily with the same dedup discipline as `L`: only when the frame owning the next assignment differs from the last `S`, so the byte cost lands at call boundaries that actually assign. Two invocations of the same function carry different serials — their local streams never merge. Skipped on replay; folded by `--step`. | +| `A =` | Assignment delta: a binding changed. Fires at **every scope** — function locals included — and is scope-qualified by the preceding `S` record, so a function-local `i` and the top-level `i` are separate streams (`--step` resolves names innermost-first along the reconstructed call chain, with shadowing). | | `N =` | Nondeterministic builtin return — the replay-determinism substrate. | ### Value serialization diff --git a/src/eigenscript.h b/src/eigenscript.h index 863cf5b..7bcfdf8 100644 --- a/src/eigenscript.h +++ b/src/eigenscript.h @@ -610,6 +610,10 @@ struct EigsThread { int loop_stall_count; int loop_iterations; const char *loop_exit_reason; + /* #539 v2: next frame-instance serial — incremented at every frame + * push, stamped into CallFrame.call_serial. Per-thread, never reset + * (wrap at 2^32 is fine: adjacent frames never collide). */ + uint32_t call_serial_next; /* Per-thread JIT state — chunk → thunk cache, chunk hotness * registry, and stop-opcode diagnostics. Lazily initialized; * cache + chunks array freed in eigs_thread_detach. */ @@ -700,6 +704,7 @@ extern __thread EigsThread *eigs_current; #define g_loop_stall_count (eigs_current->loop_stall_count) #define g_loop_iterations (eigs_current->loop_iterations) #define g_loop_exit_reason (eigs_current->loop_exit_reason) +#define g_call_serial_next (eigs_current->call_serial_next) #define g_jit_cache (eigs_current->jit_cache) #define g_chunks (eigs_current->jit_chunks) #define g_chunks_count (eigs_current->jit_chunks_count) diff --git a/src/step.c b/src/step.c index b2fa96d..790efaa 100644 --- a/src/step.c +++ b/src/step.c @@ -47,6 +47,11 @@ typedef struct { * (events before the first L clamp to 0) */ const char *name; /* A/N: binding / builtin name (into tape buf) */ const char *value; /* A/N: serialized value (into tape buf) */ + uint32_t scope; /* #539 v2: frame-instance serial this record + * belongs to (from the preceding S record; + * 0 = before any S). L records carry the scope + * current at that point so position queries can + * walk the chain. */ } StepRec; typedef struct { @@ -59,10 +64,25 @@ typedef struct { typedef struct { const char *name; + uint32_t scope; /* #539 v2: one history per (scope-instance, name) — + * a function-local i never merges with the + * top-level i, and two invocations of the same + * function never merge with each other. */ Assign *a; int n, cap; } NameHist; +/* #539 v2: one row per frame instance seen on the tape (S records). + * parent = the frame instance beneath it on the reconstructed call + * stack at push time, 0 for the base frame — position queries walk + * this chain to resolve names innermost-first. */ +typedef struct { + uint32_t serial; + const char *name; /* chunk name: fn, , (into tape buf) */ + int depth; + uint32_t parent; +} ScopeInfo; + typedef struct { char *tape; /* whole tape file, lines NUL-split in place */ StepRec *recs; @@ -71,6 +91,8 @@ typedef struct { int nsteps; NameHist *names; int nnames, namecap; + ScopeInfo *scopes; /* #539 v2 */ + int nscopes, scopecap; char **src; /* optional source lines (1-based view) */ int nsrc; char *srcbuf; @@ -96,9 +118,11 @@ static char *read_whole_file(const char *path, long *out_len) { return buf; } -static NameHist *hist_for(Tape *t, const char *name, int create) { +static NameHist *hist_for(Tape *t, const char *name, uint32_t scope, + int create) { for (int i = 0; i < t->nnames; i++) - if (strcmp(t->names[i].name, name) == 0) return &t->names[i]; + if (t->names[i].scope == scope && + strcmp(t->names[i].name, name) == 0) return &t->names[i]; if (!create) return NULL; if (t->nnames == t->namecap) { int nc = t->namecap ? t->namecap * 2 : 16; @@ -109,6 +133,7 @@ static NameHist *hist_for(Tape *t, const char *name, int create) { } NameHist *h = &t->names[t->nnames++]; h->name = name; + h->scope = scope; h->a = NULL; h->n = h->cap = 0; return h; @@ -126,6 +151,36 @@ static int hist_push(NameHist *h, Assign a) { return 1; } +/* #539 v2: scope-instance table + reconstruction stack (parse time). + * On S(fn, depth, serial): if the serial is already on the stack we are + * RETURNING into that frame — pop to it. Otherwise this is a new frame + * instance: pop everything at the same or deeper depth (frames the tape + * silently returned out of — they never assigned again), then push with + * parent = the new top. Reconstructs the call chain exactly for every + * position that has assignments, which is all the fold can show anyway. */ +static ScopeInfo *scope_info(const Tape *t, uint32_t serial) { + for (int i = 0; i < t->nscopes; i++) + if (t->scopes[i].serial == serial) return &t->scopes[i]; + return NULL; +} + +static ScopeInfo *scope_add(Tape *t, uint32_t serial, const char *name, + int depth, uint32_t parent) { + if (t->nscopes == t->scopecap) { + int nc = t->scopecap ? t->scopecap * 2 : 16; + ScopeInfo *ns = realloc(t->scopes, (size_t)nc * sizeof(ScopeInfo)); + if (!ns) return NULL; + t->scopes = ns; + t->scopecap = nc; + } + ScopeInfo *si = &t->scopes[t->nscopes++]; + si->serial = serial; + si->name = name; + si->depth = depth; + si->parent = parent; + return si; +} + /* #411 header check — the replay rule, with "step" wording. */ static int vline_ok(const char *p) { if (p[0] != 'V' || p[1] != ' ') { @@ -174,6 +229,11 @@ static int tape_parse(Tape *t, long len) { if (!t->recs || !t->steps) return 0; int first = 1; + uint32_t sstack[256]; /* scope-serial stack (VM depth <= 4096 but + * only assigning frames appear; overflow + * degrades to flat scope, never corrupts) */ + int sdepth = 0; + uint32_t cur_scope = 0; char *p = t->tape, *end = t->tape + len; while (p < end) { char *nl = memchr(p, '\n', (size_t)(end - p)); @@ -185,6 +245,7 @@ static int tape_parse(Tape *t, long len) { StepRec r = {0}; r.kind = p[0]; r.step = t->nsteps > 0 ? t->nsteps - 1 : 0; + r.scope = cur_scope; switch (p[0]) { case 'V': if (!vline_ok(p)) return 0; /* mid-stream session header */ @@ -202,7 +263,7 @@ static int tape_parse(Tape *t, long len) { r.name = p + 2; r.value = eq + 1; if (r.kind == 'A') { - NameHist *h = hist_for(t, r.name, 1); + NameHist *h = hist_for(t, r.name, cur_scope, 1); if (h) { Assign a; a.rec = t->nrecs; @@ -216,6 +277,34 @@ static int tape_parse(Tape *t, long len) { } break; } + case 'S': { /* #539 v2 scope transition */ + char *nm = p + 2; + char *sp1 = strchr(nm, ' '); + if (!sp1) { r.kind = 0; break; } + *sp1 = '\0'; + int depth = atoi(sp1 + 1); + char *sp2 = strchr(sp1 + 1, ' '); + uint32_t serial = sp2 ? (uint32_t)strtoul(sp2 + 1, NULL, 10) : 0; + int on_stack = -1; + for (int k = sdepth - 1; k >= 0; k--) + if (sstack[k] == serial) { on_stack = k; break; } + if (on_stack >= 0) { + sdepth = on_stack + 1; /* returned into it */ + } else { + while (sdepth > 0) { + ScopeInfo *top = scope_info(t, sstack[sdepth - 1]); + if (top && top->depth < depth) break; + sdepth--; /* silently-exited frames */ + } + uint32_t parent = sdepth > 0 ? sstack[sdepth - 1] : 0; + scope_add(t, serial, nm, depth, parent); + if (sdepth < (int)(sizeof(sstack)/sizeof(sstack[0]))) + sstack[sdepth++] = serial; + } + cur_scope = serial; + r.kind = 0; /* folded, not kept */ + break; + } default: r.kind = 0; /* unknown: skip */ break; @@ -280,6 +369,29 @@ static const Assign *latest_at(const NameHist *h, int pos) { /* ---- display ----------------------------------------------------- */ +/* #539 v2: the frame instance current at a stop position = the scope of + * the last record in that step's window (A records carry their exact + * scope; an assign-free stretch inherits the last transition, which is + * also the last point the fold below could differ). */ +static uint32_t scope_at(const Tape *t, int pos) { + int bound = (pos + 1 < t->nsteps) ? t->steps[pos + 1] : t->nrecs; + for (int i = bound - 1; i >= 0; i--) + if (t->recs[i].scope) return t->recs[i].scope; + return 0; +} + +/* Resolve a name innermost-first along the reconstructed call chain. */ +static const NameHist *resolve_at(const Tape *t, int pos, const char *name) { + uint32_t sc = scope_at(t, pos); + for (;;) { + const NameHist *h = hist_for((Tape *)t, name, sc, 0); + if (h && latest_at(h, pos)) return h; + if (sc == 0) return NULL; + const ScopeInfo *si = scope_info(t, sc); + sc = si ? si->parent : 0; + } +} + static void show_stop(const Tape *t, int pos) { int rec = t->steps[pos]; int line = t->recs[rec].line; @@ -295,33 +407,75 @@ static void show_stop(const Tape *t, int pos) { } } +static void print_binding(int pos, const NameHist *h, + const char *scope_note) { + const Assign *last = latest_at(h, pos); + int count = 0; + for (int k = 0; k < h->n && h->a[k].step <= pos; k++) count++; + const char *label = classify_at(h, pos, NULL); + printf("%s = %s", h->name, last->value); + if (label) printf(" [%s]", label); + printf(" (%d assign%s)", count, count == 1 ? "" : "s"); + if (scope_note) printf(" {%s}", scope_note); + printf("\n"); +} + +/* #539 v2: bindings shown are the live call chain's, innermost frame + * first — a function-local i and the top-level i are separate streams + * (fn-frame bindings get an {in fn} note; a shadowed outer binding of + * the same name is skipped). Dead frames' locals no longer appear. */ static void show_bindings(const Tape *t, int pos, const char *only) { int shown = 0; - for (int i = 0; i < t->nnames; i++) { - const NameHist *h = &t->names[i]; - if (only && strcmp(h->name, only) != 0) continue; - const Assign *last = latest_at(h, pos); - if (!last) continue; /* not yet assigned here */ - int count = 0; - for (int k = 0; k < h->n && h->a[k].step <= pos; k++) count++; - const char *label = classify_at(h, pos, NULL); - printf("%s = %s", h->name, last->value); - if (label) printf(" [%s]", label); - printf(" (%d assign%s)\n", count, count == 1 ? "" : "s"); - shown++; + if (only) { + const NameHist *h = resolve_at(t, pos, only); + if (h) { + const ScopeInfo *si = scope_info(t, h->scope); + char note[160] = ""; + if (si && si->depth > 0) + snprintf(note, sizeof note, "in %s", si->name); + print_binding(pos, h, note[0] ? note : NULL); + shown = 1; + } + if (!shown) printf("no binding '%s' at this point\n", only); + return; } - if (!shown) { - if (only) printf("no binding '%s' at this point\n", only); - else printf("no bindings yet\n"); + /* chain walk, innermost first; a name shown once shadows outer ones */ + const char *seen[512]; int nseen = 0; + uint32_t sc = scope_at(t, pos); + for (;;) { + const ScopeInfo *si = scope_info(t, sc); + char note[160] = ""; + if (si && si->depth > 0) snprintf(note, sizeof note, "in %s", si->name); + for (int i = 0; i < t->nnames; i++) { + const NameHist *h = &t->names[i]; + if (h->scope != sc) continue; + if (!latest_at(h, pos)) continue; + int shadowed = 0; + for (int k = 0; k < nseen; k++) + if (strcmp(seen[k], h->name) == 0) { shadowed = 1; break; } + if (shadowed) continue; + if (nseen < (int)(sizeof(seen)/sizeof(seen[0]))) + seen[nseen++] = h->name; + print_binding(pos, h, note[0] ? note : NULL); + shown++; + } + if (sc == 0) break; + sc = si ? si->parent : 0; } + if (!shown) printf("no bindings yet\n"); } static void show_trajectory(const Tape *t, int pos, const char *name) { - const NameHist *h = hist_for((Tape *)t, name, 0); - if (!h || !latest_at(h, pos)) { + const NameHist *h = resolve_at(t, pos, name); /* #539 v2: chain walk */ + if (!h) { printf("no binding '%s' at this point\n", name); return; } + { + const ScopeInfo *si = scope_info(t, h->scope); + if (si && si->depth > 0) + printf("(%s in %s, frame #%u)\n", name, si->name, h->scope); + } int total = 0; for (int i = 0; i < h->n && h->a[i].step <= pos; i++) total++; printf("%s: %d assign%s\n", name, total, total == 1 ? "" : "s"); @@ -391,6 +545,7 @@ int eigenscript_step(const char *tape_path, const char *src_path) { free(t.tape); free(t.recs); free(t.steps); for (int i = 0; i < t.nnames; i++) free(t.names[i].a); free(t.names); + free(t.scopes); return 3; } if (t.nsteps == 0) { @@ -399,6 +554,7 @@ int eigenscript_step(const char *tape_path, const char *src_path) { free(t.tape); free(t.recs); free(t.steps); for (int i = 0; i < t.nnames; i++) free(t.names[i].a); free(t.names); + free(t.scopes); return 1; } if (src_path) load_source(&t, src_path); @@ -496,6 +652,7 @@ int eigenscript_step(const char *tape_path, const char *src_path) { free(t.tape); free(t.recs); free(t.steps); for (int i = 0; i < t.nnames; i++) free(t.names[i].a); free(t.names); + free(t.scopes); free(t.src); free(t.srcbuf); return 0; } diff --git a/src/trace.c b/src/trace.c index 7ca7917..be46059 100644 --- a/src/trace.c +++ b/src/trace.c @@ -26,6 +26,7 @@ #include "eigenscript.h" #include "trace.h" +#include "vm.h" /* #539 v2: CallFrame/g_vm for scope-transition S records */ #include #include @@ -444,11 +445,18 @@ static void tp_printf(const char *fmt, ...) { tp_write(buf, (size_t)n); } +/* #539 v2: scope-transition dedup state. The last frame-instance serial + * an S record was emitted for; 0 = none yet (serials start at 1). Reset + * at every tape open so each session's first A record is preceded by its + * scope. */ +static uint32_t g_last_scope_serial = 0; + /* #411: stamp the version header. Called once per tape-open (EIGS_TRACE * fopen, sink install) — a journal appended across several installs * carries one V record per session; replay verifies each. */ static void emit_header(void) { tp_printf("V %d %s\n", TRACE_FORMAT_VERSION, EIGENSCRIPT_VERSION); + g_last_scope_serial = 0; } void trace_set_sink(void (*cb)(const char *, size_t, void *), void *ud) { @@ -1099,6 +1107,22 @@ void trace_assign(const char *name, EigsSlot value) { if (!trace_out_active()) return; if (!name) name = "?"; + /* #539 v2: scope-transition record. When the innermost frame differs + * from the one the last S record named (by frame-instance serial, so + * two invocations of the same function never merge), stamp + * S + * before the A record. Dedup mirrors the L-record discipline: scope + * transitions only cost tape bytes at call boundaries that actually + * assign. Replay skips S like A; only the stepper folds them. */ + if (eigs_current && eigs_current->vm && g_vm.frame_count > 0) { + CallFrame *f = &g_vm.frames[g_vm.frame_count - 1]; + if (f->call_serial != g_last_scope_serial) { + g_last_scope_serial = f->call_serial; + tp_printf("S %s %d %u\n", + (f->chunk && f->chunk->name) ? f->chunk->name : "?", + g_vm.frame_count - 1, f->call_serial); + } + } tp_puts("A "); tp_puts(name); tp_putc('='); diff --git a/src/trace.h b/src/trace.h index 188d16f..c033add 100644 --- a/src/trace.h +++ b/src/trace.h @@ -28,7 +28,7 @@ typedef union { double d; uint64_t u; } EigsSlot; * value serialization, escaping, truncation markers, the header itself. * Replay refuses a tape whose format or runtime version differs from the * running binary: version-and-reject, never migrate (docs/TRACE.md). */ -#define TRACE_FORMAT_VERSION 1 +#define TRACE_FORMAT_VERSION 2 /* v2 (#539): scope-transition S records */ /* 1 when EIGS_TRACE was set and a tape was successfully opened. * Hook sites in vm.c gate on this directly so the disabled case diff --git a/src/vm.c b/src/vm.c index 0c73126..7262f48 100644 --- a/src/vm.c +++ b/src/vm.c @@ -1815,6 +1815,7 @@ int jit_helper_call(EigsChunk *caller_chunk, int argc, int resume_off) { * over mid-thunk. */ CallFrame *frame = &g_vm.frames[g_vm.frame_count++]; frame->chunk = fn_chunk; + frame->call_serial = ++g_call_serial_next; /* #539 v2 */ chunk_incref(fn_chunk); frame->ip = fn_chunk->code; frame->bp = g_vm.sp; @@ -2177,6 +2178,7 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { } frame = &g_vm.frames[g_vm.frame_count++]; frame->chunk = chunk; + frame->call_serial = ++g_call_serial_next; /* #539 v2 */ chunk_incref(chunk); /* frame's ref — released when this frame pops */ frame->ip = chunk->code; frame->bp = g_vm.sp; @@ -3310,6 +3312,7 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { } frame = &g_vm.frames[g_vm.frame_count++]; frame->chunk = fn_chunk; + frame->call_serial = ++g_call_serial_next; /* #539 v2 */ chunk_incref(fn_chunk); /* frame's ref — the fn value popped * above may die mid-call */ frame->ip = fn_chunk->code; @@ -5107,6 +5110,7 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { } frame = &g_vm.frames[g_vm.frame_count++]; frame->chunk = fn_chunk; + frame->call_serial = ++g_call_serial_next; /* #539 v2 */ chunk_incref(fn_chunk); /* frame's ref */ frame->ip = fn_chunk->code; frame->bp = g_vm.sp; diff --git a/src/vm.h b/src/vm.h index d6b549b..8fdd441 100644 --- a/src/vm.h +++ b/src/vm.h @@ -381,6 +381,11 @@ typedef struct { int call_argc; /* args actually passed to this call; <= chunk->param_count. * Used by OP_DEFAULT_PARAM to decide if a slot was bound * by the caller or needs its default expression run. */ + uint32_t call_serial; /* #539 v2: per-thread monotonically increasing + * frame-instance id, stamped at every frame push. + * The tape's S records carry it so the stepper can + * tell two invocations of the same function apart + * (POD — rides the task save/restore memcpy). */ } CallFrame; /* ---- VM State ---- */ diff --git a/tests/test_step.sh b/tests/test_step.sh index c8529e6..7952dc2 100644 --- a/tests/test_step.sh +++ b/tests/test_step.sh @@ -168,5 +168,56 @@ printf '' | "$EIGS" --step "$TAPE" > /dev/null 2>&1 && ok "EOF quits cleanly" \ || fail "EOF quits cleanly" +# ---- 12. #539 v2: scope-qualified locals — a function-local i and the +# top-level i are separate streams; two invocations of the same function +# never merge; inside the frame the local shadows the outer binding. +SCOPE_FIX="$TMPDIR/scope.eigs" +SCOPE_TAPE="$TMPDIR/scope.tape" +cat > "$SCOPE_FIX" <<'EOF' +define work(n) as: + i is n * 10 + i is i + 1 + return i +i is 1 +r1 is work of 2 +i is 2 +r2 is work of 3 +i is 3 +EOF +EIGS_TRACE="$SCOPE_TAPE" "$EIGS" "$SCOPE_FIX" > /dev/null 2>&1 + +grep -q '^S work 1 ' "$SCOPE_TAPE" \ + && ok "tape carries S scope-transition records (v2)" \ + || fail "tape carries S scope-transition records (v2)" + +# two work() invocations = two distinct frame serials on their S records +NSER=$(grep '^S work 1 ' "$SCOPE_TAPE" | sort -u | wc -l) +[ "$NSER" -eq 2 ] \ + && ok "same-function invocations get distinct frame serials" \ + || fail "same-function invocations get distinct frame serials" "got $NSER" + +# at module end: module i folded alone (3 assigns, value 3) — the four +# fn-local writes must not pollute the stream (the v1 merge bug) +OUT=$(printf 's 20\np i\nt i\nq\n' | "$EIGS" --step "$SCOPE_TAPE" "$SCOPE_FIX" 2>&1) +echo "$OUT" | grep -q "^i = 3 .*(3 assigns)" \ + && ok "module-level i folds only its own stream" \ + || fail "module-level i folds only its own stream" \ + "$(echo "$OUT" | grep '^i =' | head -1)" + +# inside the second work frame (step 11 = its 'i is i + 1' line): the +# frame-local i (31, 2 assigns, {in work}) shadows the module i +OUT=$(printf 's 10\np\nt i\nq\n' | "$EIGS" --step "$SCOPE_TAPE" "$SCOPE_FIX" 2>&1) +echo "$OUT" | grep -q "^i = 31 .*{in work}" \ + && ok "frame-local i shadows module i inside the frame" \ + || fail "frame-local i shadows module i inside the frame" \ + "$(echo "$OUT" | grep '^i =' | head -1)" +echo "$OUT" | grep -q "(i in work, frame #" \ + && ok "trajectory resolves to the frame's own stream" \ + || fail "trajectory resolves to the frame's own stream" +# the module i (value 2 at that point) must NOT appear — shadowed +echo "$OUT" | grep -q "^i = 2 " \ + && fail "shadowed module i leaks into frame bindings" \ + || ok "shadowed module i stays hidden inside the frame" + echo "STEP: $PASS passed, $FAIL failed" [ "$FAIL" -eq 0 ]