From f80927201b6e81048be45f510e36b88a0d8fb1f2 Mon Sep 17 00:00:00 2001 From: Hojun-Cho Date: Wed, 20 May 2026 15:22:21 +0900 Subject: [PATCH] tools/sizelint + CLAUDE.md rule 13: gate hardcoded size literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: ` 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. --- CLAUDE.md | 1 + Makefile | 7 +- cmd/wcc/check.c | 9 +- cmd/wcc/type.c | 4 +- lib/ww/typ.ww | 10 ++- selfhost/cmd/w6c/main.combined.ww | 14 +-- selfhost/cmd/wcc/check.ww | 4 +- selfhost/cmd/wwdump/main.combined.ww | 14 +-- tools/sizelint | 124 +++++++++++++++++++++++++++ 9 files changed, 162 insertions(+), 25 deletions(-) create mode 100755 tools/sizelint diff --git a/CLAUDE.md b/CLAUDE.md index 9e4fd3c1..b4eb01d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,3 +10,4 @@ 10. Symmetric stages. cstage and wwstage MUST emit byte-identical asm for the same input. When inference power differs, align the richer side DOWN to the leaner side, not the other way. 11. Split commits when they bundle unrelated concerns. Bisect-cleanliness is the default. Multi-fix commits need a body paragraph explaining why they couldn't split. 12. Simple data, simple algorithms. Sea-of-stars style. Mirror Hare's structural choices over clever alternatives. +13. No hardcoded size literals in size-computation contexts. Always route through the type table (tinfo.size / Type.size / ty_*->size / size(T) / primtypesize / tyslicesize). Per Drew's framing of Hare's discipline. `make sizelint` enforces and runs as a dep of `make test`. Exemptions documented inline with `// sizelint-ok: ` (ww) or `/* sizelint-ok: */` (C). Optional local enforcement: `ln -s ../../tools/sizelint .git/hooks/pre-commit`. diff --git a/Makefile b/Makefile index 05286937..1b3ce205 100644 --- a/Makefile +++ b/Makefile @@ -926,7 +926,10 @@ $(BIN)/test_random_run: test/wcc/999_random_run.c $(BIN)/ww $(BIN)/w6c \ $(BIN)/w6a $(BIN)/w6l $(LIB)/libwwrt.a | $(BIN) $(CC) $(CFLAGS) -o $@ $< -test: all $(TESTS) +sizelint: + @sh tools/sizelint + +test: all sizelint $(TESTS) @WW=$(BIN)/ww BIN=$(BIN) sh test/run install: all @@ -1074,4 +1077,4 @@ nocc: @echo @echo "NOCC OK: $(STAGE0)/* reproduces itself from source. cc not invoked." -.PHONY: all cstage wwstage test install clean bootstrap nocc bootstrap-snapshot +.PHONY: all cstage wwstage test sizelint install clean bootstrap nocc bootstrap-snapshot diff --git a/cmd/wcc/check.c b/cmd/wcc/check.c index e75e05db..dac9e892 100644 --- a/cmd/wcc/check.c +++ b/cmd/wcc/check.c @@ -998,7 +998,8 @@ cexpr(Checker *c, Node *n) vp->type = pt; vp->next = ve; ve->type = ty_nomem; ve->next = NULL; tt->params = vp; - tt->size = 16; + /* #64: tag (8) + max-variant payload, per resolve_type:433. */ + tt->size = 8 + pt->size; tt->align = 8; n->type = tt; n->lhs->type = ty_err; @@ -1074,7 +1075,8 @@ cexpr(Checker *c, Node *n) vs->type = st; vs->next = ve; ve->type = ty_nomem; ve->next = NULL; tt->params = vs; - tt->size = 32; + /* #64: tag (8) + slice payload, per resolve_type:433. */ + tt->size = 8 + st->size; tt->align = 8; n->type = tt; n->lhs->type = ty_err; @@ -1525,7 +1527,8 @@ clet(Checker *c, Node *n) vs->type = st; vs->next = ve; ve->type = ty_nomem; ve->next = NULL; tt->params = vs; - tt->size = 32; + /* #64: tag (8) + slice payload, per resolve_type:433. */ + tt->size = 8 + st->size; tt->align = 8; call->type = tt; if (wrap) { diff --git a/cmd/wcc/type.c b/cmd/wcc/type.c index a91cd033..06e97fa6 100644 --- a/cmd/wcc/type.c +++ b/cmd/wcc/type.c @@ -61,7 +61,7 @@ typesinit(Arena *a) ty_f32 = prim(a, TY_F32, "f32", 4, 4); ty_f64 = prim(a, TY_F64, "f64", 8, 8); /* str is { *u8, len } — 16 bytes on amd64. ABI: pointer + u64. */ - ty_str = prim(a, TY_STR, "str", 16, 8); + ty_str = prim(a, TY_STR, "str", 16, 8); /* sizelint-ok: SSoT for ty_str (#64) */ ty_err = prim(a, TY_ERR, "", 0, 1); ty_never = prim(a, TY_NEVER, "never", 0, 1); /* #29: predeclared `type nomem = !void;`. NAMED so variant_match @@ -98,7 +98,7 @@ type_slice(Arena *a, Type *sub) { Type *t = newtype(a, TY_SLICE); t->sub = sub; - t->size = 24; /* { *T, len, cap } */ + t->size = 24; /* { *T, len, cap } */ /* sizelint-ok: SSoT for ty_slice (#64) */ t->align = 8; return t; } diff --git a/lib/ww/typ.ww b/lib/ww/typ.ww index 6b434330..4a8ddef0 100644 --- a/lib/ww/typ.ww +++ b/lib/ww/typ.ww @@ -186,7 +186,7 @@ export fn typesinit(c: *tctx, a: *arena) void = { c.tyuintptr= prim(a, tykind.TY_UINTPTR, "uintptr", 8u64, 8u64); c.tyf32 = prim(a, tykind.TY_F32, "f32", 4u64, 4u64); c.tyf64 = prim(a, tykind.TY_F64, "f64", 8u64, 8u64); - c.tystr = prim(a, tykind.TY_STR, "str", 16u64, 8u64); + c.tystr = prim(a, tykind.TY_STR, "str", 16u64, 8u64); // sizelint-ok: SSoT for tystr (#64) c.tyerr = prim(a, tykind.TY_ERR, "", 0u64, 1u64); c.tynever = prim(a, tykind.TY_NEVER, "never", 0u64, 1u64); @@ -210,9 +210,9 @@ export fn typeptr(a: *arena, sub: *tinfo) *tinfo = { export fn typeslice(a: *arena, sub: *tinfo) *tinfo = { let t: *tinfo = newtype(a, tykind.TY_SLICE); t.sub = sub; - t.size = 24u64; + t.size = 24u64; // sizelint-ok: SSoT for slice header (#64) t.align = 8u64; - t.slotsize = 24u64; + t.slotsize = 24u64; // sizelint-ok: SSoT for slice slotsize (#64) return t; }; @@ -270,7 +270,9 @@ export fn tinfocachelookup(c: *tctx, key: *node) *tinfo = { }; export fn tinfocachebind(c: *tctx, key: *node, val: *tinfo) void = { - let e: *tinfocacheent = amalloc(c.a, 32u64): *tinfocacheent; + // #36 (typed amalloc) — over-size the 24B tinfocacheent struct to + // dodge the cstage amalloc", 0u64, 1u64); c.tynever = prim(a, tykind.TY_NEVER, "never", 0u64, 1u64); @@ -6637,9 +6637,9 @@ export fn typeptr(a: *arena, sub: *tinfo) *tinfo = { export fn typeslice(a: *arena, sub: *tinfo) *tinfo = { let t: *tinfo = newtype(a, tykind.TY_SLICE); t.sub = sub; - t.size = 24u64; + t.size = 24u64; // sizelint-ok: SSoT for slice header (#64) t.align = 8u64; - t.slotsize = 24u64; + t.slotsize = 24u64; // sizelint-ok: SSoT for slice slotsize (#64) return t; }; @@ -6697,7 +6697,9 @@ export fn tinfocachelookup(c: *tctx, key: *node) *tinfo = { }; export fn tinfocachebind(c: *tctx, key: *node, val: *tinfo) void = { - let e: *tinfocacheent = amalloc(c.a, 32u64): *tinfocacheent; + // #36 (typed amalloc) — over-size the 24B tinfocacheent struct to + // dodge the cstage amallocsize = 24). Bumping a slice's // header layout in #34 touches only this constant. -fn tyslicesize() i64 = { return 24i64; }; +fn tyslicesize() i64 = { return 24i64; }; // sizelint-ok: SSoT for ty_slice header (#64) // #42: AST-level layout helpers for the size(T)/align(T)/offset(e.f) // fold. Mirror cstage resolve_type's size/align computation diff --git a/selfhost/cmd/wcc/check.ww b/selfhost/cmd/wcc/check.ww index f5c54f96..68a7e01d 100644 --- a/selfhost/cmd/wcc/check.ww +++ b/selfhost/cmd/wcc/check.ww @@ -670,14 +670,14 @@ fn primtypesize(nm: str) i64 = { if (streq(nm, "i32") || streq(nm, "u32") || streq(nm, "f32") || streq(nm, "rune")) { return 4i64; }; if (streq(nm, "i64") || streq(nm, "u64") || streq(nm, "f64")) { return 8i64; }; if (streq(nm, "int") || streq(nm, "uint") || streq(nm, "uintptr")) { return 8i64; }; - if (streq(nm, "str")) { return 16i64; }; + if (streq(nm, "str")) { return 16i64; }; // sizelint-ok: SSoT for ty_str primtype (#64) return -1i64; }; // #43: SSoT for slice header size (ptr+len+cap = 24B today). Mirrors // cstage cmd/wcc/type.c:103 (ty_slice->size = 24). Bumping a slice's // header layout in #34 touches only this constant. -fn tyslicesize() i64 = { return 24i64; }; +fn tyslicesize() i64 = { return 24i64; }; // sizelint-ok: SSoT for ty_slice header (#64) // #42: AST-level layout helpers for the size(T)/align(T)/offset(e.f) // fold. Mirror cstage resolve_type's size/align computation diff --git a/selfhost/cmd/wwdump/main.combined.ww b/selfhost/cmd/wwdump/main.combined.ww index 9f1e8a3f..51eac1e7 100644 --- a/selfhost/cmd/wwdump/main.combined.ww +++ b/selfhost/cmd/wwdump/main.combined.ww @@ -6613,7 +6613,7 @@ export fn typesinit(c: *tctx, a: *arena) void = { c.tyuintptr= prim(a, tykind.TY_UINTPTR, "uintptr", 8u64, 8u64); c.tyf32 = prim(a, tykind.TY_F32, "f32", 4u64, 4u64); c.tyf64 = prim(a, tykind.TY_F64, "f64", 8u64, 8u64); - c.tystr = prim(a, tykind.TY_STR, "str", 16u64, 8u64); + c.tystr = prim(a, tykind.TY_STR, "str", 16u64, 8u64); // sizelint-ok: SSoT for tystr (#64) c.tyerr = prim(a, tykind.TY_ERR, "", 0u64, 1u64); c.tynever = prim(a, tykind.TY_NEVER, "never", 0u64, 1u64); @@ -6637,9 +6637,9 @@ export fn typeptr(a: *arena, sub: *tinfo) *tinfo = { export fn typeslice(a: *arena, sub: *tinfo) *tinfo = { let t: *tinfo = newtype(a, tykind.TY_SLICE); t.sub = sub; - t.size = 24u64; + t.size = 24u64; // sizelint-ok: SSoT for slice header (#64) t.align = 8u64; - t.slotsize = 24u64; + t.slotsize = 24u64; // sizelint-ok: SSoT for slice slotsize (#64) return t; }; @@ -6697,7 +6697,9 @@ export fn tinfocachelookup(c: *tctx, key: *node) *tinfo = { }; export fn tinfocachebind(c: *tctx, key: *node, val: *tinfo) void = { - let e: *tinfocacheent = amalloc(c.a, 32u64): *tinfocacheent; + // #36 (typed amalloc) — over-size the 24B tinfocacheent struct to + // dodge the cstage amallocsize = 24). Bumping a slice's // header layout in #34 touches only this constant. -fn tyslicesize() i64 = { return 24i64; }; +fn tyslicesize() i64 = { return 24i64; }; // sizelint-ok: SSoT for ty_slice header (#64) // #42: AST-level layout helpers for the size(T)/align(T)/offset(e.f) // fold. Mirror cstage resolve_type's size/align computation diff --git a/tools/sizelint b/tools/sizelint new file mode 100755 index 00000000..b0828391 --- /dev/null +++ b/tools/sizelint @@ -0,0 +1,124 @@ +#!/bin/sh +# tools/sizelint — gate against hardcoded size literals in size-computation +# contexts. Per Drew's framing of Hare's discipline and ww task #64: +# every byte size that names a type's footprint must route through the +# type table (tinfo.size / Type.size / ty_*->size / size(T)). Bare +# numerics encode the layout twice and silently desync (#1, #43, #60, +# #65 sweeps caught one site at a time post-hoc). +# +# Patterns are matched in two tiers: +# 1) Always-on strong signals — direct writes to a type's size / +# slotsize / align field, the `prim(...,size,align)` factory call, +# and any literal in the rhs of `*->size = ` style assignments. +# 2) Context-gated literals — bare 16/24/32 (suffix-tagged or not) only +# inside files or functions whose name matches one of: +# size|slot|elem|field|stride|paramfield|tinfo|primtype|slotsize| +# letemit|tagged +# +# Exempt a single line with an end-of-line `// sizelint-ok: ` +# comment. Use sparingly and cite a task or structural reason. +# +# Scope: cmd/ selfhost/ lib/ (skip ref/ out/ bootstrap/ .combined.ww). +# Exit code: 0 if clean, 1 with one diagnostic per violation. + +set -u + +ROOT=${ROOT:-$(cd "$(dirname "$0")/.." && pwd)} +cd "$ROOT" + +files=$(find cmd selfhost lib \ + \( -type d -name out -prune \) -o \ + \( -type d -name bootstrap -prune \) -o \ + \( -type d -name ref -prune \) -o \ + \( -type d -name .git -prune \) -o \ + \( -type d -name .claude -prune \) -o \ + \( -type d -name .ai -prune \) -o \ + \( -type f \( -name '*.c' -o -name '*.h' -o -name '*.ww' \) \ + ! -name '*.combined.ww' -print \) ) + +exec awk ' +BEGIN { + ctx_re = "size|slot|elem|field|stride|paramfield|tinfo|primtype|slotsize|letemit|tagged" + tagged_lit_re = "(16|24|32)(u64|i64)" + return_lit_re = "return[ \t]+(16|24|32)[ \t]*[;}]" + # Strong signal: assignment to .size / ->size / .slotsize / ->slotsize. + # Value 8 is the natural pointer/scalar size — leave un-flagged so + # typeptr/typechan stay quiet without an allow-list per assignment. + size_assign_re = "(\\.|->)[ \t]*(size|slotsize)[ \t]*=[ \t]*(16|24|32)([^0-9]|$)" + # Strong signal: type-table factory `prim(arena, kind, "name", SIZE, ALIGN)`. + prim_call_re = "\\]+\"[ \t]*,[ \t]*(16|24|32)" + nviol = 0 +} + +FNR == 1 { + cur_file = FILENAME + file_in_ctx = (tolower(cur_file) ~ ctx_re) + is_c = (cur_file ~ /\.(c|h)$/) + fn_name = "" + fn_in_ctx = 0 +} + +# Track function context (ww + C styles). +{ + if (match($0, /^[ \t]*(export[ \t]+)?fn[ \t]+[A-Za-z_][A-Za-z0-9_]*/)) { + s = substr($0, RSTART, RLENGTH) + sub(/^[ \t]*(export[ \t]+)?fn[ \t]+/, "", s) + fn_name = s + fn_in_ctx = (tolower(fn_name) ~ ctx_re) + } else if (is_c && match($0, /^[A-Za-z_][A-Za-z0-9_]*\(/)) { + s = substr($0, RSTART, RLENGTH - 1) + fn_name = s + fn_in_ctx = (tolower(fn_name) ~ ctx_re) + } +} + +# Allow-list (case-insensitive on the keyword) — check the raw line so a +# doc comment can still exempt itself. Accepts both `// sizelint-ok:` (ww +# and C99) and `/* sizelint-ok: ... */` (Plan 9 C style). +tolower($0) ~ /sizelint-ok:/ { next } + +# Strip line and block comments before pattern matching — a comment that +# quotes a hardcoded size in prose is not a violation. Single-line block +# comments only; multi-line `/* ... */` spans rarely contain assignments +# we care about. +{ + code = $0 + sub(/\/\/.*$/, "", code) + gsub(/\/\*[^*]*\*+([^/*][^*]*\*+)*\//, "", code) +} + +# Always-on strong-signal patterns. +{ + if (match(code, size_assign_re)) { + report(substr(code, RSTART, RLENGTH), + "assignment to .size/.slotsize with literal; route via type SSoT (tinfo.size / ty_*->size)") + } + if (match(code, prim_call_re)) { + report(substr(code, RSTART, RLENGTH), + "literal in prim(...) size slot; type-table is SSoT (cmd/wcc/type.c, lib/ww/typ.ww)") + } +} + +# Context-gated patterns. +file_in_ctx || fn_in_ctx { + line = code + while (match(line, tagged_lit_re)) { + report(substr(line, RSTART, RLENGTH), + "size literal in size-context; route via size(T) / primtypesize / tyslicesize SSoT") + line = substr(line, RSTART + RLENGTH) + } + if (match(code, return_lit_re)) { + report(substr(code, RSTART, RLENGTH), + "return of bare size literal in size-context; route via type SSoT") + } +} + +function report(snip, msg) { + # Trim trailing newline / extra whitespace from snippet. + gsub(/[ \t]+$/, "", snip) + printf("%s:%d: %s; suggest: %s\n", cur_file, FNR, snip, msg) + nviol++ +} + +END { exit (nviol > 0 ? 1 : 0) } +' $files