Shared miscompile in both stages — not a divergence. Bootstrap byte-id
passed throughout because both stages emitted the same wrong asm. Both
the C cgen (cmd/w6c/cgen.c TK_SLASH/TK_PERCENT) and the ww cgen
(selfhost/cmd/wcc/cgenexpr.ww) prepped IDIVQ with `MOVQ $0, DX`, which
is the unsigned 128-bit dividend shape. For a negative RAX, the CPU
then divides 2^64 + (-RAX) by the divisor — unsigned wraparound, not
signed division. Surfaced via lib/time/add() needing the verbatim Hare
signed-%-normalisation in ref/hare/time/arithm.ha.
Fix: emit CQO (sign-extend RAX into RDX:RAX, REX.W 99) on the signed
arm; keep MOVQ $0, DX on the unsigned arm where the DIVQ-vs-IDIVQ
dispatch was already correct. Since both stages always emit 64-bit
IDIVQ regardless of source width, a single CQO suffices for
i64/i32/i16/i8 — the dividend already lives in RAX sign-extended. No
CDQ/CWTL/CBTW needed.
Symmetric stages (rule 10): both stages were broken identically; both
get the same surgical fix. Adds A_CQO to each assembler's opcode set:
cstage in cmd/w6c/6.out.h + cmd/w6c/txt.c + cmd/w6a/{parse,asm}.c;
wwstage in selfhost/cmd/w6a/{types,parse,asm}.ww.
Class B (shared miscompile) — new in the session's polarity catalog.
Bootstrap byte-id is useless for catching it; semantic 9xx runtime
tests are the right shape. test/wcc/978_intdiv_signed.c covers 27 rows
× 2 drivers = 54 fixtures across {i8,i16,i32,i64,u8,u16,u32,u64} ×
{/, %} with width-boundary minima (INT8_MIN, INT16_MIN, INT32_MIN,
INT64_MIN/2) and high-bit-set unsigned anchors. INT64_MIN is spelled
(-INT64_MAX) - 1 per task #17 (wwstage NEGQ-over-imm drops digits on
-9223372036854775808i64); that literal-cgen bug is unrelated to this
fix.
Two known compound-assign workarounds at cmd/w6c/cgen.c:3765
(TK_SLASHEQ IDENT-local) and :3549 (TK_SLASHEQ/TK_PERCENTEQ
deref-compound) remain in tree; both depend on the assembler having
CQO, so they revert in a follow-up commit citing this one.
wwstage's $64 frame was 24B below required — the second struct-return's
@retscr write at -88(BP) landed below SP. Silent miscompile masked by
bootstrap-window luck. The fix retires the stomp by enforcing single-slot
@retscr at emit-time.
cstage was per-site-fresh (wasteful but safe, frame $96); aligned UP to
single-slot for ABI consistency with wwstage's @-prefix convention, not
for correctness. Both stages now produce $64 frame; second return reuses
the first's -64..-48(BP) slot.
Generalizes #38's c.tagscrsz SSoT pattern to c.retscroff (wwstage) and
cg_retscr (cstage). Returns are terminal — only one fires per call, so
the two slots' lifetimes never overlap; single-slot is structurally
correct. wwstage's emit-side dedup was incomplete post-#27 (cgblock
save/restore unwinds the @-prefix stub); the @retscr fast path in
localadd bypasses the c.locals walk.
Test 718: 4 rows × {cstage runtime, wwstage runtime, byte-id, stomp
sentinel}. Stomp sentinel scans .s for any -N(BP) where N>64 and fails
the row if found — catches below-SP writes that bootstrap byte-id would
miss in a lucky window. Row 2 (3-return) byte-id disabled per task #15
(pre-existing label-counter skew, unrelated to #14).
Polarity catalog this session:
- #9 wwstage OVER (tagged-return slot)
- #11 wwstage UNDER (struct-by-value param decompose)
- #14 wwstage UNDER (struct multi-return @retscr — silent stomp)
Path-shaped entrypoints now take str: open, tryopen, access, remove,
mkdir, rmdir, mkdirs, stat, lstat, exists, execve (path arg only).
Each cites its Hare source (ref/hare/os/*.ha, ref/hare/sys/+linux/
*.ha).
New internal kpath(str) *u8 copies into module-level pathbuf: [4096]u8
and NUL-terminates; mirrors ref/hare/sys/+linux/syscalls.ha:25,53.
Non-reentrant — graduates with thread story. mkdirs flattens to one
kpath at entry then walks pathbuf invoking raw SYS_mkdir to avoid
nested kpath clobber.
One Hare divergence at kpath: ships *u8 with nil ENAMETOOLONG sentinel
instead of (*const u8 | errno). Reason: wwstage over-allocates
1-word-payload tagged returns to 24B (cstage emits 16B); filed as
follow-up. Repro at .ai/probe_tagged_return_pointer_payload.ww;
graduates when fix lands.
Each selfhost cmd grew a private pathstr(*u8) str (cstrlen + bs) for
remaining *u8 path sites; w6l shares via obj.ww. Probe 7 in smoke
updated.
Tests 975/976/981 cover migrated entrypoints; 976 extended with two
ENAMETOOLONG rows (-36 for stat, false for exists).
Surfaced during worker-40; cannot reproduce at HEAD across
{literal-LHS, literal-RHS, ident, u64-max, 2^63, the original
pair}. Probable side-effect of the f64/tagged-widen chain
(#37/#38/#40). Decimal RHS is permanent coverage; regression
caught by 715 if it returns.
Codify the operational rules that drove session 3's bug-surfacing
chain. Six rules, each load-bearing at worker/reviewer decision time:
7. No workarounds — STOP and report; document retained divergence
with a task pointer; never silent.
8. WHY-only comments — names carry the WHAT.
9. Hare-fidelity over convenience — no ad-hoc extensions in lib/.
10. Symmetric stages — cstage and wwstage emit byte-identical asm;
align richer side DOWN when inference power differs.
11. Split commits when they bundle unrelated concerns.
12. Simple data, simple algorithms — sea-of-stars over clever.
Auto-loaded by every Claude Code session, so future workers and
reviewers see these from turn 1.
#30 (82be8b9) shipped the f64 variant-widen MOVSD path, but
cg_widen_tagged_store's float-arm gate `fld_isfloat` only accepted
declared f64/f32 — not TY_UNTYPED_FLOAT. cunop on TK_MINUS over an
N_FLOATLIT returns the operand's type (ty_untyped_float), and
cbinop on two untyped-floats returns ty_untyped_float too. So
`let a: (i64 | f64) = -2.5;` and `(2.5 + 1.0)` fell through to the
scalar fallback and stored AX residue at payload+8 (tag still set
correctly, payload = 0).
Wwstage post-#30 was already correct via exprfloatkind's AST walk.
Extend fld_isfloat to accept TY_UNTYPED_FLOAT (defaults to f64, no
TY_UNTYPED_F32 exists). Acceptance set now matches cg_isfloat
exactly. All 15 other fld_isfloat call sites pass declared field /
element / pointee types that never carry TY_UNTYPED_* post-check —
no over-trigger.
Test 715 grows from 7 → 10 rows: unary_neg_floatlit_direct (`-2.5`),
unary_neg_floatlit_paren (`-(2.5)`), binop_floatlit_sum (`2.5+1.0`).
All pin payload bits via hex-u64 punning through *u8 — direct/paren
land 0xC004000000000000 (sign=1, exp=0x400, mant=0x4000000000000);
sum lands 0x400C000000000000 (3.5). Hex literal use is documented
inline pointing at #41 (orthogonal comparison-ladder bug surfaced
during test development; decimal-u64 RHS of != miscompiles).
After this lands, lib/fmt/fmttest.ww's 3 routed-around rows (cited
at 7f320d3) can drop the `let nv: f64 = -2.5;` indirection and use
the direct literal — sibling cleanup.
#30 (82be8b9) unblocked tagged-union widen for runtime f64. Add the
f64 arm to fmt's formattable union and dispatch.
`formattable` gains an f64 case (appended last to preserve existing
tag indices). fdprint / fprint mirror their i64-arm shape. formatraw
peels strconv's natural '-' so signof folds neg/+/space uniformly
with i64. formatfield uses the inline widen-per-arm #18 sidestep.
rawlenf64 renders via strconv.f64tos to count bytes for width
alignment (Hare's print.ha:53 uses an io::empty sink for this; ww
has none yet, so we render twice — acceptable v1 trade).
Width / alignment / pad / sign mods honored. prec / base ignored
with inline rationale (no ffmt/fflags in mods yet; Hare aborts on
non-DEC base, ww silently falls through). NaN/Inf deferred — Inf
renders deterministically as "huge"/"-huge" via strconv's `f >= cap`
path; NaN is garbage. Detection waits on f64↔u64 bit-reinterpret in
cgen.
13 test rows at signalled 31-43 cover basic/int-valued/neg/zero/
small-frac/huge/sign±/space/width-right/width-left + fprint
variadic + bsprintf + asprintf sinks. Each pins exact byte output.
Three rows bind negative literal via intermediate `let nv: f64 =
-2.5;` to route around #40 (cstage drops payload on N_UNARY-of-
N_FLOATLIT in tagged-union widen). Comments cite #40 at each row.
Probe at .ai/probe_f64_unary_neg.ww.
Strconv f64tos is fixed-point today; graduate to Ryū (ref/hare/
strconv/ftos.ha:432) when needed.
Initializing a tagged-union variant slot with a runtime f64 source
(let, cast, fn call, unary, struct field, etc.) stored the i64 bit
pattern in the payload, not the float bit pattern. cgexpr leaves f64
in X0; the existing scalar-fallback MOVQ-from-AX wrote whatever was
last in AX (typically pre-conversion integer or stale residue).
Worker-fmtfloat surfaced this during #17 pre-flight (probe at
.ai/probe_f64_union_widen.ww). Blocks #17 fmt.float dispatch arm.
TK_FLOAT literals were coincidentally correct because the lowering
loads bits into AX before passing through X0 — the literal_1_0 test
row pins that as the principled MOVSD path now.
cstage cg_widen_tagged_store: add fld_isfloat arm between the slice
and scalar fallbacks. Emit MOVSD (f64) / MOVSS (f32) from X0 to the
payload offset, then the tag MOVQ. Mirrors existing str/slice/
structlit field-flow dispatchers.
Wwstage cgwidentaggedstorebp: mirror via exprfloatkind. Resolves a
secondary gap by looking up the variant tag directly via
flatvariantidx(c, dt, "f64"/"f32") — rhstargetname has no N_FLOATLIT
/ N_CALL / N_DOT branch and would fall through to str-fallback
returning tag 0.
No in-tree consumer triggered this pre-fix (no f64 in any tagged
union yet) — hence latent silence. arr[i]= and append() have the
same class gap but no in-tree exerciser today; same shape if/when
[N]f64 / []f64 land.
Test 715 (tagged_widen_f64): 7 rows × 2 stages = 14 fixtures with
bit-pinning via *u8 punning. literal_1_0 (regression lock-in),
cast_1_f64, call_makeone, unary_neg_f64, ident_f64, field_f64
(rob's extra row), i64_rhs_still_integer (negative control).
Diagnosable 0/1/2 return codes distinguish pass / wrong-tag /
wrong-payload.
ww2 == ww3 == ww4 byte-identical post-fix.
wwstage's taggedvariantindex returned -1 (caller maps to 0) for
N_IDENT returns of an aliased mixed-variant union. Cstage returned
the correct variant index. Cross-stage divergence — root cause of
worker-fmtparser's "reads bool-true as false" symptom in the #18
repro chain. Worker-18 dodged it by dropping 707's asm byte-id
loop; #20 re-enables it.
Unwrap at entry: resolvetagged peels N_TNAME alias chains down to
the underlying N_TTAGGED before the variant-index walk. Direct-
tagged callers are unchanged (resolvetype is a no-op on non-N_TNAME).
Mirrors nodeisstr's shape — same class of wwstage-no-typed-AST gap
tracked by #11.
Test 707 grows from 6 → 9 rows; new rows pin tag=0/1/2 (i64/str/
bool) explicitly so a future variant-reorder can't hide behind a
coincidentally-correct tag=0. Asm byte-identity loop re-enabled
(disabled by #18); now exercises both #18 (ABI words) and #20
(variant-index) fixes — rows 2/3/6 also probe str/bool divergence.
995_self_rebuild green confirms wwstage source itself has no
latent aliased-tagged-return that would have surfaced as a self-
divergence.
Closes STATUS latent #1: @tagscr shared 24B reservation across the
four tagged-scratch sites (cgreturn, pushargsrev, cgindex
tagged-elem, pointer-rooted struct-field tagged write). Any fn that
needed >24B (e.g. slice-in-tagged-field 32B) silently overflowed
into the neighbor frame slot. Surfaced concretely as getopttest's
errortable wwstage exit 16 after #37 fixed the upstream gaps.
c.tagscrsz: i32 on the cgen struct is the single source of truth.
tagscrbump(c, need) in scanlocals raises the max across all 4
reservation sites and returns the frame delta. All emit sites
(cgreturn / pushargsrev / cgindex / cgwidentaggedstore pointer-
rooted) read c.tagscrsz instead of hardcoded 24. Mirrors the existing
cgwidentaggedstore precedent; @tagbase keeps its 8B scanseenmark
dedup (always 8B, correct).
Unmasked latent bug (now fixed): scanlocals's pointer-rooted struct-
field tagged-write detection uses localfindnode(c, base.str) to
resolve the *struct base. For `fn fill(h: *holder)`, h's scan-time
stub from scanseenmark had tnode=nil, so the @tagscr reservation
never fired. Pre-#38 the hardcoded 24B masked this; #38's correctly-
sized slot exposed it. cgfn's param scan loop now sets
c.locals.tnode = scanp.lhs after scanseenmark so localfindnode
resolves param types at scan time.
Test 714 (tagged_return_scratch): 4 rows × 2 stages = 8 fixtures.
Direct adjacency repro; match-arm field-by-field read; **mixed-
sizes-one-fn** (16B pushargsrev widen + 32B cgreturn widen in the
same body — pins the lockstep invariant that a sibling site can't
undersize the shared slot); call-site struct-payload widen. Row 3
specifically would regress if a future refactor ever forgets to
route an emit site through c.tagscrsz.
982 getopt_run green through both stages (was the original surface);
995 self_rebuild byte-id holds.
The original #37 symptom (worker-34's `..findflag` mangle) cannot
reproduce on master — was a runtime miscompile misattributed to a
link-time issue. Investigation surfaced three real wwstage cgen
gaps in the N_INDEX-through-struct-field family, sister bugs to
#34 (N_INDEX N_IDENT-base) and #36 (primsize-default-to-8).
1. `indexbaseesz` slice-element stride defaulted to 8 for named-
struct elements. `&opts.ptr[i]` for `opts: *[]option` computed
MOVQ $8 instead of $24. Fix: route slice case through
`elemsizeofc(c, innert)`; ptr-to-named-struct via structlookup.
2. `cgun TK_AMP N_INDEX` ignored N_DOT base. `&p.ptr[i]` left
esz=8 because only N_IDENT base was handled. Mirror cgindex's
existing N_DOT arm.
3. `nodeisstr` N_INDEX arm only walked N_IDENT bases. `cmd.argsptr[i]`
for `argsptr: *str` returned false; pushargsrev dropped the
.len half at call sites. Add N_DOT-base arm that walks the
struct field's pointee.
Test 713 (struct_field_index): 3 rows × 2 stages = 6 fixtures, one
per fix shape. Runtime-only; bootstrap byte-id (995_self_rebuild)
covers cross-stage drift.
Residual: getopttest's errortable still fails through wwstage with
a slice-of-str-via-&arr[expr] miscompile. Filed as task #38.
wwstage's nodeisstr (cgenutil) didn't recognize N_INDEX-of-[N]str.
cgindex emitted only the ptr-half MOVQ when the result was used as
a str arg (call, .len access, str streq), so the .len half read
stack residue. Surfaced by worker-21 during #21 dev — pre-#21
slotsize=24B masked the read-side defect; post-#21 (16B stride)
exposed it. cstage's typed-AST node_isstr handles this naturally;
wwstage's untyped pattern walks the base ident's tnode shape.
Added N_INDEX arm to nodeisstr: walk the indexed base's tnode
through N_TARRAY / N_TSLICE / N_TPTR.lhs, return isstrtype on the
element. Mirrors cgindex's own base-type walk byte-for-byte in
shape so the two now agree on load-shape decisions.
Not covered (separate bugs, separately filed):
- N_UN(TK_STAR) of *str — cgun itself never loads .len into BX.
- tuple `.1` of str — N_TTUPLE path has its own load shape.
- alias-typed base (`type a = [N]str`) — N_TNAME isn't peeled; cgindex
doesn't peel it either, so agreement holds. Outside #34 scope.
Test 711: 3 new rows — barelet_index_call_arg (streq direct arg),
nested_call_index_arg (f(g(argv[i])) — nested-call recursion),
barelet_index_len_arg (sister regression-pin for cgindex element
stride in bare-let context; pins a different code path that was
already correct post-#21).
The pre-existing wwstage `..findflag(SB)` symbol-mangling bug in
getopttest wwstage build is filed as task #37, not in this commit's
scope.
cmd/wcc/check.c silently accepted `let a; let a;` in the same block
and similar redecls. Pre-#27 the localoff dedup masked it; post-#27
last-write-wins via head-first localfind. Surfaced by worker-27
during the #27 review.
Cstage: 5 guard sites (check_scope_define-NULL → err) covering
N_LET block-bind, N_MLET tuple binders (incl. same-tuple
`let (a,a)`), N_FORRANGE tuple binders, top-level let, fn param.
Voice: "<kind> '<name>' redeclared in same scope" for inner;
"duplicate let %s" for top-let, matching the existing
"duplicate <kind>" idiom at 1812/1851/1872.
Wwstage: TODO(#11) comments at the 4 mirror sites (installdecl,
N_FORRANGE, N_LET, installparams). Full enforcement waits on the
checkfile pass per rob.
**Unmasked by #32 (worth flagging):** selfhost/cmd/wcc/cgenexpr.ww
cgcall had `let callee: *node = n.lhs;` twice at fn-body scope
(copy-paste, identical value). Pre-fix silent-redecl absorbed it;
post-fix the new guard rejects. Removed the second decl — outer
`callee` stays visible across the intermediate block.
Test 712 (redecl): 10 rows (6 neg + 4 pos), cstage-only per rob.
Negative rows cover all 5 guard sites + same-tuple-dup. Positive
rows pin the legal counter-shapes (cross-block, name-only bucket,
forrange body, mcase-per-arm).
Test 300 row 34 ("shadowing in inner scope; same scope flagged")
was incorrectly asserting the bug; flipped to expect "redeclared"
and added a sibling row pinning cross-block shadow stays ok. Test
709's `same_block_redecl_pin` canary (explicitly documented as
flipping under #32) removed; pointer to 712 left in its place.
708 pinned param + clet + self-import-skip after #19 landed; the
other 4 sites where check_module_shadow is wired (N_MLET, N_FORRANGE
single, N_FORRANGE tuple, N_MCASE) had no dedicated negative-compile
row. Worker-19's note that all 6 sites use identical call shape is
true today but unpinned by tests; a future asymmetric edit could
silently disable enforcement on one site.
Adds neg_mlet (let-tuple binder shadows), neg_forrange_single
(`for shadowmod of ...`), neg_forrange_tuple (`for (shadowmod, x)
of pairs`), neg_mcase (`case let shadowmod:`). Each errors at the
binder decl line with the canonical
"<kind> '<name>' shadows imported module '<name>'" message.
708's main now runs 8/8 (param, let, mlet, forrange_single,
forrange_tuple, mcase, pos_rename, pos_selfimp).
[N]str array literals wrote only the .ptr half of each element.
cstage used esz=16 from `lu->sub->size` and a single per-element
MOVQ → .len trailed uninitialized stack residue. Wwstage was worse:
primsize("str")=0 fell through to esz=8, so element i+1's ptr-MOVQ
clobbered element i's .len slot, scrambling everything.
Worker-18 sidestepped during #18 by rewriting array primer rows to
[N]i64.
cstage cgen.c N_ARRLIT TY_STR branch: emit AX → base+i*16 then
BX → base+i*16+8. Repeat-`...` path mirrored. type_isstr handles
TY_UNTYPED_STR + TY_NAMED-aliased-str.
Wwstage cgenstmt.ww: isstrel flag conditionally drives the two-MOVQ
store in both the per-element walk and the repeat fill. The dispatch
loop was refactored to unify FIELD/ellipsis branches via isellip,
cleaning up the duplicated arms.
Wwstage cgenutil.ww slotsize/letslotsize: TNAME-"str" element gets
esz=16, replacing the primsize=0 → 8B fallback. Without this the
frame collapsed to 24B for [3]str.
Slice (24B), struct, tuple, tagged element arrays have the same root
cause but distinct width/layout concerns — deferred to #35 per rob.
Test 711 (arrlit_str_full): 7 rows × 2 stages = 14 fixtures —
str_lens_3el, str_ptrs_3el, str_repeat_5el (TK_ELLIPSIS), bool_3el,
rune_3el, i32_3el, i64_3el. Rune relies on the pre-existing esz==4
→ MOVL path (incidental correctness); sibling slot types pinned as
regression nets.
Followups filed: #34 (wwstage cgindex truncate on [N]str bare-let
read side, surfaced by this fix), #35 (composite element types),
#36 (primsize-returns-0-default-to-8 cleanup).
Now that os.alloc/free ship (db2b05b), the heap-shape printf wrapper
that bb10ee7 deferred is implementable.
`asprintf(fmt: str, args: field...) str` — Hare wrappers.ha:29 shape.
Body wires memio.dynamic, runs fprintf into it, takes a stringview,
and shrink-to-fit-copies into a fresh os.alloc(view.len) before
io.closing the dynamic stream (which frees the cap-sized internal
buffer). The shrink-to-fit copy is forced by os.free's (p, n) shape:
n must match the mmap length, so the caller can't free a
cap-allocated body if cap > len.
Caller contract documented inline: free with `os.free(r.ptr, r.len)`
when r.len > 0; skip when r.len == 0 (no allocation happens).
Mirrors strings.dup's shape; not a workaround.
The fprintf io.closed arm is matched-and-ignored — memio.dynamicwrite
only returns size, never io.closed (verified at memio.ww:163-175).
Same shape as Hare's `case size => void;` in print.ha.
Hare's nomem variant intentionally dropped; ww's os.alloc returns a
poisonous pointer on OOM (per #14 contract) which faults on deref —
no in-band error to model.
Tests (signalled 27-30): basic (str + i64), growth (34B output
through 8→16→32→64 grow), empty (no-alloc / skip-free), indexed_mods
({1:_05} through heap sink).
errorf / error / errorln family deferred — drew's call to ship the
whole error story in one commit when the error type lands.
cstage's N_CAST narrow-clamp emitted `MOVL AX, AX` on u32 → enum-u32
casts. wwstage's cgcast walker steps through N_TBANG / N_TNAME alias
links only; an enum's aliaslookup returns the N_TENUM body, which
breaks the loop and skips the clamp. The asymmetry surfaced during
#10 (lib/os/stat) when kstat.mode typed as u32 tripped the cross-
stage diff; worker-stat sidestepped by typing kstat.mode as `mode`.
Single-site gate: skip the narrow-clamp when the destination type is
TY_ENUM. Mirrors wwstage's N_TENUM lacuna exactly. Predicate
recursion (type_isint, type_isunsigned) over TY_ENUM stays intact —
this is emit-side only.
Wwstage unchanged.
Test 710 (cast_enum_movl): 5 rows × {exit-code per driver, asm
byte-id when w6c_ww built} = 10+ assertions. u32→enum-u32 headline,
enum-u32→u32 reverse (pins direction-of-asymmetry), u32→enum-u8
different-width, i64→enum-i32 signed-narrow, struct-field rt mirror
of `out.mode = k.mode` (the #10 trip-wire).
A principled identity-width identity-sign skip across both stages is
filed as #33. The lib/os.ww kstat.mode workaround revert is a
sibling cleanup, not in #25 scope.
localoff (cstage) / localadd (wwstage) deduped stack slots by name
alone, ignoring scope. Outer `let a: [128]u8` and an inner-block
`let a: *u8` shared one 8B slot; prologue truncated to inner size
and outer-scope writes past saved RIP corrupted the frame. Worker-19
hit it during #19 (selfhost/cmd/w6a/main.ww carries a defensive
asm→s rename pointing at this task).
Drop the name-dedup. Each let allocates fresh. Then preserve
outer-scope visibility across inner blocks: cgstmt's N_BLOCK case
saves `*locals` head, walks body, restores. cgfn iterates fn->body
->list directly (bypassing the outermost N_BLOCK) so defers and the
implicit-return epilogue still see fn-body locals after the loop.
Wwstage symmetric: localadd keeps dedup only for `@`-prefixed
synthetic scratches (`@tagscr` / `@retscr` / `@tagbase`) which need
single-slot semantics; user names get fresh stubs. scanlocals always
counts + always appends a fresh stub for N_LET / N_MLET / N_FORRANGE
so prologue SUBQ stays in sync with emit-time offsets. cgblock and
cgfn mirror cstage.
ww2 == ww3 == ww4 byte-identical post-fix.
Test 709 (localoff_scope): 8 rows × 2 drivers = 16 fixtures —
inner_first_outer_bigger, outer_first_inner_writes, nested_3_deep,
same_name_diff_type, same_block_redecl_pin, defer_shadow,
forrange_body_shadow, if_body_shadow. defer_shadow pins the cgfn
body-bypass; if_body_shadow pins the save/restore independently.
Asm byte-id not diffed in 709 — 995_self_rebuild covers cross-stage
drift more broadly.
Follow-ups (filed): #32 (check: refuse same-block let-redecl), w6a
`s`→`asm` revert sibling commit.
c9bbfcb's commit message floated a follow-up — "lib/log can revert
format→fmt now that the silent crash is impossible." That note was
wrong. #19's rule is decl-site and body-blind by design (single
rule, no non-local reasoning), so any `fn x(fmt: str)` under
`use fmt;` is refused regardless of whether the body calls fmt.X.
Rename infeasible.
Rewrite the header bullet to drop the wishlist sentence and state
the constraint directly: ww's `.` overload for both module-access
and field-access makes `use fmt; fn x(fmt: T)` structurally
ambiguous, and #19 refuses it at decl. The `format` parameter name
stays.
Comment-only; no test surface.
When `use fmt;` is in scope and a local/param named `fmt` shadows it,
`fmt.X` in the body silently resolved to the str-typed value sym and
emitted `CALL AX` through str.ptr → runtime crash. Surfaced during
#15 (lib/log's printfln family); worked around by renaming the param
`fmt`→`format`.
Per rob + user, option (C): "value names and module names are
disjoint." Refuse the shadow at the decl site. Single rule, no
non-local reasoning, no silent footgun if a future lib/X exports a
new leaf.
cstage: src_imports walks file->list for N_USE entries (skipping
self-imports where u->module == u->str — same-module fixtures like
lib/fmt/fmttest.ww carry these); check_module_shadow runs before
each SK_PARAM / SK_VAR scope_define (param, clet, mlet, forrange
single + tuple, mcase). Wwstage mirror in check.ww; wwdump-only
diagnostic today, full enforcement waits on #11 checkfile pass.
Bootstrap byte-id holds — no codegen change. One source patch in
selfhost/cmd/w6a/main.ww renames an outer `let asm: asm_;` to `s` to
sidestep task #27 (cstage localoff scope-blind dedup); unrelated to
#19 but the new rule's first run flagged it as a self-shadow.
Test 708 (param_shadow_mod): 4 rows — neg_param (param shadow errs
at fn decl line), neg_let (let shadow errs at let decl), pos_rename
(rename compiles + runs), pos_selfimp (in-module use is skipped).
4 wired sites without dedicated rows deferred to task #28.
Follow-up: lib/log can revert format→fmt now that the silent
crash is impossible.
Top-level `def NEG: i32 = -100;` skipped DATA emission in both stages
— cstage's emit_defs and wwstage's emitdefconstants each carried a
literal-leaf whitelist that excluded N_UN nodes. Same gap in
check.c's eval_enum_value cstage-side. Surfaced during #10 (lib/os
forced an `at` enum for AT_FDCWD=-100 etc. as workaround).
Factor a single fold_int_literal helper (cstage check.c; wwstage
cgen.ww). Handles N_INTLIT / N_RUNELIT / N_TRUE / N_FALSE / N_NIL
plus N_UN with TK_MINUS / TK_TILDE / TK_PLUS recursively. Consume
from eval_enum_value, emit_defs, emitdefconstants, enumevalmember —
single source of truth for "is this a literal-leaf foldable".
Side effect: cstage's def-emit set widens from {INTLIT, RUNELIT,
TRUE} to match wwstage's pre-existing 5-shape set plus the new
unary peel. Bootstrap byte-id holds (995_self_rebuild green).
Test 631 (def_neg_global): 6 rows × cstage/wwstage run + asm
byte-identity diff. Covers all three unary arms (-, ~, +), positive
regression-pin, i32 + i64 + u32 slots.
Follows up #26: revert lib/os.ww `at` enum to three top-level defs.
Hare-shaped filestat introspection. New types: filestat (80B,
mirrors fs::filestat ref/hare/fs/types.ha:141), mode (31-member
enum mirroring fs::mode ref/hare/fs/types.ha:63), stat_mask (7 bits
mirroring fs::stat_mask ref/hare/fs/types.ha:129), timespec (i64+i64,
layout-compatible with future lib/time::instant).
APIs: stat / lstat / fstat (*filestat, *u8|i32) (void|oserror) over
SYS_newfstatat (nr=262). The out-param shape sidesteps the cgreturn
24B ABI cap; commented inline. exists(*u8) bool goes through the
syscall directly rather than wrapping stat()? — dodges task #22's
80B-scrutinee match-slot disagreement until that lands.
Three latent cgen workarounds in tree, all pointer'd to filed tasks:
#22: os.exists sidesteps the (void|oserror) match shape
#24: `at` enum bundles AT_FDCWD/SYMLINK_NOFOLLOW/EMPTY_PATH instead
of three top-level `def`s (negative-literal def DATA omit)
#25: kstat.mode typed as `mode` (enum) rather than u32 to skip the
redundant u32→enum cast emit
Tests: 976_stat_run, 9 rows — stat/lstat/fstat × regfile/dir/symlink
plus exists × {regfile,dir,noent}. Row 1 also pins perm-bit and
atime/mtime/ctime!=0 to catch silent kstat→filestat offset miscompiles
(kstat fields at 72/88/104).
Graduation to lib/fs when it ships is noted inline; signatures stay
rename-compatible.
Migrate the three modules that still carried private
@symbol("rt_alloc") / @symbol("rt_free") bindings onto the public
lib/os.alloc / lib/os.free surface that landed in 87c0883.
memio: 1 alloc (grow) + 2 free (grow's old-buffer drop, dynamicclose).
shlex: 1 alloc (dupstr) + 2 free (freepartial: element strs + slice
header). getopt: 1 alloc (tryparse) + 2 free (tryparse + finish).
ABI identity holds — same rt syms, same shapes, just routed through
the public surface.
rt_ensure stays inline in shlex + getopt; the slice-growth helper
isn't part of os and has no stdlib facade. Comments explain why.
Header rationale comments updated: dropped the now-stale
"lib/io ↔ lib/os C-symbol collision" framing on shlex's inlined
dupstr (that was a pre-#9 concern); reworded shlex's OOM trailer to
match lib/os.ww's documented contract (poisonous pointer, not nil,
fault on deref); fixed memio's dynamicfrom doc to reference
[[os.free]] instead of the retired rt_free name.
980_memio_run / 973_shlex_run / 982_getopt_run all green; bootstrap
byte-identical.
Add os.alloc(n: u64) *void and os.free(p: *void, n: u64) void as
`export fn` via @symbol("rt_alloc") / @symbol("rt_free"). Signatures
mirror lib/memio's existing internal bindings byte-for-byte — only
the name and `export` keyword change. lib/memio + lib/shlex + lib/
getopt drop their own copies in a follow-up commit.
Doc comment spells out the actual failure ABI: rt_alloc wraps the
raw mmap syscall (no libc), so OOM yields a negative-errno cast to
`*void` (e.g. (void*)-12 for ENOMEM). Neither `== nil` nor the libc
MAP_FAILED `(void*)-1` value catches it; deref faults. A typed
fallible variant is future work (alongside #16 fmt.asprintf).
Test (ostest test_alloc_free_roundtrip, signalled=5): alloc 4096B,
write 0x5a at head + 0xa5 at tail, read-back asserts both, free.
The head+tail write/read prevents DCE (failure path calls os.exit)
and proves a real page is backing the returned pointer.
cgreturn's variant-widen arm only filled the registers each variant's
payload needed: scalar variants left CX and R8 stale; str variant
left R8 stale. The receiver (cg_widen_tagged_store non-N_IDENT
branch) writes all four ABI words to the dst slot unconditionally,
so caller-side residue in CX/R8 (the array-index IMULQ being the
canonical primer) landed at slot+16 and slot+24.
Worker-fmtparser surfaced this through fprintf's loop body where
array indexing primed CX and a 24B-return helper failed to clear
it; bug isn't loop-specific — straight-line repro at /tmp/wcrs_repro/
repro8.ww confirms.
Patch: emit `MOVQ $0, CX` after the variant's register shuffle when
the slot exceeds 16B and the variant doesn't fill CX; same for R8
when the slot exceeds 24B. Symmetric across cstage cgen.c and
wwstage cgenstmt.ww. Order: zero-MOVQs precede `MOVQ $tag, AX` so
AX-as-staging stays safe. Inline comment at cg_widen_tagged_store
non-N_IDENT branch documents the producer-zero contract.
Test 707 (cgreturn_variant_zero): 6 rows × both stages = 12 fixtures.
Covers scalar/bool/str returns after array-index priming in
straight-line / single-loop body / nested-loop body / mixed-variant
loop. Asm byte-identity check intentionally omitted; wwstage
taggedvariantindex divergence on str/bool N_IDENT is filed as task
#20. 995_self_rebuild covers the broader cross-stage drift surface.
ww2 == ww3 == ww4 byte-identical post-fix. 67/67 green.
Deferred (followups filed): #20 wwstage taggedvariantindex,
#21 [N]str/[N]bool array-literal non-pointer-half writes, #22
consolidate variant-widen into uniform scratch-slot path.
Add lprintfln, printfln, lfatalf, fatalf — Hare-shape funcs over the
bb10ee7 fmt.fprintfln + fatalf scaffolding. Logger vtable grows by
one slot (printfln); std and silent loggers both wire the slot in
ensureinit. fatalf composes printfln + os.exit(255) like the existing
fatal arm.
Format-string param is named `format` rather than Hare's `fmt`. With
`use fmt;` at the top, naming the param `fmt: str` shadows the module
ref in body lookups — fmt.fprintfln in the body resolves to the str
param and emits CALL through str.ptr. Silent runtime crash. Filed as
task #19. Rename is reversible after #19.
Tests: 5 new scenarios — basic lprintfln + global dispatch + silent
no-op + indexed `{1} {0}` + modifier `{:5}`. Fatalf arms left TODO
pending the subprocess fixture (same shape as the existing fatal
TODO).
Hare-shaped {} / {0} / {n:mods} parser + printf wrappers. APIs:
fprintf, fprintfln, fdprintf, fdprintfln, printf, printfln, errorfln,
fatalf, bsprintf. Parser handles indexed/positional placeholders,
alignment (- / default / =), pad-width, zero-pad (_05), radix (x X o
b), precision (.N for int pad / str trunc), sign markers (+, space),
and {{ / }} escape.
Internals: scandigits + scanmods drive a field-by-field dispatch into
formatfield, which inlines the field→formattable widen per-arm to
sidestep task #18 (24B return-by-value miscompile in for-loop
context). Render through formatraw + formatone over io.stream sinks.
formatone tail-pad uses a separate counter rather than mirroring
Hare's `?`-propagating loop: ww's memio.fixed returns partial-write
0 instead of errors::overflow, so the Hare shape would spin forever
on a full fixed buffer.
Deferred per drew's vet: asprintf/errorf (needs os.alloc, #16),
parametric width/precision dispatch (#16-family), float arm (#17),
log.printfln family wiring (#15).
Tests: 26 scenarios covering every placeholder shape, both arms of
fprintf's variadic dispatch (incl. bool/rune to pin #18 regression),
bsprintf overflow + width-against-full-buffer, closed-stream.
wwstage cgdot lacked a TY_FN branch for module-qualified N_DOT
rvalues. `let p = mod1.ping` fell through to the MOVQ/LEAQ-narrow
fallback, loading 8 prologue bytes from the fn's first instruction
instead of taking its address. Cstage cgdot already handled this
case (wired during #9, f1440bf).
Mirror cstage: gate on fnretlookup(c, fld) before the localloadop
fallback; emit `LEAQ <module>.<name>(SB), AX` via emitfnname with
the hint from lhs.str.
Extend 706_fnlabel_mangle: pos.ww now stores mod1.ping/mod2.ping
into local fn-pointer slots and dispatches through them in addition
to the existing direct calls. Expected exit 56 → 112. Regression
shape: without the new branch, MOVQ leaf(SB) loads the prologue
bytes; indirect call jumps into garbage → SIGSEGV.
ww2 == ww3 == ww4 byte-identical at the new emit.
After 12436dd, lib/fmt and lib/log production code routed through
os.write / os.exit. The test files (fmttest, logtest, memiotest)
still carried the same @symbol("rt_syscall") syscall1ww / doexit
stub block with the (now stale) os↔io collision rationale. Same
mechanical drop as 12436dd: add use os;, route fail() through
os.exit, remove the stubs.
Also reword the stale workaround comment in lib/memio/memio.ww
covering rt_alloc/rt_free. The decls themselves stay until lib/os
exports alloc/free as a follow-up.
No combined.ww regen (these files aren't bootstrap-folded).
After #9 (f1440bf) fn labels mangle by module, so lib/fmt and lib/log
can use os; without colliding with lib/io on the read/write/close
leaves at link.
Drop the @symbol("rt_syscall") rtsyscall3/rtsyscall1 + rawwrite/
rawexit wrappers in both files; route the 7 fmt + 3 log call sites
through os.write / os.exit. Trim the workaround-rationale comments.
Mechanical rename; underlying syscall numbers and args unchanged.
Both stages emitted fn TEXT labels by leaf only; lib/os and lib/io
exporting the same leaves (read, write, close) collided at link.
lib/fmt + lib/log worked around with @symbol("rt_syscall") stubs.
Drop d->export from the fn skip rule in mod_collect (both stages) so
exported fns mangle as <module>.<name>. Let/def/type keep current
behavior. Skip retained for {@symbol, main, empty-module}.
Add cur_mod thread through cgfn + mod_lookup_for_fn(name, hint) at
all 4 label-emit sites (TEXT def, LEAQ N_IDENT, CALL N_IDENT, CALL
N_DOT). Wwstage mirror: emitfnname + modlookupforfn + curmod.
Invariant comment pinned in both stages.
ww2 == ww3 == ww4 byte-identical at the new label format.
706_fnlabel_mangle covers same-leaf cross-module CALL + private-leaf
cur_mod disambiguation through a fn-pointer rvalue.
Wwstage LEAQ-of-fn N_DOT (`let p = mod.fn` rvalue) is a pre-existing
gap; deferred to a follow-up. fmt/log rt_syscall stubs untouched
here; cleanup follows.
Sister bug to #17 / #18. The structlit-fill helper handled nested
N_STRUCTLIT field values but a struct-typed field whose VALUE is an
N_CALL (call returning a struct, #4 cgreturn ABI) fell through to the
cgexpr-then-AX-store path — landing AX=first qword and silently
dropping DX/CX. For 16B/24B inner returns the trailing 8B/16B stayed
zero (whatever was in the destination slot beforehand).
Fix: a new N_CALL+struct branch in cg_structlit_fill / cgstructlitfill,
placed between the nested-N_STRUCTLIT recursion and the scalar
cgexpr fallthrough. Emits cgexpr -> BX reload (non-BP modes only) ->
MOVQ AX/DX/CX x full + sized tail (MOVL/MOVW/MOVB) per #4's receive
shape.
INVARIANT (commented inline both stages): between cgexpr(N_CALL) and
the AX/DX/CX stores below, no instruction may touch AX/DX/CX. Only
the BX reload (MOVQ srcoff(BP),BX or LEAQ name(SB),BX) is safe.
Sized-tail dispatch is {1->MOVB, 2->MOVW, 4->MOVL, else MOVQ}. Unlike
the scalar fallthrough — which still uses the {1/4/else MOVQ} shape
to stay byte-identical with cstage pending #13 — the new branch is
correctness-by-construction: MOVW for tail==2 only fires on call-rhs
shapes that didn't compile before, and both stages emit it
symmetrically (705's 10B inner row pins this).
Guard `fsz <= 24 && fsz%8 in {0,1,2,4}` mirrors #4's cgreturn ABI:
>24B falls through (sret deferred), and fsz%8 in {3,5,6,7} would need
shift-store — also unsupported by #4. Filed as task #21 (covers both
cgreturn and call-rhs's identical gap).
Two #15 sidesteps, both documented inline:
1. wwstage's fi.fsz for an inner-struct field is slot-padded
(8-rounded), not natural — using it would emit 2x MOVQ where
cstage emits MOVQ+MOVL for a 12B inner. The new wwstage branch
uses structnaturalsize(csi) to recover the natural size, matching
cstage's fl->type->size (check.c hands the helper natural sizes).
This sidesteps #15 without touching its scope.
2. The outer struct's totsize diverges across stages when
maxalign<8 (wwstage rounds to 8 universally; cstage to maxalign).
The 705 test rows pin `x: i64` on the outer to force outer
maxalign=8, keeping BP offsets stable across stages. Test-side
sidestep only; also #15 territory.
Files:
- cmd/w6c/cgen.c cg_structlit_fill extended
- selfhost/cmd/wcc/cgenutil.ww cgstructlitfill mirror
- selfhost/cmd/{w6c,wwdump}/main.combined.ww auto-regen
- test/wcc/705_nested_call_rhs.c 8 rows, table-driven; pins cstage
exit + wwstage exit + .s byte-identity. Tail widths 0/4/2/1, dst
modes DST_BP + DST_PTR_LOCAL, shallow + 3-deep.
- Makefile 705 wiring
Test: 65/65 PASS. 994_w6c_ww + 995_self_rebuild PASS (byte-identity
holds — load-bearing).
Sister fix to #17. The BP-rel helper from #17 covered N_LET /
N_ASSIGN N_IDENT-lhs / N_RETURN; the four N_ASSIGN N_DOT-lhs
structlit walks still went through the inline `cgexpr(field.lhs);
store-AX-sized` shape and silently dropped trailing bytes when a
struct-typed field's value was itself an N_STRUCTLIT. Affected dot
flavors: single-dot via_ptr / global / BP-rel and the chained-dot
walker (depth >= 2, all three root flavors).
Extend `cg_structlit_fill_bp` / `cgstructlitfillbp` into
`cg_structlit_fill` / `cgstructlitfill` taking a destination mode
(DST_BP / DST_PTR_LOCAL / DST_GLOBAL = 0/1/2), srcoff (PTR_LOCAL),
srcname (GLOBAL), and disp accumulator. `disp` grows by foff on
descent; srcoff/srcname stay constant across the call tree. The
pre-#17 wrappers are preserved byte-identically by delegating with
mode=DST_BP — 995_self_rebuild byte-identity holds for the no-
nested-STRUCTLIT case that selfhost source actually uses.
The non-BP modes reload BX before the ELLIPSIS zero-fill loop AND
before every field store (tagged, scalar, and the cgexpr leaf).
This is correctness-by-construction — cgexpr clobbers BX between
fields, and the redundant reload only fires on shapes that didn't
compile before. The four dot-flavor sites in each stage now compute
their dst mode + disp and call the shared helper (reducing each
from ~80-130 inline lines to ~5-12 lines of dispatch).
Stage signature asymmetry: cstage threads Local** for cgexpr; ww-
stage takes explicit totsize because #15 (split totsize into
naturalsize + slotsize) is still pending and the dot sites need
structnaturalsize while the BP-rel sites need si.totsize. Both
asymmetries are documented in the helper docstrings.
704 covers 8 rows (24 checks: 8 cstage exits, 8 wwstage exits, 8
cstage-vs-wwstage .s byte-identity diffs): 6 dst-flavors (single-
dot local/ptr/global, chained-dot local/ptr/global) plus single-
local 3-deep and single-ptr 3-deep to pin disp threading through
the helper's recursion and through DST_PTR_LOCAL BX reloads.
The nested struct-typed CALL rhs in field-walks has the same shape
as the STRUCTLIT bug fixed here but the helper only handles
STRUCTLIT — tracked as task #20.
Pre-existing landmine surfaced by #5. For a struct literal whose
field value is itself an N_STRUCTLIT of a struct-typed field, the
inline field-walk did `cgexpr(field.lhs); store-AX-sized`. cgexpr
has no whole-struct-in-register convention, so the nested literal
landed AX = first qword and the trailing bytes silently stayed zero
(or stack garbage). Three BP-relative sites in each stage hit it:
N_LET, N_ASSIGN N_IDENT-lhs, and N_RETURN N_STRUCTLIT.
Fix: shared cg_structlit_fill_bp (cstage) / cgstructlitfillbp
(wwstage) helper handles TK_ELLIPSIS autofill, tagged-field
widening, float vs scalar store dispatch, AND recurses on
struct-typed N_STRUCTLIT field values at bp_off + field_off. All 3
sites in each stage now call the helper instead of the inline walk.
Scalar store dispatch is the explicit {1->MOVB, 4->MOVL, else MOVQ}
shape (not fieldstoreop, which would emit MOVW for fsz==2) to stay
byte-identical with cstage pending task #13. Sister N_ASSIGN N_DOT
structlit walks (via_ptr / global / BP-relative-through-N_DOT) keep
their inline walk and still drop nested-STRUCTLIT silently — tracked
as task #18.
703 covers 6 rows: let_nested_i64, let_nested_3deep, let_nested_i32,
let_nested_middle (i64; switch to i32 once #15 lands),
assign_ident_nested, return_nested. 995_self_rebuild byte-identity
preserved.
Pre-existing landmine surfaced by #5 (whole-STRUCT N_ASSIGN). Both
stages' N_DOT dispatch gated on `lhs->lhs->kind == N_IDENT`; the
parser produces N_UN(STAR, IDENT(p)) for `(*p).f`, so both sides fell
off:
- Write side (cgassign N_DOT base): emitted nothing, store dropped.
- Read side (case N_DOT pointer-auto-deref): cgexpr derefed the
pointer as a scalar, AX = first qword of struct, field offset
dropped.
Fix: retarget base / dot_lhs to the inner IDENT when shape is
N_UN(STAR, IDENT). The existing via_ptr branch fires identically to
`p.f`. v1 scope is bare-IDENT inner only; `(*expr).f` (non-IDENT
pointer expression) is tracked separately as task #19.
702 covers 7 rows: write_i64/i32/str, read_i64/i32/str_len, roundtrip
Both wwstage targets transitively pull lib/strings via lib/strconv
(strconv.ww has `use strings;`), but neither rule listed it as a
prereq. A touch on lib/strings/strings.ww would not re-stamp the
binaries, masking real changes in selfhost smoke tests.
Verified: after the fix, touching lib/strings/strings.ww re-stamps
exactly wwdump_ww + w6c_ww; w6a_ww / w6l_ww / ww_ww (no strconv
use) stay put. 61/61 tests pass, 995_self_rebuild PASS.
Auto-regen of derived files; lib/os.mkdirs landed in 19aa66a but the
selfhost combined.ww snapshots that fold lib/os in were not regened
in that commit. Catching them up now so the next make doesn't fight
the tree.
No source change; make test 61/61.
Capture envp from the kernel-supplied stack into a DATAW slot during
_start's prologue (before CALL main), and expose it via a `rt_envp`
TEXT getter. lib/os.getenv binds the getter as `@symbol("rt_envp")
fn rtenvp() **u8` — the getter-fn pattern works around @symbol-on-let
not being supported by the compiler yet (silent miscompile otherwise).
`os.getenv(name: str) (str | void)` matches Hare's os::getenv surface:
walks the NUL-terminated envp table, "name=" prefix-matches with an
explicit `=` boundary check so prefixes don't false-match longer
names, returns the value as a borrowed str view. Empty value (env
"FOO=") returns len=0 str, not void — void is reserved for "name
not present at all".
Cohort coverage in lib/os/ostest.ww + test/wcc/974_getenv_run.c:
set / empty / unset / prefix-no-match (4 @test fns).
Receive side of #4's cgreturn ABI (aee8149) for TY_STRUCT lvalues of
size <=24B. Producer materialises rhs into AX=bytes[0..7], DX=[8..15],
CX=[16..23], zero-padded to 24B; receive sites here read the regs and
write only `declared sz` bytes — MOVQ for full 8B chunks plus a sized
tail (MOVL/MOVW/MOVB) by the *declared* struct size. ASYMMETRY: do NOT
mirror the sender's three uniform MOVQs, else trailing 1..7B chunks
overrun the next local slot. Tail chunks in {3,5,6,7} are unreachable
under WW struct align rules (size%align==0) and fall through.
Five sites wired in each stage (cstage cgen.c, wwstage cgenexpr.ww +
cgenstmt.ww), call-result + structlit rhs at each:
- N_LET `let s: T = bar()` / `= T{...}` cgenstmt cglet
- N_ASSIGN N_IDENT-lhs `s = bar()` / `= T{...}` cgenexpr cgassign
- N_ASSIGN single-DOT local-base `o.f = ...`
- N_ASSIGN single-DOT ptr-base auto-deref `p.f = ...`
- N_ASSIGN single-DOT global-base `g.f = ...`
- N_ASSIGN chained-DOT depth>=2 `o.m.in = ...`
(The four dot-flavors share one shape pattern, hence "5 sites".) Where
the dst addr needs scratch (ptr-base/global-base/via_cx), it is loaded
into BX after the call so CX stays as the third value word; for
structlit field-walks BX is reloaded before each store since cgexpr
clobbers AX/BX between fields.
wwstage needed a new `structnaturalsize(si)` helper (cgenutil.ww):
si.totsize is mis-named — it's slot-padded to 8 by registerstruct for
stack-slot use, while the receive ABI wants the type's natural size
(max(foff+fsz)). Splitting si.totsize into naturalsize + slotsize is
tracked as the wwstage struct sizing follow-up (task #15); until that
lands, the helper recovers the natural size at receive sites.
Test 701_cgassign_struct.c (18 rows, 3 checks each — cstage value,
wwstage value, asm byte-identity), wired in Makefile after 698. The
headline ASYMMETRY case is the 20B `{i32×5}` row: sender pads to 24B
via three MOVQs, receiver writes MOVQ AX +0, MOVQ DX +8, MOVL CX +16.
A regression to a MOVQ tail there overruns 4B past the slot and
flips the exit-code check.
smoke.combined.ww is the auto-regen ride-along of strings.freeall
landing in 714d089 (worker-shlex).
Pre-existing gaps surfaced and tracked separately (not fixed here,
out of scope):
- task #16: silent drop of `(*p).f = ...` explicit-deref dot lhs.
- task #17: silent zero of nested STRUCTLIT field in N_LET / N_ASSIGN
initializer — the field_chain and field_global test rows use
explicit field writes (`o.m.t = 10i64;`) rather than nested
literals as a fixture-level workaround.
- task #9: module-name-mangle for fn labels avoided in the
field_global_call fixture by `let g: outer;` (no init).
make test: 59/59. 994_w6c_ww + 995_self_rebuild PASS — bootstrap
byte-identity is the load-bearing proof for this commit's scope.
The 699 test source landed with the SK_USE→SK_X promotion fix in
7f60ebb but Makefile wiring was deferred so it wouldn't collide with
parallel fnmatch + cgreturn WIP. Tree is clear; wiring it now.
make test: 57/57.
Whole-struct return ABI for sizes <=24B. Both stages materialise rhs
into a zero-padded 24B @retscr scratch slot, then load AX=bytes[0..7],
DX=bytes[8..15], CX=bytes[16..23] unconditionally — three MOVQs
regardless of declared struct size, so the receive side (landing in
task #5) can read all three words and mask by the declared size. R8
stays reserved for the tagged-return 4th word; the uniform-MOVQ shape
is cheap over a size-conditional partial-load and keeps the producer
diff vs the existing tagged-return AX/DX/CX/R8 path minimal.
Two rhs shapes wired this pass: N_IDENT (word-copy from rhs local slot,
MOVQ pairs + MOVL/MOVB tail bounded by declared struct size) and
N_STRUCTLIT (field-walk; tagged fields delegate to the existing tagged
widening helper, float fields go through X0, int fields use MOVQ/MOVL/
MOVB by field size). Sizes >24B fall through to the existing scalar
path (only AX gets the first qword), pending sret in a future task.
N_CALL chain-return (`return otherfn()`) is deferred to task #5's
receive side — until that lands the call-result lives in caller regs.
The wwstage mirror in cgenstmt.ww matches cgen.c byte-for-byte on the
new branch; cgendecl.ww's scanlocals pre-reserves 24B for @retscr under
the same predicate (N_RETURN, fnret is N_TNAME, structlookup hit,
totsize<=24, rhs is N_IDENT|N_STRUCTLIT) since wwstage writes its
prologue SUBQ from the upfront frame total — cstage patches SUBQ at fn
end so it can allocate inline.
Latent fsz==2 MOVW divergence between stages (cstage structlit int-
branch only special-cases fsz 1/4, wwstage's fieldstoreop also returns
MOVW for fsz==2) tracked as task #13; not exercised by the new fixtures
or by any current selfhost <=24B struct return.
main.combined.ww files also pick up worker-checkfix's wwstage
architectural comment from 7f60ebb (auto-regen ran after that commit).
Port of ref/hare/fnmatch/fnmatch.ha. Public surface mirrors Hare:
`flag` enum (NONE/PATHNAME/NOESCAPE/PERIOD) and `fnmatch(pattern,
string, flags) bool`.
Algorithm is the three-phase sea-of-stars (also used in musl):
exact-match the prefix before the first `*`, exact-match the tail
after the last `*`, then greedily match each star-delimited middle
segment with backtrack on inner failure. No exponential corner —
each star anchors a "match found" at strictly increasing positions.
Bracket expressions: Hare-strict — `!` for negation, `^` rejected
as invalid; `]` as first member legal, trailing `-` literal, all
12 POSIX classes ([:alnum:] … [:xdigit:]) via direct streq + the
ascii.is* predicates.
Divergences from Hare (documented in fnmatch.ww docblock):
- byte-indexed cursors in place of strings::iterator (no UTF-8
rune iter yet); ASCII-only meaningful, multibyte matches
byte-identically. Graduates "in one go" per lib/CLAUDE.md
when the language stack grows rune iteration.
- invalid pattern collapses to `false` at the public boundary
(Hare's `b is bool && b: bool;`); a try-shaped diagnostic
entry can be added later without churning the surface.
- tail-match uses a forward cursor at `string.len - cnt`
instead of riter/prev — same byte sequence either way.
Test fixture follows the project's helper-per-row table-driven
shape (precedent: lib/encoding/base32/base32_test.ww). 8 @test
fns clustered by feature (basic / brackets / ctype / period /
noescape / musl_basic / pathname / combined), ~95 rows total
adapted from Hare's +test.ha plus musl-derived edge cases.
Wired as 972_fnmatch_run alongside 970_fmt_run / 971_log_run in
the stdlib-runtime band.
Unblocked by 7f60ebb (cstage+wwstage SK_USE→SK_X promotion
missing use_alias), which is what let the module name `fnmatch`
coexist with an exported leaf fn `fnmatch`.
In cmd/wcc/check.c the pass-1.5 SK_USE→SK_DEF/SK_FN/SK_VAR promotion
sites forgot to set prev->use_alias = 1 when the imported module's
top-level decl shadowed the SK_USE leaf in flat scope. Downstream
dot-prefixed lookups (resolve_typename L77, N_DOT L709) gate the
module-head walk on (SK_USE || use_alias), so `mod.flag` resolution
fell through to "unknown type". The SK_TYPE precedent at L1660 had
the line; the three sister sites at L1709/L1722/L1736 now do too,
in the same one-line shape and field-set order.
The wwstage selfhost/cmd/wcc/check.ww uses coexistence rather than
in-place promotion: SK_USE and same-leaf SK_TYPE/FN/DEF/VAR live as
separate entries differentiated by sym.mod, and scopelookupinmodule's
mod-filter already disambiguates dotted lookups — no use_alias flag
needed, so the cstage bug is structurally non-reachable there. An
architectural note at installdecl documents this divergence-by-design
and warns against porting the flag (adding a field to `sym` changes
its size and risks the wwstage cgen amalloc-undersize trap).
Audit covered every SK_USE→SK_X promotion path in check.c (4 sites:
SK_TYPE already-correct as precedent, SK_DEF/SK_FN/SK_VAR fixed). The
surfacing case was lib/fnmatch: `fn fnmatch(...)` shadows the SK_USE
leaf, so `fnmatch.flag` failed in worker-fnmatch's WIP — that test
(972_fnmatch_run) now flips PASS as live integration proof.
test/wcc/699_use_promote_alias.c pins all four rows with a single
table-driven driver (type/fn/def/var → use mod; let m: mod.flag =
mod.flag.A; return m: i32, expecting exit 42 per row). 995_self_rebuild
byte-identity holds.
Validates the #15 same-leaf-name cross-module type fix (9d85aa4):
`bufio.stream` and `io.stream` now coexist on a `use bufio; use io;`
surface — bufiotest.ww references both in the same scope (e.g.
`let m: io.stream; let b: bufio.stream;`) and compiles clean.
Lifts the bufio-side workaround that bstream existed to dodge.
@test fns rename in lockstep (bstreamsmallwrite → streamsmallwrite,
etc.). Also tidies the stale "until #21 lands" parenthetical in
test/wcc/696_modtype_leaf_collision.c, since #21 is this commit.
make test: 54/54; bootstrap fixed-point 990–997 holds.