Commit Graph

134 Commits

Author SHA1 Message Date
17765942f9 selfhost/cmd/wcc: delete mem.ww (γ-7, Phase 0 close)
mem.ww has 0 callers post-γ-6 — newarena/amalloc/grow/freearena/
roundup all unreferenced after the *arena cascade strip. Drop the
91-line module.

Makefile: remove mem.ww from 5 dep lists (wwdump_ww, w6c_ww,
w6a_ww, w6l_ww, ww_ww); drop `-I selfhost/cmd/wcc` from w6a_ww/
w6l_ww/ww_ww build invocations (wwdump_ww + w6c_ww still need it
for check.ww/cgen*.ww).

test/wcc/990_selfhost.c: drop 6 mem.ww entries from probe_codegen,
probe_dump_diff (×2), probe_resolve, probe_dump_stable, and
probe_cgen_match file lists.

lib/memio/memio.ww: dynamicgrow doc comment reframed as historical
context (collision source is gone, but task #9 keeps the
module-prefixed name conservative against future collisions).

Two dead `import mem;` lines remain in selfhost/cmd/w6a/asm.ww and
selfhost/test/uses.ww; tolerated silently by ww build, swept in
task #8.

main.combined.ww auto-regenerated for w6a/w6c/wwdump.

Verified 132/132 incl. 994_w6c_ww + 995_self_rebuild byte-identity.
Phase 0 closes.
2026-05-21 13:24:27 +09:00
353dffb5e8 lib/ww + wcc + w6c + wwdump: strip *arena cascade (γ-6)
amalloc has 0 callers post-γ-2; the *arena threaded through
newnode/newscope/newtype/prim/typesinit/type{ptr,slice,array,chan,
named}/lexinit/parserinit/joindotted/checkinit/arenau64tos/cgeninit
and the scope.a / tctx.a / lex.a / parser.a / checker.a / cgen.a
fields are vestigial.

Drop `import mem;` from 15 files, remove six struct fields, strip
*arena from 14 signatures, update ~120 call sites across lib/ww +
wcc + w6c + wwdump. selfhost/test/sym_link.ww fixture drops the
newarena/freearena probe; still exits 42 on scopedefine/scopelookup.
Both main.combined.ww auto-regenerated.

Comments retidied: typ.ww "once per arena" → "once per program";
parse.ww drops "arena-build" qualifier on joindotted; sym.ww drops
mem-sibling-imports rationale.

Verified 132/132 incl. 994_w6c_ww + 995_self_rebuild byte-identity
(the primary symmetric-stages gate).
2026-05-21 13:01:57 +09:00
917d6250fc lib/ww/lex + selfhost/cmd: astrndup → strings.dup view (γ-1)
#7 Phase A first cut. 13 of 15 astrndup callers converted to the
explicit (*u8, n) → str view + strings.dup shape (ref/hare/strings/
dup.ha:7). astrndup export stays in selfhost/cmd/wcc/mem.ww — 2 w6a
sites blocked by the selfhost/cmd/w6a/types.ww shadow (filed as #11)
and carry an inline WHY pointer until the rename ships.

NUL-dependence audit: no consumer reads token text past `.len`. tok.text
flows through fputq (length-bounded) and parse.curtext → n.str (streq-
based dispatch across check/cgenutil); p.file is written via os.write
(ptr,len); selfhost/cmd/ww/main.ww's astrndup'd pathstr only flows into
visitseen/visitadd's manual byte-loop, while all OS calls in that file
use the unrelated `pathstr(*u8) str` view helper on the raw pointer.

Empty-str sites (lex.ww:579, 624, 640) collapse to strings.dup of an
empty view; strings.dup short-circuits len==0 (lib/strings/strings.ww:72)
and returns {nil, 0} — observationally identical to astrndup's prior
{arena_1byte, 0}.

Sites:
 - lib/ww/lex/lex.ww (8: 450, 529, 554, 564, 579, 624, 640, 794)
 - selfhost/cmd/w6c/main.ww:139
 - selfhost/cmd/w6l/dyn.ww:111 (inside dcstrtostr)
 - selfhost/cmd/w6l/obj.ww:185 (inside cstrtostr)
 - selfhost/cmd/ww/main.ww:531

Wrappers (dupstr/cstrtostr/dcstrtostr) keep their bodies; deletion
deferred to #10.
2026-05-21 10:41:54 +09:00
fd7dee985e cgen + memio: cgoutarena → memio.dynamic, grow → dynamicgrow (β-3)
Phase 0 last β-shape site. Two concerns in one commit because the
refactor surfaced the rename:

 - selfhost/cmd/wcc/cgen.ww  cgout buffer (cgoutbuf/cap/len + arena +
   cgout_grow + CGOUT_INIT_CAP) → memio.state + io.stream behind a
   one-shot lazy-init guard. cgout_enable drops its *arena param;
   memio.reset in cgout_flush keeps the buffer sticky across fns so
   the arena's amortisation survives — re-init per fn would abandon
   the buffer and re-grow from 0 via the 8→…→65536 ladder for every
   function (no io.close path → no os.free).

 - lib/memio/memio.ww  private fn grow → dynamicgrow. Symmetric with
   dynamicwrite / dynamicclose; required because cstage bundles all
   imported modules into a flat TU and resolves private fns by
   unqualified name, so the new `import memio;` in wcc's bundle
   collided with selfhost/cmd/wcc/mem.ww's arena `grow`. Module-aware
   private-fn scoping in cstage is task #9.

@test fn dynamicgrow in memiotest.ww (same package as memio.ww)
renamed to dynamicgrowcases to free the name; new suffix mirrors the
file's existing fixedwritecases / borrowedreadcases convention.

Lazy-init guard cgoutinit. memio.dynamic runs once on first
cgout_enable; subsequent enables just set cgoutmode. Mirrors
lib/log/log.ww:124 ensureinit. Without it, ~14 mmap syscalls per fn
and ~100 MiB+ cumulative leak on a typical bootstrap.

io.write bare discard in emitbytes mirrors lib/log/log.ww:169 —
memio.dynamicwrite never returns io.closed (memio.ww:166).

Verified 132/132 incl. 995_self_rebuild byte-identity.
2026-05-21 10:11:40 +09:00
d9f097250b lib/ww/lex + w6a/parse: amalloc β grow → alloc([], n)! (β-1)
Phase 0 first β-batch. 4 amalloc sites across 2 β grow loops:
 - lib/ww/lex/lex.ww:570,592 lexstr string-literal escape buf
 - selfhost/cmd/w6a/parse.ww:513,548 DATA "..." escape-payload buf

Both follow the established α-per-alloc shape with loop logic left
manual: `*u8 = amalloc(_, cap): *u8` → `[]u8 = alloc([], cap)!`,
inner copy `nb[i] = old[i]` unchanged (slice indexing emits identical
asm to *u8 indexing — no .len bounds compare), `.ptr` extracted at
the *u8 consumer (s.ptr, pr.bytes). Bear-trap N/A — byte count tracked
in locals (nb, blen), slice .len = 0 never read.

Verified 132/132 + 995_self_rebuild byte-identity.
2026-05-21 09:14:39 +09:00
6ff38f52bb lib/ww: migrate 5 typed amalloc sites to alloc(T{...})! (typed-9)
Phase 0 critical lib/ww/ gap (frontend used by BOTH cstage + wwstage,
not covered by phase0-mapper's selfhost/cmd/ audit). 5 typed-struct
amalloc sites + 1 γ pointer-array + 1 α byte buffer:

 - lib/ww/ast.ww newnode (node, 20 fields, was 208u64 over-sized)
 - lib/ww/typ.ww newtype (tinfo, 13 fields, was 112u64 over-sized)
 - lib/ww/typ.ww tinfocachebind (tinfocacheent, was 32u64 sizelint-ok)
 - lib/ww/sym.ww newscope (scope, 6 fields, was 64u64) + buckets γ
 - lib/ww/sym.ww scopedefineinmodule (sym, 10 fields, was 112u64)
 - lib/ww/parse/parse.ww joindotted (α []u8 + .ptr extract)

Retires 4 rule-7 over-sized amalloc workarounds plus a #36
tinfocacheent sizelint-ok. WHY-comments documenting the workarounds
are dropped (no longer applicable — alloc(T{...})! sizes from the
type table).

ast.ww newnode's `fval = 0: f64` carries a 3-line WHY comment naming
the 990_selfhost TK_FLOAT-count diff probe (lex.ww:382 precedent for
the same cast pattern). Bare `0.0` here would shift the dump-diff
token-input scope and break 990's byte-identity probe.

β grow loops in lib/ww/lex/lex.ww:570,592 deferred — separate sweep.

Verified 132/132 + 995_self_rebuild byte-identity. Net -63 lines.
2026-05-21 04:18:52 +09:00
4f4504d10a selfhost/cmd/w6a/parse + lib/ww/lex: amalloc → alloc([], N)! (α-8)
Phase 0 #8 eighth α-batch. 3 sites:
 - w6a/parse.ww:198 nextline newline-hit branch
 - w6a/parse.ww:208 nextline EOF-no-newline branch
 - lib/ww/lex/lex.ww:457 float underscore-strip buffer

All α: alloc([], n+1)! + p[k] indexing + .ptr at the consumer
(`return buf.ptr, n` for parse; `parsef64(clean.ptr, j)` for lex).
No struct-field shape change, no escape.

Verified 132/132 + 995_self_rebuild byte-identity.
2026-05-21 03:14:04 +09:00
00d88ff9fc lib: α-batch-2 rt.malloc → alloc([], N)! (path/shlex/fmt/ostest)
Phase 0 #8 second α-batch. 7 sites: lib/path/path.ww `join` ×4,
lib/shlex/shlex.ww `dupstr`, lib/fmt/fmt.ww `asprintf` tight-copy,
lib/os/ostest.ww `test_alloc_free_roundtrip`. Same dup-pilot pattern
(4c07ef0, 47918d3): `alloc([], N)!` + `buf.len = N;` +
`return strings.frombytes(buf);`.

Side effects:
- path/shlex/fmt: import switches `rt` → `strings` (callers now
  reference `strings.frombytes`, not `rt.malloc` direct).
- ostest.ww: `import rt;` retained — the alloc builtin lowers to
  `CALL malloc(SB)` which resolves via rt's @symbol("rt_malloc")
  decl. Other files reach rt transitively via `import strings`;
  ostest only imports os, so it needs the explicit rt import.
- shlex stale comment "avoid strings dep" stripped — strings is now
  in scope.

Verified make test 132/132 + 995_self_rebuild byte-identity.
Advances #43.
2026-05-21 01:01:13 +09:00
47918d3ced lib: drop _unsafe convention; rename fromutf8_unsafe → frombytes; strings α-batch (concat/join/lpad/rpad)
CLAUDE.md rule 9 amended with the explicit carve-out: ww is C/Plan-9-
lineage — no GC, no "safe" baseline to be unsafe relative to — so the
Hare `_unsafe` suffix flags an axis ww doesn't have. The convention
is dropped wholesale in lib/.

Concrete changes:
- lib/strings: `fromutf8_unsafe` → `frombytes` (pure reinterpret). The
  validating sibling `fromutf8` is deleted entirely (28 lines, plus its
  84-line fromutf8_cases test). Callers that need validation write the
  two lines inline at the IO source: `utf8.validate(b)?;
  let s = strings.frombytes(b);`. `fromutf8` name reserved for a future
  true validating helper.
- lib/strings α-batch: concat/join/lpad/rpad migrate from
  `rt.malloc(N): *u8` to `alloc([], N)!` + `buf.len = N;` +
  `return frombytes(buf);`. Same dup-pilot pattern (4c07ef0). Task #41.
- lib/memio header comment trimmed: drops a stale reference to
  "lib has no fromutf8 today"; cites the rule-9 carve-out instead.
- Caller renames across selfhost combined.ww files (auto-regen) +
  cgenutil.ww comment ref.

Rule-11 disclosure on the bundle: the rename and the α-batch are
nominally separable concerns (symbol-naming policy vs amalloc→
alloc-slice migration), but they touch the same 4 functions in
lib/strings/strings.ww — the α-batch's first emission of `frombytes`
postdates the rename. The α-batch was applied on top of the rename
sweep mid-flight by the pre-commit reviewer; splitting them back
out is fiddly text surgery for marginal bisect value. The rename is
the primary concern; α-batch is one entry in #8's sized-slice
migration.

Verified: make test 132/132, 995_self_rebuild byte-identity holds.
Closes #42; advances #41.
2026-05-21 00:35:14 +09:00
4c07ef0552 lib/strings/dup: rt.malloc → alloc([], n)! slice form
Pilot for task #8 (runtime-N alloc API). `alloc([], n)!` yields a
slice with cap=n, len=0; explicit `buf.len = s.len;` lifts the len
before the fromutf8_unsafe reinterpret. Same shape as
ref/hare/strings/dup.ha:15 modulo ww not yet having `append`
(task #36) — open-coded byte loop in lieu of static-append.

Verified 132/132 + 995_self_rebuild byte-identity. Pattern is the
template for the next α-category sites (concat/join/lpad/rpad/...).
2026-05-20 23:38:03 +09:00
a376ec89eb lib/rt: rename rt_alloc → rt_malloc; rt.alloc → rt.malloc
Hare's canonical runtime allocator is rt::malloc with linker symbol
rt.malloc (ref/hare/rt/malloc.ha:27,78). ww kept the dot→underscore
Plan 9 convention (CLAUDE.md rule 4) so the linker symbol becomes
rt_malloc; the lib/rt exported function name becomes malloc; ww
callers say rt.malloc(...).

The language builtin keyword stays `alloc(T)!` — unchanged from Hare
(ref/hare/hare/lex/token.ha:21 ltok::ALLOC, parse/expr.ha:398
builtin()). The rename only touches the lowered linker symbol and the
exported function name behind it; the user-facing syntax for
heap-allocation is identical to Hare.

Surface:
- rt/alloc.s: TEXT rt_alloc → TEXT rt_malloc, labels updated
- lib/rt/malloc.ww: @symbol("rt_malloc") fn malloc(...) (was rt_alloc/alloc)
- rt/ensure.ww: local FFI decl + call site updated to malloc; `!` dropped
  on the direct FFI call (rt_malloc returns *void, not a tagged union)
- 18 .ww callers: rt.alloc(...) → rt.malloc(...)
- cstage cmd/wcc/check.c + wwstage selfhost/cmd/wcc/check.ww
  alloc-builtin suppression gate routes through ffi_resolve("malloc")
  for the lowering; the user-shadow check still keys on the BUILTIN
  KEYWORD "alloc" since that is what `alloc(...)` parses as. Adding
  "malloc" to the user-shadow check was unnecessary and was reverted
  during pre-commit review.
- cstage cmd/w6c/cgen.c: 2× ffi_resolve("alloc") → ffi_resolve("malloc")
- wwstage cgenexpr/cgenstmt: 2× ffiresolve(c, "alloc") → ffiresolve(c, "malloc")
- Test fixtures (700_e2e, 758_cgalloc_str_field, 990_selfhost, 992_w6l_ww,
  selfhost/test/tagged_ptr_ret.ww): updated inline ww sources to the new
  decl + call form

This is commit 2 of 3 in the lib/rt extraction (#38). Commit 3 closes
the OOM contract — return type becomes nullable *void and the builtin
lowering null-checks + propagates nomem.

Verified 132/132 + 995_self_rebuild byte-identity (5 wwstage tools
round-trip identical) + make clean cold rebuild.
2026-05-20 22:11:34 +09:00
d68d3c7eb4 lib: extract rt module from os, sweep imports
Hare puts runtime allocation in rt::, not os:: (ref/hare/rt/malloc.ha:27,
README). ww's `@symbol("rt_alloc") fn alloc(n: u64) *void;` lived at
lib/os/os.ww as a historical bootstrap shortcut; this commit relocates
it to a new lib/rt/malloc.ww and sweeps every site that depended on
`import os` for the alloc decl over to `import rt`.

This is commit 1 of 3 in the lib/rt extraction (#35):
  1. (this) move decl, sweep imports — preserves shape
  2. rename rt_alloc → rt_malloc (#38)
  3. nullable return type + OOM-propagating builtin lowering (#39)

No rename here. Symbol stays rt_alloc, function stays `alloc`, return
stays *void. Behavior identical — same ffi resolution outcome, just
sourced from a different module file. The rt::ensure runtime helper at
selfhost/rt/ensure.ww is its own compilation unit with a local decl and
is untouched.

Side effect: every wcc cgen file used `rt` as a local *node variable
name for "return type." `import rt;` shadows the module, so each
selfhost/cmd/wcc/{check,cgenstmt,cgenexpr,cgenutil}.ww site renamed
to `rtyp`. Mechanical follow-through; only the wcc module-import was
forced to do this rename.

Verified 132/132 + 995_self_rebuild byte-identity (5 wwstage tools
round-trip byte-identical).
2026-05-20 20:39:52 +09:00
f80927201b tools/sizelint + CLAUDE.md rule 13: gate hardcoded size literals
Drew's Hare-discipline framing: "no hardcoded size literals anywhere in
the compiler." This session spent 32 commits sweeping after-the-fact
and STILL kept introducing new bypass sites in our own structural
work (A.5's tupleelemslot/fieldslotsize most recently). The cure is a
gate that catches new violations at commit time, not a deeper sweep.

tools/sizelint (sh+gawk):
- Always-on: `.size = NN` / `->size = NN` / `prim(...,"name",NN,...)`.
- Context-gated literals (NN(u64|i64) and `return NN`) in files or fns
  matching size|slot|elem|field|stride|paramfield|tinfo|primtype|
  slotsize|letemit|tagged.
- Allow-list via `// sizelint-ok: <reason>` or `/* sizelint-ok: ... */`.
- Comment strip happens after allow-list match so prose mentions of
  16/24 stay quiet.

Makefile: `test: all sizelint $(TESTS)` so the gate runs before any
binary builds.

CLAUDE.md rule 13 documents the discipline + escape hatch + optional
pre-commit-hook symlink.

Audit caught 3 real cstage bugs (cmd/wcc/check.c resolve_type:1002,
1079, 1531 hardcoded `tt->size = 16` / `= 32` for tagged-with-ptr and
tagged-with-slice payloads — should read `8 + sub.size`). Fixed
inline; behavioral no-op today (pt->size=16, st->size=24, sub.size=24
match the prior literals) but the SSoT seam carries forward through
#1/#34/#65.

8 SSoT-seed allow-lists added (cstage type.c ty_str/ty_slice prim
factories; wwstage primtypesize/tyslicesize; lib/ww/typ.ww tystr +
slice fields + their main.combined.ww mirrors). One amalloc-overalloc
allow-list at lib/ww/typ.ww:273 cites pending #36 (typed amalloc).

#66 filed for extending the filter once #65 routes lib/bytes +
lib/getopt's sizeof(slice) / sizeof(option) literals through SSoT —
naive line-pattern extension would false-positive on 22+ ELF wire-
format sites in dynout.ww.

131/131 + 994 + 995 + bootstrap green with `make sizelint` exit 0.
2026-05-20 15:22:21 +09:00
03b7336cae selfhost/cmd/wcc + lib/strings: restore SSoT routing for str/slice tinfo helpers
Phase A.5's tupleelemslot / fieldslotsize hardcoded 16u64 for TY_STR
and 24u64 for TY_SLICE — bypassing the tinfo.size SSoT seeded by
lib/ww/typ.ww:189 (the very pivot they were introduced to consult).
Route those four arms through pt.size / ft.size so #1 (str→24) and
#34 (slice graduation) land as a one-line bump at the seed.

lib/strings/stringstest.ww carried 12 `(cap: u64) * 16u64` strides
missed by #43's sweep over strings.ww + shlex.ww; convert to
`* size(str): u64` so the #42 fold owns the constant. Doc comments
in strings.ww (freeall + splitn) updated to the same SSoT form.

No-op at today's str.size=16 / slice=24: tinfo.size already matches
the literals these arms had baked in. Reviewer's pre/post asm-identity
probe (struct{i64,str,i64} + (i32,str,i32) tuple + bare str) shows
zero-byte diff. 131/131 + 994 + 995 + bootstrap (ww2==ww3==ww4) green.

Forward-link to #1 (str→24B bump) and #64 (sizelint pre-commit gate);
#65 filed for lib/bytes + lib/getopt sibling sites the reviewer
surfaced. Forward of #64 will catch any future regressions of this
class.
2026-05-20 14:55:36 +09:00
9fd79cdc33 selfhost/cmd/wcc + lib/ww: tinfo.slotsize SSoT + module-name TNAME fallback (Phase A.5)
A.4 left 74 fallback hits, all TNAME-flavored — 71 TNAME → TY_STRUCT
(natural-align vs slot-padded mismatch) + 3 module-name TNAME quirks
(`let l: lex;` where lex is both struct and imported module).

tinfo gains a slotsize: u64 field (96 → 104 bytes; amalloc bumped
to 112B per rule-7). size(T) stays Hare-natural at the user level;
cgen's slot storage now reads ti.slotsize for kinds where the two
differ. tinfofornode populates both:

- TSTRUCT: existing natural-align walk for r.size; new size-derived
  align walk (sz≥8→8, ≥4→4, ≥2→2) for r.slotsize, rounded to 8.
  Mirrors cgenutil.ww:2192-2218 registerstruct exactly.
- TTUPLE: parallel via tupleelemslot helper (primitives→8, str=16,
  slice=24, ptr/fn/chan/i64/u64/int/uint/uintptr/f64=8, composite
  →pt.slotsize, void=0).
- TARRAY: typearray sets slotsize = sub.slotsize * n. [N]i32 stays
  4N (natural); [N]Triplet lifts to 16N (slot-padded). Reverts
  A.4's r.size override since slot-pad now lives in slotsize.
- TFN/TENUM/TTAGGED/nullable: explicit slotsize. Default trail
  `if r.slotsize == 0 then r.slotsize = r.size` catches TBANG.
- New fieldslotsize(ft) helper mirrors registerstruct's per-field
  rule (struct→ft.slotsize, array→ft.slotsize, primitive→ft.size,
  tagged→ft.size).

slotsize fast-path (cgenutil.ww) reads ti.slotsize for TY_STRUCT,
TY_TUPLE, TY_ARRAY; ti.size stays correct for PTR/SLICE/CHAN/FN/
STR/TAGGED/VOID (size == slotsize for those). Narrow scalars still
pad-to-8 at the read site (moving into slotsize would break
[N]i32 stride).

lib/ww/sym.ww adds scopelookuptype(s, name) — same FNV bucket+parent
walk as scopelookup but filtered on skind==SK_TYPE. resolvealias
calls it when bare-leaf scopelookup returns non-TYPE (e.g., the
SK_USE/SK_MOD short-circuit case). Fixes `let l: lex;` (mod=leaf)
AND `let t: tok;` (mod≠leaf, tok lives in package lex).

Post-A.5 fallback: 0 across full bootstrap. Reviewer's stricter
metric (zero fast-path MISSES when tinfo IS stamped) also 0;
remaining FB_NIL hits are value-expression nodes the checker
doesn't yet stamp — A.6 candidate.

Ragged-tail probe `struct{inner=3*i32, mark:i32}`: ti.size=16
(natural), ti.slotsize=24 (slot-padded). Cstage emits [N]<ragged>
stride=16 on the same source — latent divergence filed as #63.
Not exercised by selfhost, so bootstrap byte-identity holds today.

131/131 + 994 + 995 + bootstrap (ww2==ww3==ww4) all green.
2026-05-20 14:30:55 +09:00
82c1948239 selfhost/cmd/wcc + lib/ww/typ: nullable fold + slot-pad fast-path (Phase A.3)
A.2's slotsize fast-path covered PTR/SLICE/CHAN/FN/STR but bailed on
TAGGED (no nullable fold) and on primitives (cstage let_emit_size pads
to 8B for slot storage; tinfo.size is natural width). Fallback hit
count under wwdump build was 2187. A.3 closes both gaps.

tinfo gains a `nullable: i32` field (fits the existing 4B pad, struct
stays 96B). tinfofornode's N_TTAGGED arm detects `(*T | void)` (exactly
2 variants, one N_TPTR, one bare N_TNAME "void" — aliased or !void-
wrapped void don't match) and folds to size=8, align=8, nullable=1.
Mirrors cmd/wcc/check.c:412-426.

slotsize fast-path re-adds TY_TAGGED (safe now) and gains a primitive-
pad branch: BOOL/RUNE/I8-I64/U8-U64/INT/UINT/UINTPTR/ENUM/F32/F64 →
return 8. Padding lives at the read site; tinfo.size remains a faithful
natural-width SSoT. TUPLE/TSTRUCT/TARRAY deliberately stay on the
fallback because per-field stride is registerstruct.totsize, not
tinfo.size.

Post-A.3 fallback hit count: 134 (94% reduction from A.2's 2187).
Reviewer's per-kind breakdown: N_TNAME 101 (alias-to-struct chains)
+ N_TARRAY 33 (struct-element rounding) account for all remaining
hits. Both A.4 work.

Probes: `(*i32 | void)` byte-identical between stages with the
8B nullable encoding. `(*i32 | nomem)` correctly does NOT fold
(nomem ≠ bare void). `(*i32 | !void)` correctly does NOT fold
(N_TBANG isn't N_TNAME).

131/131 + 994 + 995 + bootstrap byte-identical (ww2==ww3==ww4).
2026-05-20 12:51:22 +09:00
93ac65ba0a lib/ww/typ + selfhost/cmd/wcc/check: tinfo-on-node infrastructure (Phase A.1)
Foundation for audit §1.8 — wwstage cgen recomputes type sizes at every
site instead of reading n.type_ like cstage does (cmd/wcc/check.c sets
n->type via cexpr; cgen reads n->type->size). The scattered literals
this session has been chasing (#43, #60, etc.) are the symptom; this
chain is the cure.

A.1 is infrastructure only — no cgen-site graduation yet. Subsequent
A.2+ sub-commits collapse each walker family (slotsize, elemsize,
fieldsize, isstrtype, istaggedtype, ...) onto n.type_ reads.

lib/ww/typ.ww:
- tinfocacheent struct (key, val, cnext) — sea-of-stars per rule 12.
- tinfocache: *tinfocacheent field on tctx (now 25 fields).
- tinfocachelookup / tinfocachebind — head-prepend linked-list ops.

selfhost/cmd/wcc/check.ww:
- tinfofornode(c, n) *tinfo — covers N_TNAME primitive (singleton
  lookup), N_TNAME alias (recurse via resolvealias), N_TBANG
  (unwrap+recurse, iserror dropped — graduate alongside the first
  cgen reader that needs it), N_TPTR/N_TSLICE/N_TCHAN (recurse on
  sub, call typeptr/typeslice/typechan).
- exprtype N_INTLIT arm now sets e.type_ = tinfofornode(c, tn). Only
  population site in this commit; every other arm unchanged.

Empirically verified via temp probe that tinfofornode is reached and
returns non-nil on `let x: i32 = 42;`. Strict scope: zero cgen reads
of n.type_; primtypesize/slotsize/etc. still drive size queries.

131/131 + 994 + 995 byte-identical to caa72f2.
2026-05-20 10:40:12 +09:00
8e93b31088 cmd/w6c+selfhost/wcc+lib: route sizeof(str)/sizeof(slice) through SSoT
Audit §1.1/§1.2 cataloged 17 wwstage sites hardcoding 16 for sizeof(str)
and ~10 hardcoding 24 for sizeof(slice), plus 4 cstage str-size sites
and the cstage let_emit_size str/slice arms.  Each new size constant
required ~30 edits in both stages to bump cleanly — task #1 (str → 24B
{ptr,len,cap}) can't land until the literal sweep is done.

Track A — wwstage codegen (selfhost/cmd/wcc/*):

  - check.ww introduces two stateless helpers next to astsize:
    primtypesize(nm)  — primitive-name → byte size (i64; -1 unknown)
    tyslicesize()     — slice-header bytes (i64; 24 today)
    astsize now reads both for its N_TNAME-primitive and N_TSLICE arms,
    so the size(T) fold gets the SSoT for free.
  - cgen.ww, cgenutil.ww, cgenstmt.ww, cgendecl.ww: every `return 16`
    / `esz = 16` / `sz0 = 16` for str, every `return 24` /
    `localadd(c, _, 24, _)` for slice, plus the matching `sz == 16` /
    `sz == 24` / `for (i < 16/24)` gates in the global-let DATAW emit,
    route through primtypesize / tyslicesize.
  - Direct delegation slotsize→astsize would require restructuring
    astsize to drop its *checker dep (resolvealias) — the leaf
    primitive/slice cases factor out cleanly, the alias-chain leaves
    diverge because cgen's aliaslookup/structlookup tables and check's
    scope chain aren't unified yet (§1.8, task #50 follow-up).  Sharing
    the leaf table satisfies the SSoT promise without that refactor.

Track B — cstage (cmd/w6c/cgen.c):

  - let_emit_size's TY_STR/TY_SLICE arms drop the hardcoded 16/24 and
    fall to `(int)u->size` like the existing TY_STRUCT/TUPLE/TAGGED arms.
  - N_LET cgstmt's per-kind `sz` cascade collapses to a single
    `if (lu->kind ∈ {ARRAY,SLICE,STR,STRUCT,TUPLE,TAGGED}) sz = lu->size`.
  - N_LET cgexpr's match-bind primitive sizing: `bsz = (int)bu->size`
    drops the TY_STR/TY_SLICE special-cases (same outcome — ty_str/
    ty_slice already have ->size set by type.c).
  - Three `sz == 16` / `let_emit_size(d->type) != 16` gates against the
    str slot width route through ty_str->size.

  Cap-offset sites (cgen.c:2440/1994/3206/5517 `delta = 16` for
  slice's .cap field-write) intentionally NOT touched: 16 there is the
  *offset of .cap inside a slice header*, structurally always 16
  regardless of str.size.  #1 doesn't move the slice layout.

Track C — lib/ user code:

  - lib/strings.freeall + appendstr, lib/shlex.freepartial + appendstr:
    the four `16u64` literals (per-str-element stride for rt_ensure and
    os.free) become `size(str): u64`.  Check-time fold via #42's
    intercept resolves to 16 today; #1 reroutes via the bumped tinfo.

After this commit, bumping ty_str to 24B for task #1 requires editing
exactly two places (cmd/wcc/type.c:64 ty_str.size, plus check.ww
primtypesize's "str" arm) for the SSoT to propagate.

Verification:
  - 131/131 tests pass.  994_w6c_ww + 995_self_rebuild byte-identity
    holds — each replacement evaluates to the same constant the
    literal had today, so cgen output is unchanged.
  - selfhost source's `size(str): u64` folds at check time (cstage
    cmd/wcc/check.c:907-960 for the C-bootstrap of selfhost; wwstage
    check.ww:898-942 for the rebuild path), no runtime call introduced.
2026-05-20 08:50:40 +09:00
cd44cc1893 lib/strings+test: port replace per Hare
ref/hare/strings/replace.ha:46-66. Two-pass byte scan: pass 1 counts
non-overlapping needle hits via bytes.hasprefix, pass 2 allocs the
result []u8 at exact size and copies chunks + replacement. Single
nomem propagation site at the alloc — ww's append builtin aborts
on OOM (#11), so the per-chunk append(...)? form Hare uses is not
available; the exact-size single alloc is equivalent in spec.

total==0 returns {nil,0} to dodge rt_alloc(0) per #47.

Empty needle is intentionally ungated and loops forever — that's
Hare's behavior at ref/hare/strings/replace.ha:31 (i += len(needle)
is 0; hasprefix("") always matches). Hare-faithful divergence,
documented at the site.

multireplace deferred to #49 — its (str, str) variadic gather hits
#39 in variadic-param position. Filed and blocked accordingly.
2026-05-20 02:46:22 +09:00
f8770d1502 selfhost/cmd/wcc/cgenutil+test: slotsize zero for void, recurse N_TBANG
Wwstage's slotsize had a catch-all `return 8` for any N_TNAME where
primsize's `> 0` guard failed. `primsize("void") == 0` (correct —
void is zero-sized per cmd/wcc/type.c:46), so void landed on the
catch-all. (void | !void) then sized as `8 (tag) + max(8, 8) = 16`
instead of `8 + 0 = 8`, and the phantom payload word made
cgwidentaggedstore spill DX for the let-init — diverging from
cstage's `8`-byte slot.

Two narrow additions per rule 10 (align wwstage DOWN to cstage):
1. N_TBANG case at the top of slotsize, recurse on .lhs. Mirrors
   cstage resolve_type N_TBANG which copies the underlying type's
   size unchanged.
2. `void => 0` in N_TNAME BEFORE the primsize guard, so the SSoT
   matches cmd/wcc/type.c:46.

757_letbind_void_bang_void exercises three shapes — void-arm,
invalid-arm, full natural-form fromutf8 — and pins cstage/wwstage
asm byte-identity per row.

lib/strings/strings.ww fromutf8 WHY-comment drops the Bug-B
SIGSEGV caveat (measurement artifact: original test linked without
rt/start.s; RET popped argc). Keeps #19 dependency for the
eventual collapse to `utf8.validate(in)?`.

Hare matches ww's design (void zero-sized, !T inherits T's
layout); this is a pure wwstage implementation gap, not a
divergence to argue about.
2026-05-20 02:27:17 +09:00
6d006da26c lib/strings+test: graduate fromutf8 + bytesub to validating return
fromutf8(in: []u8) (str | utf8.invalid) and the bytesub form per
ref/hare/strings/utf8.ha:22 and sub.ha:59. bytesub keeps its byte
asserts (ww extension over Hare; predates #7).

fromutf8 walks the utf8 decoder via utf8.next rather than the
shorter `utf8.validate(in)?` form. Two compiler bugs in the way:
cross-shape `(void | invalid) → (str | invalid)` propagation is
#19, and (void | !void) match-bind locals diverge between stages /
str→union lift SIGSEGVs in cstage — both filed as #48. The
decoder-walk form bypasses both and matches what
ref/hare/strings/utf8.ha actually does in source.

getopt.ww:314 caller updated to match the new (str | invalid)
return; bi+1 cannot hit a continuation byte in well-formed argv
(bi is a just-matched ASCII flag), so abort spells the precondition.

bytesub_cases rewritten as exhaustive match; new rows cover
start-on-continuation and end-on-continuation invalid arms plus an
end==s.len bypass. fromutf8_cases is new — Hare vector + edge
bytes + multibyte parity rows.
2026-05-20 02:02:28 +09:00
e03a6281d6 lib/strings+test: port dupall from Hare
ref/hare/strings/dup.ha:26-35. Returns ([]str | nomem); duplicates
every str in the input slice via the now-graduated alloc-slice
builtin (#45 unblocked `let s: []str = alloc([], n)?`). Loop body
uses appendstr because `[]str` element is 16B and the bare `append`
builtin truncates (#11) — pre-allocated cap=s.len means rt_ensure's
grow branch never fires.

Defer-rollback omitted: with `dup()` still unchecked (graduation
tracked by #46), the only nomem source is the initial slice alloc,
so there is no partial state to roll back. Will revisit when #46
lands.

Empty-input early-return short-circuits via {nil,0,0} because
rt_alloc(0) is an mmap of 0 bytes which the kernel rejects with
-EINVAL — Hare hands back a sentinel. Localized at the call site
pending #47.

Tests assert independent allocations at every index of multi-element
inputs, including a multibyte row.
2026-05-20 01:30:12 +09:00
dd27ce3339 lib/strings+test: re-port index str-arm to dual-iterator rune walk
Old shape ran byteindex then rewound to count runes — two passes,
different algorithm from Hare. New `indexstring` mirrors
ref/hare/strings/index.ha:59-81: one outer iterator over the
haystack, an inner iterator re-seated from it for each candidate
match, both walking rune-by-rune. Returns the rune-index of the
first match, or void.

Rest-iterator copy is field-wise rather than `let rest_iter =
s_iter;` because the local-to-local copy of the 3-field iterator
struct diverges between stages today (#41 — 993_ww_ww and
995_self_rebuild byte-diverge when written the natural way).
WHY-comment cites #41 with the precise failing tests.

Tests pin the rune-vs-byte distinction at i=2 and i=4 with 3-byte
kana, plus self-match, empty-needle, empty-haystack, and a no-match
multibyte row from ref/hare/strings/index.ha:119.
2026-05-19 23:11:01 +09:00
18fe1a7a31 lib/strings+test: 0-arg trim strips ASCII whitespace per Hare
The 0-arg ltrim/rtrim/trim used to return input unchanged. Hare's
0-arg form strips [' ', '\n', '\t', '\r'] (ref/hare/strings/trim.ha:6).
Aligned by delegating to bytes.ltrim/bytes.rtrim with the whitespace
set spread inline at the call site — the obvious `let ws = whitespace[0:4]`
shape produces a slice whose ptr does NOT alias storage (#40).
N-arg forms (strip-specific-runes) untouched.

Test rows retargeted to Hare's canonical inputs from trim.ha:78/85
so '\r' is exercised alongside ' '/'\t'/'\n'.
2026-05-19 22:52:23 +09:00
f827429215 lib/strings+test: compare returns int, not i32
Hare's strings::compare returns int (ref/hare/strings/compare.ha:12).
Result is a sign, not an index, so the i32 was cargo-culted from the
str-index type. Callsites already compared against 0, so callers
needed no migration. Widened the two return-site casts (u8→int,
i32→int) — the latter avoids i32 underflow on adversarial length
diffs. Added a multibyte test row contrasting ASCII vs UTF-8 lead
byte to exercise the high-bit-operand path.
2026-05-19 22:39:05 +09:00
031f5f9ec8 lib/strings+test: split sub byte-wise vs rune-wise per Hare
The byte-indexed silent-clamp sub from ww was Hare's bytesub wearing
the wrong name. Renamed accordingly; added the real rune-indexed sub
per ref/hare/strings/sub.ha:30-42, with utf8bytelenbounded helper
per :10. Both forms assert on start>end; bytesub also asserts
end<=len(s).

lib/getopt/getopt.ww:314 migrated to bytesub — its bi index is a
byte offset over the arg's bytes.

Tests cover ASCII parity, multi-byte UTF-8 (こんにちは / héllo) where
rune index ≠ byte index, and a row contrasting identical args to
make the distinction explicit. OOB-abort coverage deferred until
the assert_aborts harness lands (#38).

Selfhost combined.ww snapshots regenerated — they're bootstrap-stage
inputs and would otherwise compile the old byte-wise sub. Two-arg
default form omitted (#37, ww has no default parameter values).
2026-05-19 22:25:06 +09:00
2b46abe7d1 lib/encoding/utf8+test: add strerror per Hare 2026-05-19 16:50:02 +09:00
8683202a4c lib/bytes+test: port ltrim/rtrim/trim from Hare (u8... variadic) 2026-05-19 16:19:12 +09:00
e5c1d6baa4 lib/strings+test: port lpad/rpad from Hare 2026-05-19 16:01:25 +09:00
3176d83d37 lib/strings+test: port split family from Hare 2026-05-19 15:40:33 +09:00
b0da6167b8 lib/strings+test: port tokenize family (Hare cross-module re-export) 2026-05-19 15:22:45 +09:00
7e1b681701 lib/bytes+test: port split family from Hare 2026-05-19 13:50:54 +09:00
02ada29624 lib/strings+test: port join from Hare str... variadic (#19) 2026-05-19 12:22:16 +09:00
3a85db0f3f lib/bytes+test: port tokenize family from Hare (#18) 2026-05-19 11:41:36 +09:00
23fccee4a0 lib/bytes+test: graduate contains to Hare (u8|[]u8)... variadic (#10)
contains(s, needle: (u8|[]u8)) -> contains(s: []u8, needles:
(u8|[]u8)...) bool per ref/hare/bytes/contains.ha:6.

Body: for-loop over needles.len; inner match (needles[i]) with u8/
[]u8 arms each forwarding to index(s, ...) with early return true
on the i32-match arm. 0-arg returns false per Hare spec. Mirrors
sister #9 strings.contains body shape modulo element type.

Sister of #9 (7c5463c). Element shape (u8|[]u8) — slice payload +
scalar u8 — structurally distinct from (str|rune). Was flagged as
potential new-latent surface; verified clean by 967_bytes_run +
cross-module 750_mklabel_modscoped[bytes_strings_contains] +
995_self_rebuild byte-id. No cgen wedge fired — #15 frame growth +
#12 sum-tag forward + #16 fnparamslookupmod close it on the slice-
payload variant too.

contains_cases adds 5 variadic rows (signalled 1700+i): 0-arg false,
1-arg slice hit, 1-arg u8 hit, 3-arg mixed middle-hit, 3-arg all-miss.

Module-header non-variadic divergence note removed.

make test 126/126; ww2==ww3==ww4 byte-id holds via 995_self_rebuild.
2026-05-19 05:19:21 +09:00
7c5463c7d9 lib/strings+test: graduate contains to Hare (str|rune)... variadic (#9)
contains(haystack, needle: (str|rune)) -> contains(haystack: str,
needles: (str|rune)...) bool per ref/hare/strings/contains.ha:9.

Body: for-loop over needles.len; inner match (needles[i]) dispatches
byteindex(haystack, s|r); early return true on hit; fall-through
return false. Hare's match-yield + if (matched) return true collapses
naturally given byteindex's (i32|void) shape (Hare uses bytes::contains
-> bool). 0-arg returns false per Hare spec.

Unblocked by: #8 (5ed6293 varargseq per cgcall), #12 (cbf1042 sum-tag
forward), #15 (5609d04 frame-strategy), #16 (d9b0c90 callee_variadic_
param N_DOT). All four landed this session; #9 exercises every
unblocker. The cross-module same-leaf collision with bytes.contains
(non-variadic) is closed by #16's fnparamslookupmod re-routing —
existing 750 sentinel row bytes_strings_contains continues to pass.

contains_cases adds 6 variadic rows (signalled 1600+i): 0-arg,
1-arg str, 1-arg rune, 3-arg mixed (middle hits), 3-arg all-miss,
multibyte mixed (heart-é via 0xE9u32: rune per single-byte lexrune
precedent at hasprefix_cases:115).

Module-header divergence note for contains removed; lib/bytes
whitespace-default note retained for future bytes graduation.

make test 126/126; ww2==ww3==ww4 byte-id holds via 995_self_rebuild.
2026-05-19 05:08:57 +09:00
77e62e5ed2 lib/strings+test: add index/rindex Hare sum-type dispatch (#11)
index(haystack: str, needle: (str|rune)) (i32|void) per
ref/hare/strings/index.ha:10. rindex symmetric per :22.

Returns RUNE-index (not byte-index) per Hare contract. str-arm reuses
byteindex/rbyteindex for the anchor byte offset, then walks iter
forward counting runes until position(&it) >= bo. rune-arm forward-
iterates with next(), counts rune positions.

rindex_rune divergence from ref/hare/strings/index.ha:45: Hare's
rindex_rune walks i = len(s) - 1 by 1 per step (byte-len-1 minus
decrement count) — neither pure-byte nor pure-rune for multibyte
input, contradicts its own docstring's rune-wise claim. ww honors
the docstring contract: forward iter + last-match-index. Cited
inline at strings.ww.

Unblocked by #13 (d2c64bc) — pre-#13 the test rows
"strings.index" + "bytes.index" would collide on
*.index_match_next_1 labels in combined.s.

index_cases + rindex_cases per-row inline-match (sister convention of
byteindex_str/rune_cases at stringstest.ww:147-231; sum-type-needle
single-struct table awkward). Bisect via signalled = 1400+i / 1500+i.
Rune-vs-byte pin rows: "こんにちは"+"ちは" → 3 (byte 9),
"またあったね"+"た" rindex → 4 (byte 12). Void miss + 4-byte rune
multibyte coverage.

make test 125/125; ww2==ww3==ww4 byte-id holds via 995_self_rebuild.
2026-05-19 04:12:42 +09:00
c305b04cb5 lib/strings+test: graduate trim/ltrim/rtrim to Hare rune... variadic
ltrim(input, exclude: rune) -> ltrim(input, trim: rune...) per
ref/hare/strings/trim.ha:11. rtrim mirrors via riter/prev per :32.
trim thin wrapper ltrim(rtrim(input, trim...), trim...) per :54.
Unblocked by #15 (5609d04) — multi-rune iter+match-prev composition
in non-leaf callees now produces byte-identical asm + correct runtime
on both stages.

Subset: 0-arg call (len(trim)==0) returns input unchanged. lib/bytes
whitespace-default future commit will graduate strings.trim's
whitespace... fast path.

ltrim_cases / rtrim_cases / trim_cases table-driven via parallel runes
+ inputs + argo/argn + want arrays + loop. Hare ref/hare/strings/trim.ha:75
vectors covered: bracket-pair, "mississippi" multi-rune, "yellowwooddoor"
multi-rune, Sentimentalized long form, plus 0-arg cases and 4-byte rune
"abacadabra"/"ahi" coverage. Bisect via signalled = 1100/1200/1300 + i.

make test 122/122; ww2==ww3==ww4 byte-id holds via 995_self_rebuild.
2026-05-19 02:32:17 +09:00
5466ea048d lib/strings+test: graduate concat to Hare str... variadic
concat(a, b: str) -> concat(strs: str...) per ref/hare/strings/concat.ha:5.
Drop nomem return per project no-alloc-error idiom (os.alloc aborts).
Unblocked by #16 variadic-pack store fix (3bd9b1d).

concat_cases rewritten to table-driven: flat pool + argo/argn parallel
arrays + slice-spread call. 9 rows cover Hare concat.ha:18 vectors
(0/1/2/3-arg, multibyte) plus empty-mid/first/last/2-empty edges.
Bisect via signalled = 200 + i.

trim/contains variadic held on task #36 — surfaced by worker-variadic
pre-flight: iter + match prev composition in non-leaf callees still
hits scanlocals offset divergence. Resolves via #15 size-strategy.

make test 121/121; ww2==ww3==ww4 byte-id holds.
2026-05-19 00:40:33 +09:00
049ebc14a1 selfhost+lib+test: route cgcall + nodeis{slice,str} N_DOT through fnretlookupmod (#34)
Class A silent miscompile, surfaced by landing strings.slice in
Hare's natural delegation form `fromutf8_unsafe(utf8.slice(begin,
end))` (ref/hare/strings/iter.ha:75). strings.slice itself returns
str, so the inner utf8.slice (cross-module N_DOT) call's cgcall
return-ABI fixup hit post-#4e fnretlookup's same-module-first walk
and grabbed strings.slice's own str return — emitted a spurious
`MOVQ DX, BX` after the cross-module CALL even though utf8.slice
returns []u8 (selfhost/cmd/wcc/cgenexpr.ww cgcall return-ABI fixup,
line 3249-3261 pre-fix). Every other consumer of cgcall:3249's
str-shuffle decision sat on the same bare-leaf table and was
silently miscompiling on the same collision shape pre-#34.

Sibling: nodeisslice + nodeisstr N_CALL arms in
selfhost/cmd/wcc/cgenutil.ww were N_IDENT-only — for a cross-
module N_DOT call returning a slice or str, pushargsrev fell
through to the natural 1-word PUSHQ AX, dropping the `.len`
(and `.cap` for slices) of the return value when consumed as a
call arg. strings.slice's body passes utf8.slice's []u8 result
to fromutf8_unsafe; pre-fix wwstage pushed 1 word vs cstage's
3, breaking the receiver's slice-3-pop drain.

Cstage carries no sister bug: cmd/w6c/cgen.c reads return shape
from the typed `n->lhs->type` (TY_FN sig) for both str-shuffle
and slice-/str-arg push counts — module-aware via the typed AST,
sidestepping any bare-leaf table. Mirror of #4e's cstage-no-
sister-bug note.

Fix: route cgcall return-ABI fixup + nodeisslice/nodeisstr N_CALL
arms through fnretlookupmod with `callee.lhs.str` (N_DOT
qualifier) or `c.curmod` (N_IDENT). Mirror of #28
fnparamslookupmod / #31 fnretlookupmod N_DOT re-routing.
Remaining bare-leaf fnretlookup consumer sites (~8 sites across
cgenexpr/cgenutil/cgenstmt/cgendecl listed in task #34a) stay
on the graduated bare-leaf path — none of the present-corpus
N_DOT leaf collisions have return-shape divergence at those
sites. A future stdlib port introducing a return-shape-divergent
same-leaf N_DOT collision will need the *mod re-routing — filed
as #34a sibling-latents.

Bundled three concerns per rule 11: cgcall fix, nodeisslice/
nodeisstr fix, and strings.slice retire + sentinel. (a) alone
leaves strings.slice byte-id breaking on slice-arg push count.
(b) alone leaves a phantom MOVQ DX, BX on the inner cross-
module CALL. (c) alone fails 995_self_rebuild without (a)+(b).
The three cannot land separately bisect-cleanly; the 745
sentinel pins the primary repro (cgcall str-shuffle) which
sentinel-flips on a cgcall:3257 revert.

745_fnret34_modshadow pins the fix with 1 row: caller.slice
returns str (same leaf as the cross-module callee, divergent
return shape); caller.run calls myutf8.slice returning []u8.
Asserts CALL myutf8.slice present inside caller.run TEXT +
`MOVQ DX, BX` anti-check on each stage plus cs-vs-ws byte-id.

strings.slice retired in lib/strings/strings.ww: the deferral
block becomes the natural Hare delegation form with two local
utf8.decoder reconstructions for the iterator endpoints — ww
has no anonymous-embed (parallel to the existing `move` helper).
iter_slice_cases mirrors ref/hare/strings/iter.ha:110-127;
sidesteps the Hare `let t = s;` iterator-copy via fresh
strings.iter() to stay clear of #35's sibling latents.

119/119 ok. ww2 == ww3 == ww4 byte-id holds.
2026-05-18 23:37:16 +09:00
aa8d15182a lib/strings+test: Hare port (prev + riter + iterstr + position)
Port four of the five c3 strings functions per ref/hare/strings/iter.ha;
strings.slice deferred behind task #34 (wwstage fnretlookup same-name
cross-module phantom return-ABI fixup, sub-bug of #4e).

  - prev     ref/hare/strings/iter.ha:49
  - riter    ref/hare/strings/iter.ha:32
  - iterstr  ref/hare/strings/iter.ha:63
  - position ref/hare/strings/iter.ha:82

Also adds a private move() helper (ref/hare/strings/iter.ha:51) shared
by next/prev. Hare's move picks the utf8 function via a fn-pointer
(`let fun = if (forward) &utf8::next else &utf8::prev`); ww has no
fn-pointers in scope yet, so move branches on `forward` and calls
utf8.next or utf8.prev directly at each site.

strings.next is updated to dispatch via move(!it.reverse, it) — c2's
implementation always called utf8.next regardless of iter.reverse,
which was correct for forward iter() but would walk forward on
riter()-produced iterators too. With riter landed in this commit,
next() now correctly walks backward on reverse iterators per Hare's
ref/hare/strings/iter.ha:45. No in-tree consumer regression: only
stringstest constructs iterators today.

strings.iterstr uses ww's `[lo:hi]` slice syntax instead of Hare's
`[lo..hi]`; same semantics (borrowed []u8 view).

strings.slice deferred — Hare's body is
`fromutf8_unsafe(utf8::slice(begin, end))` (ref/hare/strings/iter.ha:76).
That delegation form triggers a wwstage fnretlookup miscompile when
the caller module has a function of the same name as the callee
(here: both strings::slice and utf8::slice exist), causing wwstage
to emit the wrong return-ABI fixup (MOVQ DX, BX, str's AX/DX→AX/BX
shim) after the cross-module CALL. Cstage handles the collision
correctly; wwstage routes through the same-module function's
return type and the byte-id checks (993_ww_ww + 995_self_rebuild)
trip. Filed as task #34 with minimal repro; will land strings.slice
when the fnretlookup graduate-by-module sub-bug in #34 is fixed.
Top-of-file divergence note lists slice in the deferred set; the
landing site keeps a comment-only stub. The public strings c3
surface ships 4-of-5 in this commit; slice + #34 land together in
a follow-up.

Tests: 5 new @test fns in stringstest.ww (signalled 22-26):
iter_prev_at_start_cases (prev at offs=0 → done),
iter_prev_ascii_cases (round-trip on forward iter),
iter_full_cases (mirror of ref/hare/strings/iter.ha:84-108 — iter
"こんにちは" with mid-walk iterstr + prev + next, then s = riter(...)
sret-into-existing-slot for the reverse iterator pass),
iter_position_cases (position tracks offs through a multibyte walk),
iter_iterstr_reverse_cases (riter iterstr is bytes BEFORE the cursor,
dual to forward iter's bytes-AFTER).

The Hare @test fn iter body uses `s = riter("にちは")` mid-test to
swap the iterator's direction (ref/hare/strings/iter.ha:101); ww's
sret-into-existing-slot path handles that fine (probed pre-port).
struct-copy let-from-ident (task #32) is sidestepped because no
test creates a duplicate iterator via `let dup = it;`.

117/117 ok. 995_self_rebuild stays green (ww2==ww3==ww4 byte-id).
993_ww_ww + 994_w6c_ww also green (cstage/wwstage byte-identical
on every corpus input including selfhost/cmd/wwdump/main.combined.ww).
2026-05-18 22:32:13 +09:00
9526d21007 lib/encoding/utf8+test: Hare port (prev + slice + position + remaining)
Port four deferred utf8 functions per c2 backlog (utf8.ww:18
pre-port). All are non-trivial enough that the test rows mirror
Hare's @test fn decode/slice bodies (ref/hare/encoding/utf8/
decode.ha:85-198) row-for-row.

  - prev       ref/hare/encoding/utf8/decode.ha:52-71
  - remaining  ref/hare/encoding/utf8/decode.ha:74
  - slice      ref/hare/encoding/utf8/decode.ha:80-83
  - position   ref/hare/encoding/utf8/decode.ha:203

prev walks back from d.offs to a byte that could start a codepoint
(state-0 dfa cell != -1), re-decodes forward from there, and
confirms the forward decode lands back at the original offset.
Returns done at start-of-input; invalid if no initial byte appears
within 4 steps (no legal UTF-8 codepoint exceeds 4 bytes) or if
the forward decode shortcircuits to more/invalid or lands at a
different offset than expected.

Two structural deltas from the Hare source:

  - Hare's `for (d.offs < len(d.src); d.offs -= 1)` relies on
    size_t wrap-around to exit when offs underflows past 0; ww's
    offs is i32 (utf8.ww:204), so the same exit is spelled
    `d.offs >= 0`.

  - Hare's `defer d.offs = t` restores offs after the return; ww
    has no defer, so the restore is inlined in each match arm.

slice asserts the Hare precondition (same source + begin.offs <=
end.offs) via abort; Hare uses assert(). position is a one-liner
returning d.offs (Hare uses size, ww uses i32 per lib/CLAUDE.md
"indices use the underlying length type").

The `bi: i32 = b: i32;` indirection in prev's dfa lookup is
required because `dfa[b: i32]` parses as a slice expression
`dfa[b : i32]` where `i32` becomes the upper bound. The let-binding
matches the existing pattern at utf8.ww:236-238 in the c1 next()
port.

Tests: 14 new @test fns in utf8test.ww (signalled 22-35):
prev_done_at_start, prev_one/two/three/four_byte (round-trip
forward+reverse), prev_mixed_roundtrip (full forward then full
reverse on the same こんにちは+NUL input Hare uses at
decode.ha:85-111), prev_continuation_only_more (Hare's
[0xA0,0xA1] more case at decode.ha:117), prev_incomplete /
surrogate / overlong / extracont_invalid (decode.ha:120-150),
prev_max_in_range (decode.ha:158-163; pins state-7 acceptance via
reverse decode), prev_min_out_of_range (decode.ha:166-169; the
only case that trips prev's 4-step-bound arm), and
remaining_slice_position mirroring decode.ha:172-198.

The Hare slice @test idiom `let d2 = d1` (struct copy) miscompiles
in both stages (cstage + wwstage zero-init the rhs instead of
copying — task #32, Class A but bootstrap-byte-id-symmetric, so
995 doesn't catch it). The ww test uses two parallel
`decode(src[0:16])` calls to produce two decoders with the same
src.ptr; coverage on slice() is equivalent (exercises the same
source-different-offs pattern). Divergence cited inline.

117/117 ok. 995_self_rebuild stays green (ww2==ww3==ww4 byte-id).
2026-05-18 22:00:15 +09:00
9e0816e199 cmd+selfhost+lib+test: directory-as-module enumeration in driver (#22)
Replace the cmd/ww + selfhost driver's file-walk import resolver
with true directory enumeration. `import encoding.utf8;` now finds
the lib/encoding/utf8/ directory and concatenates every *.ww file
in it (excluding *test.ww and the driver's *.combined.ww artifacts)
in byte-wise sorted order, instead of just finding the single
lib/encoding/utf8/utf8.ww file. Mirrors Hare's
hare/module/srcs.ha:183 _findsrcs minus tag handling.

Lookup order in both stages: (1) <dir>/<dot-as-slash>/ as directory
→ enumerate. (2) <dir>/<dot-as-slash>.ww as file. The legacy
<dir>/<name>/<name>.ww shape from #18's retained divergence is
dropped per rule-9 Hare-fidelity — Hare has no foo/foo.ha fallback;
a module IS the directory.

Symmetric across cstage (cmd/ww/main.c via opendir+qsort+stat) and
wwstage (selfhost/cmd/ww/main.ww via existing lib/os.getdents64 +
os.stat — no new lib/os surface needed; the rundirtests() walker
in main.ww from #18 was the model). Bootstrap ww2.s==ww3.s==ww4.s
byte-identical post-change.

Bundling justification (rule 11): strict-same-package validation is
bundled because the failure mode is dir-enum's own (a non-dir-enum
compilation unit cannot trigger mismatch across enumerated files).
The natural enforcement site is the driver — the parser can't
distinguish dir-enum concat from file-walk concat. Both stages
peek each file's first `package <name>;` line in expand_dir /
expanddir and exit(1) on mismatch with a precise error pointing
at the offending file. Hare's hare/module/srcs.ha:131 has the
same constraint via its README gate. Other half of #23 (strict
missing-package error tightening — 63 inline-source test wrappers
blocker) stays deferred per its filing.

Parser side (cmd/wcc/parse.c parseuse + lib/ww/parse/decl.ww
parseuse): n->str now carries only the LEAF identifier from a
dotted import. With the driver translating the full dotted path
to a directory walk, the checker only needs the package bareword
(last component) for the N_USE → decl disambiguation walk in
check.c's src_imports / decl_mod. Mirrors Hare's
`use encoding::utf8;` → `utf8::name` semantics
(ref/hare/hare/ast/import.ha:7).

Migration: lib/ww/sym.ww drops `import typ; import ast;`;
lib/ww/parse/parse.ww drops `import expr; import stmt; import
decl;`; lib/ww/lex/lex.ww drops `import tok;` — all sibling
imports auto-resolve via the new dir-enum when callers import the
package directory. lib/strings/, lib/encoding/utf8/utf8test.ww
migrate `import utf8;` → `import encoding.utf8;`. Makefile drops
-I lib/encoding/utf8 stopgap from wwdump_ww + w6c_ww. Seven test
wrappers (700_e2e, 966_strings_run, 970_fmt_run, 971_log_run,
972_fnmatch_run, 982_getopt_run, 990_selfhost) and 995_self_rebuild
drop the -I lib/encoding/utf8 runtime stopgap.

Tests: new 737_direnum C wrapper + test/wcc/data/direnum/ fixtures
pin (a) cross-pkg multi-file dir-enum build at runtime (both stages
must succeed) and (b) strict-same-package mismatch error (both
stages must surface "differs from" + exit non-zero). 738_module_decl
gains row 6 pinning the n_use->str leaf-only storage post-parser
change.

Retained workaround at selfhost/cmd/ww/main.ww expanddir loop:
`names[i][k]` nested-deref-then-index split into
`let nm: *u8 = names[i]; nm[k]` because wwstage cgen miscompiles
the chained form (treats inner u8 element as 8B sizeof *u8 instead
of 1B sizeof u8: extra MOVQ $8 + IMULQ on the inner index, MOVQ
instead of MOVZBQ load). Inline rule-8 WHY comment cites task #24
(wwstage cgen chained-index inner element size on **T). Two-step
form routes through the bare-pointer index path which both stages
handle byte-identically.

Class A wwstage cgen UNDER (chained-index inner element size on
**T) surfaced first time the codebase exercises the **T[i][k]
shape via enumeratedir() — corpus-coverage-blind landmine pattern,
same family as the trio (#27/#28/#31) from STATUS-5.

112/112 ok. ww2 == ww3 == ww4 byte-id holds.
2026-05-18 19:22:27 +09:00
79d9528a00 toolchain+lib+test: Go-style package/import keywords (#18)
User-mandated language redesign: source files declare their own
namespace via the new `package <name>;` keyword and pull dependencies
via `import <path>;`. Both keywords use Plan-9 `.` separator (user
override on Hare's `::` — `import encoding.utf8;`). Internal token-
kind enum values TK_MODULE=86 and TK_USE=17 kept stable for 990
wwdump byte-diff symmetry; only kwtab strings + tokname spellings
rotated. Executables (selfhost/cmd/{ww,w6c,w6a,w6l,wwdump}/main.ww)
declare `package main;` per Go convention; lib/ + selfhost/cmd/wcc/
files declare their parent-dir basename.

One-commit bundle per the brief's all-at-once directive: a per-stage
split breaks bootstrap byte-id mid-rewrite (cstage with new keyword
can't parse old `module`/`use` files and vice-versa). Body documents
the bundle per rule 11.

Two retained divergences from the user's stated ask, both filed per
rule 7 / rule 8 with inline task pointers at the deferred sites:

  Task #22 — Directory-as-module enumeration in the driver. User
  asked: "module is combination of files in directory" (golang/hare
  shape). After this commit lib/ww/{ast,sym,typ}.ww all declare
  `package ww;` but are still pulled into the compilation unit via
  explicit sibling `import` chains (sym.ww does `import ast;` etc.),
  not via dir enumeration. The cstage scaffold for true dir
  enumeration was drafted and reverted because the symmetric wwstage
  port requires a ww-side opendir/readdir wrapper around getdents64
  (~150-200 lines new ww). Inline citation at locate_import_in /
  locatein in both stages points to task #22.

  Task #23 — Parser strict missing-`package` error. The original
  brief mandated: parser errors when a .ww source omits `package
  <name>;` as its first non-comment item. Softened here to silent-
  default because 63 test wrappers (200_parse, 100_lex, 300_check,
  400_w6c, ..., the inline-source-fragment family) build ad-hoc ww
  source strings that lack `package` and the strict error cascaded
  into 60+ test failures. Migration is mechanical-sed but deferred
  so this commit ships green. Inline citation at parsefile in both
  stages points to task #23.

Node.module renamed to Node.nmod and modent.module to modent.nmod
in wwstage source — the field name `module` would collide with the
freshly-reserved TK_MODULE token. The rename is left in place as
clean separator between AST-field-name and reserved-keyword
namespaces. Cstage's n->module retained — C has no `package` or
`module` keyword.

rt/ensure.ww deliberately ships WITHOUT a package declaration so
its `export fn rt_ensure` keeps the bare linker symbol; adding
`package rt;` would mangle to `rt.rt_ensure` and break libwwrt.a
linkage. Documented at the file head.

111/111 ok (110 + new 738_module_decl sentinel). 995_self_rebuild
byte-id holds (ww2 == ww3 == ww4). All 5 frozen
selfhost/cmd/*/main.combined.ww regenerated under the new driver.
CLAUDE.md rule 5 amended with the language-layer divergence note.
2026-05-18 18:25:36 +09:00
d09197af8e lib/strings+test: Hare port (iterator + next)
Forward UTF-8 rune cursor per ref/hare/strings/iter.ha; iterator
flattens Hare's anon-embedded utf8::decoder to explicit
offs/src/reverse fields, next() copy-in/copy-out a local decoder
and aborts on more/invalid per move()'s discipline.

The iterator flattens Hare's anonymous-embedded utf8::decoder
(ref/hare/strings/iter.ha:6-9) to explicit offs/src/reverse
fields because ww has no anon-embed syntax. reverse is retained
on the struct so riter populates it once utf8.prev (reverse DFA)
and strings.prev land.

next() copies the iterator's offs/src into a local utf8.decoder,
delegates to utf8.next, then writes offs back; copy-in/copy-out
is the cost of the flattened layout. more/invalid arms abort with
"strings.next: invalid UTF-8", mirroring Hare's move()
(ref/hare/strings/iter.ha:51-58) which aborts unconditionally
on those arms.

Deferred surface (no in-tree caller; follow-up tasks): prev, riter,
iterstr, slice, position, move. prev specifically needs utf8.prev
(reverse DFA), which isn't on the lib/encoding/utf8 surface yet.

lib/strings/strings.ww moves off test/wcc/900_stdlib.c's
standalone-compile list per the existing bufio/fmt/os precedent:
the iterator's (rune | utf8.done) return type and the local
utf8.decoder reference need cross-module type resolution, which
the standalone w6c path doesn't do. Runtime coverage stays at
966_strings_run, which now exercises 21 signalled cases (was 15).

The iterator's Hare-faithful `reverse: bool` field surfaced #33
(cstage cgen narrow-sret-field copy mis-width) during this port's
pre-flight; that fix landed at 0d96196 ahead of this commit.
Future bisecters tracking a narrow-field-related regression in
lib/strings or sret-aware stdlib growth should consult #33 + this
commit's bracket. The 4-arm match on utf8.next return surfaced
#31 (wwstage fnretlookupmod) earlier in the same chain (b787641).

Tests:
  - 6 new @test fns in stringstest.ww (signalled 16-21): empty,
    ASCII, 2-byte (café), 3-byte (こんにちは), 4-byte (🦀rust),
    mixed-width ("Hello, 世界! 🌍"). Each verifies forward
    iteration, done@EOI, and repeated next-after-done stays done
    (iter_empty). Multibyte literal limitation handled via
    `0xE9u32: rune` cast per existing pattern at stringstest.ww:82.

104/104 ok. 995_self_rebuild stays green (ww2==ww3==ww4 byte-id).
2026-05-18 12:16:22 +09:00
a651883c14 lib/strings+test: Hare port (dup/concat/trim/index/contains/has{pre,suf}fix/compare/utf8)
Hare-faithful index/predicate family per ref/hare/strings/{dup,
concat,trim,index,suffix,contains,compare,utf8}.ha. Non-variadic
subset (concat 2-arg, trim single-rune, contains single-needle)
pending task #16 — cstage variadic-pack drops .len on multi-field
element types; ship the Hare-faithful single-arg shape now, file
the variadic upgrade as follow-up. `sub` follow-up filed as #29
(commit 2 with iterator + utf8.chars relocation).

Surface: dup, concat, trim/trimprefix/trimsuffix (single rune),
hasprefix, hassuffix (both with (str|rune) sum needle),
byteindex, rbyteindex (both with (str|rune) sum needle),
contains (single str needle), compare, toutf8, fromutf8_unsafe,
runebytes helper. (str|rune) match arms route the rune via
utf8.encoderune into a [4]u8 scratch then bytes.index/rindex —
drew-devault's directive for clean Hare-fidelity over invented
ASCII-only rune-byte arms.

byteindex / rbyteindex rune-arm semantic correction —
corpus-coverage-blind unmask. Pre-existing impl scanned for
`r: u8` (broken for all rune values >0x7F since strings.ww first
landed; no caller exercised it). Replaced with utf8.encoderune-
based scan via runebytes helper. Severity-marker: silent
wrong-result for any non-ASCII rune needle, masked by zero
in-tree callers until lib/strings + utf8 chain pulled the shape
in.

Build-system propagation: lib/strings depends transitively on
lib/encoding/utf8 (via byteindex's rune arm). cmd/ww driver's
locate_import_in (cmd/ww/main.c:85) walks `<dir>/<name>.ww` and
`<dir>/<name>/<name>.ww` only — `use utf8;` doesn't find
lib/encoding/utf8/utf8.ww without explicit `-I lib/encoding/utf8`.
Propagated through 5 wwstage-tool Makefile targets + 7 test
wrappers + test/wcc/995_self_rebuild.c sprintf lines. Task #17
filed for the principled resolver fix (subdir walk vs Hare's
qualified `use encoding::utf8;` notation).

This commit chain (#15 strings) surfaced 7 cgen bugs during
landing: #16 cstage variadic-pack, #17 resolver nested-paths,
#27 aliaslookup leaf-collision, #22 zero-init !void/void-alias
let-decl, #15-cstage retscr SSoT name, #24 composite CALL return
as composite arg, #28 N_DOT calleeparams. All blocking ones
fixed (#16/#17 deferred-with-stopgap, others fixed in their
respective commits). Pre-flight + stop-and-surface discipline
held throughout — no workarounds shipped in stdlib.

Tests:
  - 966_strings_run drives lib/strings/stringstest.ww via ww run.
    15 @test fns: dup (alloc, multibyte), concat (empty, lopsided,
    multibyte), trim/ltrim/rtrim incl. 4-byte rune U+1D68A,
    hasprefix/hassuffix with (str|rune) incl. multibyte,
    byteindex/rbyteindex both arms 1/2/3/4-byte rune coverage,
    compare. Cited from ref/hare/strings/+test.ha where vectors
    apply.

100/100 ok. 995_self_rebuild stays green (ww2==ww3==ww4 byte-id).
2026-05-18 10:28:44 +09:00
a6abac22d8 lib/bytes+test: Hare port (equal / index / rindex / contains / has{prefix,suffix} / reverse / zero)
Mirrors ref/hare/bytes/{equal,index,contains,reverse,zero}.ha for
the in-tree subset used by lib/encoding, lib/bufio, lib/memio;
converts 4 hextest sites from local beq to bytes.equal and drops
the now-dead beq in utf8test.

Surface:
  - equal(a, b: []u8) bool
  - index(s: []u8, needle: (u8 | []u8)) (i32 | void)
  - rindex(s: []u8, needle: (u8 | []u8)) (i32 | void)
  - contains(s: []u8, needle: (u8 | []u8)) bool
  - hasprefix(s, pre: []u8) bool
  - hassuffix(s, suf: []u8) bool
  - reverse(s: []u8) void  (already present, citation added)
  - zero(s: []u8) void  (already present, citation added)

Two documented Hare-fidelity gaps (cited in lib/bytes/bytes.ww
header, no in-tree caller demands them yet):
  - index_slice / rindex_slice use naive O(n·m). Hare specialises
    2/3/4-byte needles + falls back to Crochemore-Perrin two-way
    (ref/hare/bytes/two_way.ha). Correctness equivalent.
  - contains takes a single needle. Hare uses variadic
    needle: (u8 | []u8)... (ref/hare/bytes/contains.ha:5).

Tests: 967_bytes_run drives lib/bytes/bytestest.ww via ww run.
Eight @test fns × table-driven row sets: equal (5), index_byte
(5), index_slice (10), rindex_byte (3), rindex_slice (3),
contains (4), hasprefix (6 verbatim from contains.ha:25),
hassuffix (6 verbatim from contains.ha:40).

Call-site conversions in the same commit (the conversions are
the proof the API is wired): lib/encoding/hex/hextest.ww drops
the local beq helper and 4 callers switch to bytes.equal;
lib/encoding/utf8/utf8test.ww drops the dead beq helper.

91/91 ok. 995_self_rebuild stays green (ww2==ww3==ww4 byte-id).
2026-05-18 00:48:14 +09:00
a7700f201b lib/fmt/fmttest: inline -2.5 literal in 3 f64 rows (post-#40)
Pre-#40 wwstage's UNTYPED-float fold for `-2.5` was broken, so the
three f64-sign tests (fprintf_f64_neg, fprintf_f64_sign_plus,
asprintf_f64) used a `let nv: f64 = -2.5;` indirection to dodge it.
With #40 (session-3 commit 4d6a19f) landed, the wwstage variant-
widen N_UNARY-of-N_FLOATLIT fold renders the literal directly on
both stages. Indirection inlined; workaround comment blocks dropped.
Kept the sign-fold WHY in fprintf_f64_sign_plus (genuine semantic,
not a workaround note).
2026-05-18 00:30:58 +09:00
225ee97f5c lib/os: drop kstat.mode typed-alias workaround (post-#33)
Pre-#33 workaround widened kstat.mode to a typed alias to dodge
an MOVL emit issue. With #33 (session 3) landed, plain u32 works
and matches both the kernel SYS_newfstatat struct layout (st_mode
is unsigned int) and Hare's sys/+linux/types.ha:120 st.mode width.
The cast-to-mode at fillfilestat is retained (kstat.mode is a u32
holding mode-typed bits, and the cast carries that intent).

Keeps the kstat surface internally consistent with its other raw-
primitive fields (uid: u32, gid: u32, ino: u64, …). A future
Hare-fidelity pass can graduate kstat to the t-suffixed aliases
({uid,gid,mode,…}_t) but that's a separate consolidation.
2026-05-18 00:30:49 +09:00
793734c1e0 lib/encoding/utf8+test: Hare port (decoder / next / encoderune / runesz / utf8sz / validate)
Hoehrmann DFA from ref/hare/encoding/utf8/decodetable.ha flattened
to 1D [2048]i8 (task #20: 2D-array jagged cgen still pending);
encoderune takes a caller buffer matching lib/encoding/hex.encode;
done/more/invalid all spelled as plain void aliases per lib/io's
eof precedent. Surface ports decoder + decode + next + encoderune
+ runesz + utf8sz + validate from ref/hare/encoding/utf8/{types,
decode,encode,rune}.ha. next() polarity rewritten from Hare's
`(state-1):uint >> 31` to an explicit `if state == 0` branch
because ww's uint is 64-bit (cmd/wcc/type.c:58); same effect, no
hidden 32-bit assumption.

Deferred (no in-tree callers): prev, slice, position, remaining,
appendrune, strencode, strdecode. String iteration (chars/
newchars/nextchar in the session-4 draft) dropped per Hare
discipline — belongs in lib/strings::iterator, not encoding/utf8.

Tests:
  - 968_utf8_run drives lib/encoding/utf8/utf8test.ww via ww run.
    21 @test fns: boundaries (ASCII, 2-byte, 3-byte, 4-byte
    encode/decode), surrogate/overlong/out-of-range/bad-continuation
    reject, max-in-range (U+10FFFF) accept, truncated→more, done@EOI,
    validate empty/mixed/malformed, encode/decode roundtrip. Two
    rows ported from ref/hare/encoding/utf8/decode.ha @test that
    were missing in the session-4 draft: bad-continuation
    [0xC2,0xFF]→invalid and max-in-range [0xF4,0x8F,0xBF,0xBF]→
    U+10FFFF.
  - 9xx stdlib runtime slot range extended from 970-989 to 960-989
    to accommodate utf8 at 968 (970-989 block was full).

90/90 ok. 995_self_rebuild stays green (ww2==ww3==ww4 byte-id).
2026-05-17 23:46:34 +09:00