wcc: general call-arg typecheck via assignability union, both stages

wwstage's desugarcallargs ran no general per-arg typecheck (only the
narrow #258 array-to-slice arm): any mistyped scalar call-arg silently
miscompiled (int read as a 24B slice header; the -T face was a user
const __wwtests building a garbage test binary). Route every call-arg
through the predicate union isassignable()||assignableaddrfn(),
mirroring cstage type_assignable||assignable_addrfn and the check.c:1869
diagnostic. Confident scalar/aggregate and aggregate/aggregate
kind-mismatch rejects live in shared isassignable; the concrete-to-
tagged arm is shape-matched-lenient via tagshape() (AST mirror of cgen
taggedvariantindext) so genuine variant members keep flowing while
shape-mismatched aggregates reject. Reserve __wwtests under -T in both
stages (mirror the main reservation, check.c:2996). New table-driven
989_callarg_typecheck, 31 fixtures, reject rows proven red on pre-fix
binaries.

Deferred, filed, site-commented: the assign seam rides #178->#36
(typeeqast cannot compare variadic/module-qualified fn sigs); the
same-coarse-shape same-leaf nominal collision over-accept rides #37
(#10/#66 — the distinguishing module is absent from the AST surface
isassignable operates on).
This commit is contained in:
2026-06-11 20:45:02 +09:00
parent 8d2d157a58
commit 556a65ee86
6 changed files with 1291 additions and 45 deletions

View File

@@ -14284,6 +14284,21 @@ fn isstrtname(t: *node) bool = {
return streq(t.str, "str");
};
// tagshape — #24/#37: the coarse variant-shape bucket of a (resolved) type
// node, the AST-side mirror of cgen taggedvariantindext's str/slice shape
// fallback (cgenutil.ww:3062-3070, `wantstr`/`wantslice` over typeisstr/
// typeisslice). Three buckets: 2=slice, 1=str, 0=scalar/other (ptr / struct
// / tuple / chan / fn / int / enum / ...). Used by the concrete→tagged
// aggregate-shape-lenient leg to keep a tagged accept lenient ONLY against a
// shape-compatible variant — same classifier cgen boxes with, so the checker
// accept and the cgen box agree (rule-12: reuse the in-tree classifier).
fn tagshape(t: *node) i32 = {
if (t == nil) { return 0i32; };
if (t.kind == nkind.N_TSLICE) { return 2i32; };
if (isstrtname(t)) { return 1i32; };
return 0i32;
};
// addrfnptrmatches — true iff `ptr` (after alias-resolve) is a
// pointer whose referent resolves to a fn type structurally equal to
// `synth` (a synthetic N_TFN built from a fn decl's ret + params).
@@ -14397,6 +14412,21 @@ fn isassignable(c: *checker, dst: *node, src: *node, confident: *bool) bool = {
};
v = v.next;
};
// #24: a SPREAD variant (`...formattable`) keeps the lenient
// escape — cstage flattens spreads at resolve_type so its
// type_assignable sees the spread's inlined numeric leaves and
// accepts `take(42)` into `(...formattable | bool)`; wwstage
// stays AST-keyed (#115) and cannot flatten, so a confident
// reject here would OVER-reject what cstage accepts (the new c3
// general call-arg check made this path reachable). Mirror the
// tagged→tagged spread escape (:4117). Spread decl-form flatten
// is #199b, deferred.
for (let p: *node = du.list; p != nil; p = p.next) {
if (p.op == tkind.TK_ELLIPSIS) {
*confident = false;
return true;
};
};
// #23: no DIRECT variant accepts an untyped int -> confident
// reject (mirror cstage type.c:343 `return 0`). ww does NOT
// flatten a nested union variant (#199-alpha non-drill); an int
@@ -14416,6 +14446,18 @@ fn isassignable(c: *checker, dst: *node, src: *node, confident: *bool) bool = {
if (streq(du.str, "void")) { return false; };
if (streq(du.str, "str")) { return false; };
};
// #24: untyped int into a known AGGREGATE (slice/array/ptr/fn/chan/
// tuple/struct) — confident reject. `let xs: []int = 5` read the int
// as a 24B slice header (the #24 silent-garbage; the catch-all below
// left it unconfident → silent accept). cstage type_assignable
// rejects untyped_int into a non-numeric aggregate (cmd/wcc/type.c).
// The TTAGGED case is handled above; this is the bare aggregate.
if (du.kind == nkind.N_TSLICE || du.kind == nkind.N_TARRAY
|| du.kind == nkind.N_TPTR || du.kind == nkind.N_TFN
|| du.kind == nkind.N_TCHAN || du.kind == nkind.N_TTUPLE
|| du.kind == nkind.N_TSTRUCT) {
return false;
};
// Unknown shapes: stay quiet.
*confident = false;
return true;
@@ -14449,6 +14491,22 @@ fn isassignable(c: *checker, dst: *node, src: *node, confident: *bool) bool = {
};
v = v.next;
};
// #24: spread variant keeps the lenient escape (cstage flattens;
// wwstage can't) — same rationale as the untyped-int arm above.
for (let p: *node = du.list; p != nil; p = p.next) {
if (p.op == tkind.TK_ELLIPSIS) {
*confident = false;
return true;
};
};
return false;
};
// #24: untyped float into a known AGGREGATE — confident reject (twin
// of the untyped-int aggregate arm above; `let xs: []f64 = 1.5`).
if (du.kind == nkind.N_TSLICE || du.kind == nkind.N_TARRAY
|| du.kind == nkind.N_TPTR || du.kind == nkind.N_TFN
|| du.kind == nkind.N_TCHAN || du.kind == nkind.N_TTUPLE
|| du.kind == nkind.N_TSTRUCT) {
return false;
};
*confident = false;
@@ -14471,6 +14529,14 @@ fn isassignable(c: *checker, dst: *node, src: *node, confident: *bool) bool = {
if (v.kind == nkind.N_TFN) { return true; };
v = v.next;
};
// #24: spread variant keeps the lenient escape (cstage flattens;
// wwstage can't) — same rationale as the untyped-int arm above.
for (let p: *node = du.list; p != nil; p = p.next) {
if (p.op == tkind.TK_ELLIPSIS) {
*confident = false;
return true;
};
};
// A5: no nullable (ptr/slice/chan/fn) variant -> confident
// reject (cstage type.c:316 loop returns 0; nil accepts only
// into ptr/slice/chan/fn per type.c:382-385). *confident is
@@ -14512,6 +14578,70 @@ fn isassignable(c: *checker, dst: *node, src: *node, confident: *bool) bool = {
};
v = v.next;
};
// #24: no variant matched. A SCALAR src (known primitive, su is
// N_TNAME) is a CONFIDENT reject — `int` into `(str | bool)` (the
// #23/#199-α path). An AGGREGATE src (ptr/slice/struct/tuple/chan/
// fn — su.kind != N_TNAME) stays LENIENT: wwstage's nominal-lossy
// model can't confirm a cross-module ptr/struct variant (the `stream`
// variant is `*vtable` but io.handle's consumer hands a `*io.vtable`
// from `&cgoutstream.vt`, or a qualified `io.stream` alias — bare-vs-
// qualified NAMED identity, #10/#66; typeeqast can't span it), while
// cstage flattens+resolves and ACCEPTS (io.stream → io.handle =
// (file | stream)). Pre-c3 the loop accepted ANY src via the first
// scalar variant's lenient-true short-circuit; the c3 scalar↔aggregate
// reject removed that crutch, exposing the latent nominal gap, so
// distinguish by src shape here. The handoff's "preserve concrete→
// tagged accept" path.
//
// An AGGREGATE src (su.kind != N_TNAME) stays lenient ONLY against a
// SHAPE-COMPATIBLE variant — tagshape mirrors cgen taggedvariantindext's
// str/slice/scalar-other classifier (cgenutil.ww:3062), so the checker
// accept and the cgen box agree (rule-12). A shape-MISMATCHED aggregate
// (e.g. a `[]int` slice src into a tagged with no slice variant) is a
// CONFIDENT reject, matching cstage's nominal type_assignable; this is
// the reachable win (#24-B, rob/lead-ruled shape-narrowing over the
// blanket aggregate-lenient).
//
// RULE-7 TRACKED RESIDUAL (task #37, behind the #10/#66 nominal arc;
// NEVER silent): the SAME-COARSE-SHAPE leg still OVER-ACCEPTS a cross-
// module SAME-LEAF collision that cstage REJECTS — e.g. `mod1.stream`
// (a `*mod1.wbox`, scalar/other shape) passed where `io2.handle =
// (io2.file | io2.stream)` is wanted (io2.stream is also scalar/other,
// so the shapes match and this stays lenient). cstage rejects it on
// NOMINAL identity; wwstage accepts. PROVEN STRUCTURALLY UNREACHABLE
// here: the tagged-union variant node is a BARE name (`stream`,
// N_TNAME, no module) — BYTE-IDENTICAL for the genuine io2.stream and
// the collision mod1.stream — so isassignable, which is AST-NODE-keyed,
// has no bit to tell them apart; the distinguishing identity lives only
// in the tinfo layer (#66 per-decl TY_NAMED ptr, which cgen's
// flatvariantidxt already uses). The reject becomes reachable ONLY when
// isassignable is converted to nominal-tinfo keying = the #37 /
// #10/#66 work itself, NOT a c3-scope change. (B) shape-narrowing
// shrinks the residual from "all aggregate→tagged" to "same-coarse-
// shape same-leaf" but cannot close the same-shape ptr↔ptr collision.
// This is the LEAF-NAME nominal-collision family also documented at the
// tagged→tagged qualleaf bridge (this fn, below) — the eventual #10/#66
// sweep must convert BOTH sites uniformly (enumerate for the sweep:
// (i) this concrete→tagged shape-lenient leg, (ii) the tagged→tagged
// qualleaf bridge). Pre-c3 the collision was ALSO accepted (call-arg
// ran no check; let/return short-circuited on the scalar variant) — c3
// is NEUTRAL on it.
if (su.kind != nkind.N_TNAME) {
let ss: i32 = tagshape(su);
let sp: *node = du.list;
for (sp != nil) {
let svu: *node = resolvealias(c, unwrapbang(sp));
if (svu != nil) {
if (tagshape(svu) == ss) {
*confident = false;
return true;
};
};
sp = sp.next;
};
// no shape-compatible variant → confident reject (the #24-B win)
return false;
};
return false;
};
// tagged → tagged: structural variant list compare. Skip
@@ -14652,6 +14782,54 @@ fn isassignable(c: *checker, dst: *node, src: *node, confident: *bool) bool = {
return false;
};
};
// #24: a known scalar primitive vs a known aggregate (slice / array /
// ptr / fn / chan / tuple / struct), and two aggregates of DIFFERENT
// kinds, are CONFIDENT rejects — mirror cstage type_assignable, which
// separates scalar from aggregate and rejects a kind mismatch (the int
// read as a 24B slice header was the #24 silent-garbage). du/su are
// already alias-RESOLVED (:3943/3944), so `type A = []int` arrives as
// N_TSLICE. The array→slice BORROW (su N_TARRAY into du N_TSLICE),
// untyped / nil / tagged, and the known-primitive-pair / fn-ptr / fn-fn
// shapes all returned above before reaching here. The `&fn` adopt-the-
// alias case (a *fn N_TPTR src into a bare-fn N_TFN dst — different
// aggregate kinds) is rescued at the let/return/call-arg sites by the
// assignableaddrfn UNION, so a reject here is correct (the caller's
// union accepts the genuine &fn). SAME-kind aggregate structural
// mismatches ([]int vs []str, *u8 vs *i32) stay lenient below —
// wwstage's nominal-lossy model can't span them (the #10 gap); cstage
// rejects via structural type_assignable, a filed residual under-reject,
// NOT a new over-reject. A NAMED struct/alias dst that does NOT resolve
// to a known kind stays N_TNAME-non-prim → neither set → lenient.
let dprim: bool = du.kind == nkind.N_TNAME
&& (isnumerictname(du) || isstrtname(du)
|| streq(du.str, "bool") || streq(du.str, "void"));
let sprim: bool = su.kind == nkind.N_TNAME
&& (isnumerictname(su) || isstrtname(su)
|| streq(su.str, "bool") || streq(su.str, "void"));
let daggr: bool = du.kind == nkind.N_TSLICE || du.kind == nkind.N_TARRAY
|| du.kind == nkind.N_TPTR || du.kind == nkind.N_TFN
|| du.kind == nkind.N_TCHAN || du.kind == nkind.N_TTUPLE
|| du.kind == nkind.N_TSTRUCT;
let saggr: bool = su.kind == nkind.N_TSLICE || su.kind == nkind.N_TARRAY
|| su.kind == nkind.N_TPTR || su.kind == nkind.N_TFN
|| su.kind == nkind.N_TCHAN || su.kind == nkind.N_TTUPLE
|| su.kind == nkind.N_TSTRUCT;
if (sprim && daggr) { return false; };
if (dprim && saggr) { return false; };
// #24: two aggregates of DIFFERENT kinds → confident reject (array/slice
// into ptr/fn/chan/tuple is the 24B/16B-header misread). EXEMPT a STRUCT
// on either side: wwstage's name-keyed resolvealias mis-resolves a bare
// cross-module same-leaf type name to the WRONG module's struct (#224 —
// `type s = *vtable` in sa vs `type s = struct{}` in sb; sa.read's bare
// `s` param resolves to sb's struct), so a struct-vs-ptr "mismatch" here
// is an artifact of the lossy resolution, not a real type error — cstage
// resolves `s` correctly and ACCEPTS (test 784). Same nominal-lossy
// principle as the concrete→tagged aggregate-lenient arm above; the
// struct-into-ptr genuine mismatch stays a filed #224/#10 under-reject.
if (daggr && saggr && du.kind != su.kind
&& du.kind != nkind.N_TSTRUCT && su.kind != nkind.N_TSTRUCT) {
return false;
};
// Anything else: don't claim confidence.
*confident = false;
return true;
@@ -15246,6 +15424,21 @@ fn calleefndecl(c: *checker, callee: *node) *node = {
// arg flows into the gather as an element, not the slice itself).
fn desugarcallargs(c: *checker, n: *node) void = {
if (n == nil) { return; };
// #24: a 1-arg `free(x)` is the Hare no-op pseudo-builtin (#27), NOT the
// rt 2-arg `free(p: *void, n: u64)` that calleefndecl resolves to in the
// bundle (lib seeds both: the nil-decl builtin at check.ww:137 AND
// rt's @symbol("rt_free") decl). cstage intercepts the builtin by name +
// arity BEFORE call resolution (cmd/wcc/check.c:1650, n->list->next ==
// NULL) and runs NO arg typecheck; exprtype's free arm (:3014) is the
// wwstage twin but runs after this seam. Skip so `free(charset)` /
// `free(slice)` (regex finish #27) isn't checked against rt_free's *void
// param. `free` is the only builtin name with a colliding real decl
// (len/alloc/append/delete/insert keep nil decls → calleefndecl bails).
if (n.lhs != nil) { if (n.lhs.kind == nkind.N_IDENT) {
if (streq(n.lhs.str, "free")) {
if (n.list != nil) { if (n.list.next == nil) { return; }; };
};
}; };
let decl: *node = calleefndecl(c, n.lhs);
if (decl == nil) { return; };
let param: *node = decl.list;
@@ -15265,21 +15458,25 @@ fn desugarcallargs(c: *checker, n: *node) void = {
// param/destination width).
let runet: *node = coercerunelit(c, a, param.lhs);
if (runet != nil) { atype = runet; };
// #258: an array arg into a []T param with a
// MISMATCHED element is not a borrow — loud reject,
// mirror cstage's call-arg type_assignable failure.
// Gated to the array→slice-param shape so wwstage's
// broader call-arg leniency (it runs no general
// param typecheck) is untouched.
let pu: *node = resolvealias(c, unwrapbang(param.lhs));
let au: *node = resolvealias(c, unwrapbang(atype));
if (pu != nil) { if (au != nil) {
if (pu.kind == nkind.N_TSLICE) { if (au.kind == nkind.N_TARRAY) {
if (!typeeqast(c, pu.lhs, au.lhs)) {
errnotassign(c, param.lhs, atype, "argument");
};
}; };
}; };
// #24: GENERAL per-arg assignability — align UP to
// cstage check.c:1867-1870, which type_assignables
// every non-variadic call arg (`argument type %s not
// assignable to %s`). wwstage previously ran NO general
// param typecheck (only a narrow #258 array→slice arm),
// so any mistyped scalar call-arg silently miscompiled
// (an int read as a 24B slice header). The shared
// isassignable SUBSUMES that #258 arm: its N_TSLICE/
// N_TARRAY arm is a confident reject on an element
// mismatch and an accept on a match (the desugar below
// then borrows). Conf-gated + UNIONed with
// assignableaddrfn exactly like the let/return sibling
// sites (:5151/5154, :5222/5225); the spread-tagged
// lenient escape (isassignable :4078) keeps conf=false so
// `take(42)` into `(...formattable | bool)` stays accepted.
let conf: bool = false;
let ok: bool = isassignable(c, param.lhs, atype, &conf);
if (!ok) { if (assignableaddrfn(c, param.lhs, a)) { ok = true; }; };
if (conf) { if (!ok) { errnotassign(c, param.lhs, atype, "argument"); }; };
// #12: overlong array-lit CALL-ARG — `g([1,2,3])`.
// Reject at CHECK time (clean over-fill msg) instead
// of falling to cgen #271's late aggregate-arg loud.
@@ -15324,6 +15521,23 @@ fn checkassign(c: *checker, n: *node) void = {
// width (byte-id-neutral). Removes the getopt `'X': u8` index casts.
let runet: *node = coercerunelit(c, n.rhs, ltn);
if (runet != nil) { rtn = runet; };
// #24/#36 (rule-7 deferred-divergence, NEVER silent): the ASSIGN seam
// does NOT yet route the general conf-gated UNION (isassignable ||
// assignableaddrfn) that the let / return / call-arg seams run — so a
// mistyped bare-assignment `x = some_slice` (a 24B slice header into an
// 8B int slot) is still silently accepted here, the one remaining
// member of the #24 cat-A. cstage DOES check it (cmd/wcc/check.c:1899,
// `cannot assign %s to %s`, every op incl. compound). The union was
// implemented + reverted: it correctly closed `x = slice` and matched
// cstage on `p += 1`, but surfaced a false over-reject of an EXACT-
// signature bare fn assigned to a fn-pointer struct field (lib/log
// `r.logger.println = stdprintln`) because typeeqast compares fn types
// at the AST level and cannot match a variadic + module-qualified-param
// fn signature (the #178 divergence, self-flagged at the typeeqast
// N_TFN arm). So the assign seam is BLOCKED on #178 and filed as task
// #36 (the bounded #178 typeeqast fn-compare fix, task #35, lands
// first). Until then this seam runs only coercerunelit + the #258
// array→slice desugar below.
// #31/#33: bare array-literal rhs has no backing — loud-reject
// (supported only at a `let`).
if (!rejectarrlitborrow(c, ltn, n.rhs)) {
@@ -16099,6 +16313,19 @@ export fn checkfile(c: *checker, file: *node) void = {
cerr(": error: test mode: main is synthesized by -T; remove the explicit main\n");
c.errs += 1;
};
// #24(b): the synth table OWNS `__wwtests` — loud-reject a user
// decl of that name (mirror the `main` reservation; cstage
// cmd/wcc/check.c). A user `__wwtests` whose type HAPPENS to
// match run()'s `[](str, *fn()void)` param slips the general
// call-arg check (a) but still silently shadows the synth table,
// so the synth `run(__wwtests)` iterates the user's table, not
// the collected @tests — reserve the NAME so the collision is
// loud regardless of type. Any decl kind (const/let/fn).
if (streq(u.str, "__wwtests")) {
cerr(u.file);
cerr(": error: test mode: __wwtests is reserved by -T; rename the declaration\n");
c.errs += 1;
};
u = u.next;
};
// (c) collect @test fns in file.list order; build one table row