wcc: nullable-global storage is a loud reject pending #15, both stages

A module-level nullable `(*T | void)` GLOBAL has no storage path in
either stage: let_emit_size / letemitsize returned 0 for the nullable
TY_TAGGED, so let_collect skipped registration and emit_lets skipped
DATA. The three READ paths then miscompiled SILENTLY and identically-
wrong (a #263-class both-wrong gap, not a wwstage align-up): match read
0(BP) = saved BP via the let_islet-gated #87 arm falling to localfind;
`g is *T` / `g as *T` emitted MOVQ name(SB) for a symbol with no DATA →
w6l undefined-reference. cstage's #87 match arm was itself `!is_nullable`-
gated, so both stages were wrong.

This is the silent→loud bridge: die loud at the size/storage layer the
instant a nullable global is declared, so all three read paths hit one
diagnostic instead of a silent miscompile. A silent gap here is exactly
what "stable before CSP" forbids — CSP's process/handle/chan singletons
(`let c: *Chan | void`) are THE canonical nullable-global consumer. The
full storage + read-class arc (real DATA, nil/void/address-of init, let-
registration, the three SB-resolution read arms) is deferred to task #15
(CSP-prereq); the `&`-init sub-problem additionally couples to the #48
static address-of relocation gap (which already bites a plain `*T` global
init the same way).

Diagnostic core text is identical both stages ("nullable-global storage
unimplemented (task #15)"); cstage's fatal() adds the harness-wide "ww: "
err.c prefix err.ww does not, the same per-stage asymmetry every existing
both-stage reject carries. Byte-id-neutral: the corpus declares zero
nullable globals (grep-verified), so the loud path is unreached in self-
compile and the emitted asm is zero-move; the embedded w6c/wwdump
combined.ww amalgamations are regenerated for the cgen.ww source change.

New 989_nullableglobal_reject: 6 reject rows (match/is/as on a &gv init,
plus nil-init and void-init match, plus an inline non-aliased nullable
form) prove rc!=0 + the shared diagnostic on both stages, init- and
form-invariant; 2 controls (non-nullable tagged global, plain nil-init
*T global) prove the reject is keyed on the nullable TY_TAGGED and the
#87 storage path is untouched.
This commit is contained in:
2026-06-13 18:27:53 +09:00
parent dd24de1134
commit 728d86518e
6 changed files with 349 additions and 16 deletions

View File

@@ -1269,10 +1269,18 @@ let_emit_size(Type *t)
* variant init via emit_tagged_data; match-scrutinee reads
* resolve the box at name(SB). Pre-#87 the missing arm sized 0
* → no DATA, no letvar registration, and match read saved BP
* as the tag (SEGV). The nullable `(*T | void)` one-word fold
* stays 0 (handled, when const, by the 8B scalar arm in
* emit_lets — adding it here would re-route that path). */
return u->nullable ? 0 : (int)u->size;
* as the tag (SEGV).
* #45 (silent→loud bridge, task #15): a nullable `(*T | void)`
* GLOBAL has no storage path. Returning 0 here made let_collect
* + emit_lets silently skip the decl (no DATA, no letvar reg),
* so a later match read 0(BP) and is/as emitted MOVQ name(SB)
* for an undefined symbol — a silent miscompile in the CSP
* handle-singleton substrate. Die loud at the size/storage layer
* so all three read paths hit one diagnostic; the full storage +
* read-class arc is task #15 (CSP-prereq). */
if (u->nullable)
fatal("nullable-global storage unimplemented (task #15)");
return (int)u->size;
default:
return 0;
}