From 556a65ee8638e934d64341397240d7ebb6b9d872 Mon Sep 17 00:00:00 2001 From: Hojun-Cho Date: Thu, 11 Jun 2026 20:45:02 +0900 Subject: [PATCH] wcc: general call-arg typecheck via assignability union, both stages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- Makefile | 12 + cmd/wcc/check.c | 10 + selfhost/cmd/w6c/main.combined.ww | 257 ++++++++++++- selfhost/cmd/wcc/check.ww | 257 ++++++++++++- selfhost/cmd/wwdump/main.combined.ww | 257 ++++++++++++- test/wcc/989_callarg_typecheck.c | 543 +++++++++++++++++++++++++++ 6 files changed, 1291 insertions(+), 45 deletions(-) create mode 100644 test/wcc/989_callarg_typecheck.c diff --git a/Makefile b/Makefile index 203f6b36..1e438b3d 100644 --- a/Makefile +++ b/Makefile @@ -247,6 +247,7 @@ TESTS = $(BIN)/test_smoke $(BIN)/test_lex $(BIN)/test_parse $(BIN)/test_check \ $(BIN)/test_arr_infer_len \ $(BIN)/test_arr_cap_reject \ $(BIN)/test_tagged_subset_reject \ + $(BIN)/test_callarg_typecheck \ $(BIN)/test_arr_ptr_global \ $(BIN)/test_def_arr_infer_len \ $(BIN)/test_def_arr_len \ @@ -620,6 +621,17 @@ $(BIN)/test_test_filter: test/wcc/989_test_filter.c $(BIN)/ww $(BIN)/ww_ww \ $(LIB)/libwwrt.a | $(BIN) $(CC) $(CFLAGS) -o $@ $< +# 989_callarg_typecheck drives `ww build` + `ww test -c` then ` -T` +# on BOTH driver/compiler twins (#24 general call-arg typecheck + #34 +# fn-rvalue stamp + #29 rune-lit coerce), so it needs the full cstage + +# wwstage tool sets plus libwwrt for the link. +$(BIN)/test_callarg_typecheck: test/wcc/989_callarg_typecheck.c \ + $(BIN)/ww $(BIN)/ww_ww \ + $(BIN)/w6c $(BIN)/w6a $(BIN)/w6l \ + $(BIN)/w6c_ww $(BIN)/w6a_ww $(BIN)/w6l_ww \ + $(LIB)/libwwrt.a | $(BIN) + $(CC) $(CFLAGS) -o $@ $< + $(BIN)/test_let_global: test/wcc/630_let_global.c $(BIN)/ww $(BIN)/w6c \ $(BIN)/w6a $(BIN)/w6l $(LIB)/libwwrt.a | $(BIN) $(CC) $(CFLAGS) -o $@ $< diff --git a/cmd/wcc/check.c b/cmd/wcc/check.c index 743c6aaa..daaa9419 100644 --- a/cmd/wcc/check.c +++ b/cmd/wcc/check.c @@ -2999,6 +2999,16 @@ check_file(Checker *c, Node *file) && strcmp(d->str, "main") == 0 && d->body != NULL) err(c, d->pos, "test mode: main is synthesized " "by -T; remove the explicit main"); + /* #24(b): the synth table OWNS `__wwtests` — loud-reject a user + * decl of that name (twin of the `main` reservation above). A user + * __wwtests whose type happens to match run()'s [](str,*fn()void) + * param slips the general call-arg check but still silently shadows + * the synth table; reserve the NAME so the collision is loud + * regardless of type. Any decl kind. */ + for (Node *d = file->list; d; d = d->next) + if (d->str && strcmp(d->str, "__wwtests") == 0) + err(c, d->pos, "test mode: __wwtests is reserved " + "by -T; rename the declaration"); /* (c) collect @test fns in file->list order; build one table row * `("", &)` per validated @test fn. */ Node *rhead = NULL, *rtail = NULL; diff --git a/selfhost/cmd/w6c/main.combined.ww b/selfhost/cmd/w6c/main.combined.ww index 478892b6..4c0af8fe 100644 --- a/selfhost/cmd/w6c/main.combined.ww +++ b/selfhost/cmd/w6c/main.combined.ww @@ -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 diff --git a/selfhost/cmd/wcc/check.ww b/selfhost/cmd/wcc/check.ww index 36424d0a..5efb0b5c 100644 --- a/selfhost/cmd/wcc/check.ww +++ b/selfhost/cmd/wcc/check.ww @@ -3864,6 +3864,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). @@ -3977,6 +3992,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 @@ -3996,6 +4026,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; @@ -4029,6 +4071,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; @@ -4051,6 +4109,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 @@ -4092,6 +4158,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 @@ -4232,6 +4362,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; @@ -4826,6 +5004,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; @@ -4845,21 +5038,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. @@ -4904,6 +5101,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)) { @@ -5679,6 +5893,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 diff --git a/selfhost/cmd/wwdump/main.combined.ww b/selfhost/cmd/wwdump/main.combined.ww index f111fb9d..10bb2905 100644 --- a/selfhost/cmd/wwdump/main.combined.ww +++ b/selfhost/cmd/wwdump/main.combined.ww @@ -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 diff --git a/test/wcc/989_callarg_typecheck.c b/test/wcc/989_callarg_typecheck.c new file mode 100644 index 00000000..4934a016 --- /dev/null +++ b/test/wcc/989_callarg_typecheck.c @@ -0,0 +1,543 @@ +/* + * 989_callarg_typecheck — #24: wwstage align UP to cstage on GENERAL + * call-argument assignability (CHECKER-ONLY, cat-A silent-miscompile). + * + * THE BUG: wwstage ran NO general call-arg typecheck. desugarcallargs + * (selfhost/cmd/wcc/check.ww) only had the narrow #258 array→slice arm, + * so ANY mistyped non-array scalar call-arg was SILENTLY accepted: an + * `int` passed where a `[]T` param expects a 24B slice header builds + * rc=0 and runs garbage (the int's 8 bytes read as the slice .len/.ptr). + * cstage rejects every non-variadic arg at cmd/wcc/check.c:1867-1870 + * (`argument type %s not assignable to %s`). + * + * The -T face is the headline silent miscompile: a user + * `const __wwtests: int` + ≥1 @test fn shadows the synth test table, so + * the synth `run(__wwtests)` passes the int 99 where run() wants the + * `[](str, *fn() void)` table — cstage rejected it loud, wwstage + * silently built a broken binary that iterated 99 as a slice .len and + * printed 63 garbage FAIL rows / crashed. + * + * THE FIX is two stacked concerns, both align-UP-to-cstage: + * (a) general per-arg isassignable at the desugarcallargs choke-point, + * mirroring check.c:1867 (conf-gated like the let/return sibling + * sites; subsumes the old narrow #258 reject arm). + * (b) reserve the synth `__wwtests` name under -T in BOTH stages, + * mirroring the `main` reservation (check.c:2996 / check.ww). A + * user `__wwtests` whose type HAPPENS to match run()'s param + * ([](str,*fn()void)) slips (a) but still silently shadows the + * synth table → reservation rejects it loud regardless of type. + * + * Rows (every row builds+runs on cstage `ww` and, when present, wwstage + * `ww_ww`; rule-10 — both stages must agree): + * row | shape | verdict + * ---------------------+------------------------------------+---------- + * scalar_for_slice | takesslice(int) | REJECT [bug] + * str_for_int | takesint(str) | REJECT + * slice_borrow_ok | takesslice([3]int arr) | ok 0 (#258) + * scalar_arg_ok | takesint(5) | ok 7 (control) + * + * -T faces (bundle via `ww test -c`, then ` -T ` must + * loud-reject; the synth's run() callee is resolved from the bundled + * lib/test): + * wwtests_int | const __wwtests: int | REJECT [(a)] + * wwtests_shadow | const __wwtests: [](str,*fn()void) | REJECT [(b)] + * + * wwtests_shadow is the (b) teeth: its type matches run()'s param so (a) + * stays silent (both unpatched stages built it rc=0, silently running + * the user's 1-entry table instead of the collected @test set) — only + * the name reservation rejects it. + */ +#include +#include +#include +#include +#include +#include + +static int +runwait(const char *cmd) +{ + int rc = system(cmd); + if (rc == -1) return -1; + if (WIFEXITED(rc)) return WEXITSTATUS(rc); + return -1; +} + +struct row { + const char *label; + const char *src; + int expect_build; /* 1 = build+run to want_exit; 0 = must FAIL */ + int want_exit; /* meaningful only when expect_build */ +}; + +static const struct row rows[] = { + /* (1) THE BUG — int where []int is wanted → REJECT both stages. + * Pre-fix wwstage built rc=0 and read the int as a slice header. */ + { "scalar_for_slice", + "package main;\n" + "fn takesslice(xs: []int) void = { return; };\n" + "export fn main() int = {\n" + " let n: int = 5;\n" + " takesslice(n);\n" + " return 0;\n" + "};\n", + 0, 0 }, + + /* (2) str where int is wanted → REJECT both stages. A second scalar + * shape: the general check is not array-specific. */ + { "str_for_int", + "package main;\n" + "fn takesint(x: int) void = { return; };\n" + "export fn main() int = {\n" + " let s: str = \"hi\";\n" + " takesint(s);\n" + " return 0;\n" + "};\n", + 0, 0 }, + + /* (3) CONTROL — [3]int into a []int param is the legit #258 borrow + * (matching element) → ACCEPT both, run to 0. The general check must + * NOT regress the array→slice borrow it subsumes. */ + { "slice_borrow_ok", + "package main;\n" + "fn takesslice(xs: []int) int = { return len(xs): int; };\n" + "export fn main() int = {\n" + " let a: [3]int = [1, 2, 3];\n" + " if (takesslice(a) != 3) { return 1; };\n" + " return 0;\n" + "};\n", + 1, 0 }, + + /* (4) CONTROL — a correctly-typed scalar arg → ACCEPT both, run to 7. + * Pins that the new check does not over-reject a valid scalar call. */ + { "scalar_arg_ok", + "package main;\n" + "fn takesint(x: int) int = { return x; };\n" + "export fn main() int = { return takesint(7); };\n", + 1, 7 }, + + /* (5) scalar→aggregate at the LET context — `let xs: []int = 5` read + * the int as a 24B slice header (same silent-garbage class as the + * call-arg #24, via the SHARED isassignable). REJECT both. */ + { "let_slice_eq_scalar", + "package main;\n" + "export fn main() int = { let xs: []int = 5; return 0; };\n", + 0, 0 }, + + /* (6) bare fn name into a fn-alias param → ACCEPT both (#34/c1': a + * bare fn rvalue types as its fn TYPE; the general check then sees a + * matched fn type). run to 7. */ + { "fn_bare_accept", + "package main;\n" + "type myfn = fn() int;\n" + "fn g() int = { return 7; };\n" + "fn use_it(f: myfn) int = { return f(); };\n" + "export fn main() int = { return use_it(g); };\n", + 1, 7 }, + + /* (7) annotated `g: myfn` into the same param → ACCEPT both, run 7. */ + { "fn_cast_accept", + "package main;\n" + "type myfn = fn() int;\n" + "fn g() int = { return 7; };\n" + "fn use_it(f: myfn) int = { return f(); };\n" + "export fn main() int = { return use_it(g: myfn); };\n", + 1, 7 }, + + /* (8) `&g` (*fn) into a bare-fn alias param → REJECT both. ww-cstage + * takes NO &-required rule for a bare-fn arg, and the *fn-vs-fn KIND + * mismatch is a confident reject (the assignableaddrfn union admits a + * genuine &fn only into a *fn / *alias slot, not a bare-fn alias). */ + { "fn_addr_reject", + "package main;\n" + "type myfn = fn() int;\n" + "fn g() int = { return 7; };\n" + "fn use_it(f: myfn) int = { return f(); };\n" + "export fn main() int = { return use_it(&g); };\n", + 0, 0 }, + + /* (9) matched-signature fn rvalue into a fn slot → ACCEPT both, run 5. + * Pins #34's correct-stamp path (fn type vs fn type, equal sigs). */ + { "fn_match_sig_accept", + "package main;\n" + "fn g() int = { return 5; };\n" + "export fn main() int = { let p: fn() int = g; return p(); };\n", + 1, 5 }, + + /* (10) MISMATCHED-signature fn rvalue into a fn slot → REJECT both + * (#34: `let p: fn() int = h` where h: fn() str — was a silent + * mis-accept via the lenient catch-all; the fn-type stamp now compares + * structurally and rejects). */ + { "fn_mismatch_sig_reject", + "package main;\n" + "fn h() str = { return \"\"; };\n" + "export fn main() int = { let p: fn() int = h; return 0; };\n", + 0, 0 }, + + /* (11) aggregate<->aggregate KIND mismatch — a [3]int array arg into a + * `*int` param. Both aggregate, different kind -> confident reject (the + * array->slice borrow is the ONLY implicit array coercion; array->ptr + * is not). REJECT both. */ + { "aggr_array_into_ptr", + "package main;\n" + "fn takesptr(p: *int) void = { return; };\n" + "export fn main() int = {\n" + " let a: [3]int = [1, 2, 3];\n" + " takesptr(a);\n" + " return 0;\n" + "};\n", + 0, 0 }, + + /* (12) CONTROL — a concrete value into a SPREAD-tagged param must NOT + * over-reject: cstage flattens `...inner` at resolve_type and accepts + * the int leaf; wwstage can't flatten, so it stays LENIENT on a spread + * variant (the faithful escape, mirroring tagged->tagged #115). ACCEPT + * both, run 0. Pins the new check does not regress spread unions. */ + { "spread_tagged_accept", + "package main;\n" + "type inner = (int | str);\n" + "fn take(x: (...inner | bool)) void = { return; };\n" + "export fn main() int = { take(42); return 0; };\n", + 1, 0 }, +}; + +/* run_build — build+run `src` via `driver`; returns the binary's exit + * code, or -1 on a build failure. */ +static int +run_build(const char *driver, const struct row *r, int i) +{ + char src[64], tmpdir[64], cmd[1024]; + snprintf(src, sizeof src, "/tmp/cat_%d_%d.ww", getpid(), i); + snprintf(tmpdir, sizeof tmpdir, "/tmp/cat_%d_d_%d", getpid(), i); + + FILE *f = fopen(src, "wb"); + if (!f) return -2; + fputs(r->src, f); + fclose(f); + + mkdir(tmpdir, 0755); + snprintf(cmd, sizeof cmd, "cd %s && %s build %s 2>/dev/null", + tmpdir, driver, src); + int brc = runwait(cmd); + + const char *base = strrchr(src, '/'); + base = base ? base + 1 : src; + char outbin[128]; + snprintf(outbin, sizeof outbin, "%s/%s", tmpdir, base); + char *dot = strrchr(outbin, '.'); + if (dot && strcmp(dot, ".ww") == 0) *dot = '\0'; + + int got = -1; + if (brc == 0) got = runwait(outbin); + + unlink(src); unlink(outbin); rmdir(tmpdir); + return brc == 0 ? got : -1; +} + +/* build_should_fail — the build must error on `driver`; returns 0 when + * it correctly FAILS, non-zero when it wrongly succeeded. */ +static int +build_should_fail(const char *driver, const char *src, int i) +{ + char s[64], tmpdir[64], cmd[1024]; + snprintf(s, sizeof s, "/tmp/catn_%d_%d.ww", getpid(), i); + snprintf(tmpdir, sizeof tmpdir, "/tmp/catn_%d_d_%d", getpid(), i); + + FILE *f = fopen(s, "wb"); + if (!f) return -1; + fputs(src, f); + fclose(f); + + mkdir(tmpdir, 0755); + snprintf(cmd, sizeof cmd, "cd %s && %s build %s 2>/dev/null", + tmpdir, driver, s); + int rc = runwait(cmd); + + unlink(s); + const char *base = strrchr(s, '/'); + base = base ? base + 1 : s; + char outbin[128]; + snprintf(outbin, sizeof outbin, "%s/%s", tmpdir, base); + char *dot = strrchr(outbin, '.'); + if (dot && strcmp(dot, ".ww") == 0) *dot = '\0'; + unlink(outbin); + rmdir(tmpdir); + return rc == 0 ? -1 : 0; /* build must NOT succeed */ +} + +/* -T reject faces — a user fixture is bundled with lib/test via + * `ww test -c` (compiler-neutral; the synth `run()` callee resolves from + * the bundle), then ` -T ` must loud-reject. */ +struct trow { + const char *label; + const char *src; +}; + +static const struct trow trows[] = { + /* (a) the headline cat-A: user __wwtests:int shadows the synth table + * → run(int) where run wants [](str,*fn()void). Pre-fix wwstage built + * a broken binary (int read as a 24B slice header). */ + { "wwtests_int", + "package main;\n" + "const __wwtests: int = 99;\n" + "@test fn checkfoo() void = { return; };\n" }, + + /* (b) reservation teeth: a user __wwtests whose type MATCHES run()'s + * param typechecks fine — (a)'s isassignable stays silent — but it + * silently shadows the synth table (both unpatched stages built it + * rc=0, running the user's 1-entry table, not the collected @tests). + * Only the `__wwtests` name reservation rejects it. */ + { "wwtests_shadow", + "package main;\n" + "fn dummy() void = { return; };\n" + "const __wwtests: [](str, *fn() void) = [(\"x\", &dummy)];\n" + "@test fn checkfoo() void = { return; };\n" }, +}; + +/* tbundle_reject — bundle `src` via `drv test -c`, then `comp -T` the + * combined unit; the compile must exit nonzero. Returns 0 on the + * expected reject. */ +static int +tbundle_reject(const char *bin, const char *comp, const char *drv, + const struct trow *t, int i) +{ + int pid = getpid(); + char src[128], stem[128], comb[160], cmd[4096]; + snprintf(src, sizeof src, "/tmp/catt_%s_%d_%d.ww", comp, pid, i); + snprintf(stem, sizeof stem, "/tmp/catt_%s_%d_%d", comp, pid, i); + snprintf(comb, sizeof comb, "%s.combined.ww", stem); + + FILE *f = fopen(src, "wb"); + if (!f) return -1; + fputs(t->src, f); + fclose(f); + + snprintf(cmd, sizeof cmd, + "%s/%s test -c -o %s %s > /dev/null 2>&1", bin, drv, stem, src); + runwait(cmd); + if (access(comb, 0) != 0) { + fprintf(stderr, "callarg_typecheck[%s][%s]: %s produced no %s\n", + comp, t->label, drv, comb); + unlink(src); + return -1; + } + + snprintf(cmd, sizeof cmd, "%s/%s -T %s -o /dev/null 2>/dev/null", + bin, comp, comb); + int rc = runwait(cmd); + + unlink(src); unlink(comb); + char tmp[200]; + snprintf(tmp, sizeof tmp, "%s.s", stem); unlink(tmp); + snprintf(tmp, sizeof tmp, "%s.o", stem); unlink(tmp); + unlink(stem); + if (rc == 0) { + fprintf(stderr, "callarg_typecheck[%s][%s]: %s -T accepted " + "(expected a loud reject)\n", comp, t->label, comp); + return -1; + } + return 0; +} + +/* multimod_build_fail — write io2.ww (always; defines handle=(file|stream)), + * mod1.ww (when withmod1), and a main.ww with `mainbody`, then `drv build -I` + * the tree. Returns 0 when the build correctly FAILS (the arg is rejected), + * non-zero when it wrongly built. Drives the #24 cross-module collision rows + * that single-file rows[] can't express. */ +static int +multimod_build_fail(const char *drv, const char *mainbody, int withmod1, + int tag) +{ + int pid = getpid(); + char dir[96], io2d[160], mod1d[160], p[224], cmd[2048], rm[256]; + snprintf(dir, sizeof dir, "/tmp/catcoll_%d_%d", pid, tag); + snprintf(io2d, sizeof io2d, "%s/io2", dir); + snprintf(mod1d, sizeof mod1d, "%s/mod1", dir); + mkdir(dir, 0755); mkdir(io2d, 0755); + if (withmod1) mkdir(mod1d, 0755); + + snprintf(p, sizeof p, "%s/io2.ww", io2d); + FILE *f = fopen(p, "wb"); + if (!f) return -1; + fputs("package io2;\n" + "export type vtable = struct { x: i32 };\n" + "export type stream = *vtable;\n" + "export type file = i32;\n" + "export type handle = (file | stream);\n" + "export fn take(h: handle) int = { return 7; };\n", f); + fclose(f); + + if (withmod1) { + snprintf(p, sizeof p, "%s/mod1.ww", mod1d); + f = fopen(p, "wb"); + if (!f) return -1; + fputs("package mod1;\n" + "export type wbox = struct { y: i64 };\n" + "export type stream = *wbox;\n" + "export fn mk() stream = { return nil; };\n", f); + fclose(f); + } + + snprintf(p, sizeof p, "%s/main.ww", dir); + f = fopen(p, "wb"); + if (!f) return -1; + fputs(mainbody, f); + fclose(f); + + if (withmod1) + snprintf(cmd, sizeof cmd, + "cd %s && %s build -I %s -I %s main.ww >/dev/null 2>&1", + dir, drv, io2d, mod1d); + else + snprintf(cmd, sizeof cmd, + "cd %s && %s build -I %s main.ww >/dev/null 2>&1", + dir, drv, io2d); + int rc = runwait(cmd); + + snprintf(rm, sizeof rm, "rm -rf %s", dir); + runwait(rm); + + return rc == 0 ? -1 : 0; /* build must NOT succeed */ +} + +/* a []int SLICE passed where io2.handle = (file | stream) is wanted — NO + * slice variant. SHAPE-MISMATCH: cstage rejects nominally, and c3's (B) + * shape-matched-lenient leg ALSO rejects (slice src, no slice-shape variant) + * — the #24-B reachable win, asserted DUAL-STAGE (both drivers must fail). */ +static const char COLLIDE_SHAPE_MISMATCH[] = + "package main;\n" + "import io2;\n" + "export fn main() int = {\n" + " let xs: []int = [1, 2, 3];\n" + " return io2.take(xs);\n" + "};\n"; + +/* mod1.stream (a *mod1.wbox) passed where io2.handle is wanted — a cross- + * module SAME-LEAF, SAME-COARSE-SHAPE (both ptr → scalar/other) collision. + * cstage REJECTS on NOMINAL identity; wwstage's AST-keyed isassignable + * OVER-ACCEPTS it — the tracked #10/#66/#37 residual that (B) shape-narrowing + * cannot reach (the tagged variant node is a bare `stream` with no module, + * byte-identical to the genuine io2.stream; the distinguishing identity lives + * only in tinfo/#66, reachable only by the #37 nominal-tinfo conversion). The + * cs-side pin below asserts ONLY cstage's reject (catching a cs regression + + * recording the eventual ww target); the ww over-accept is documented, NOT + * asserted (a dual-stage row would be dark until #37 lands). */ +static const char COLLIDE_SAME_SHAPE[] = + "package main;\n" + "import io2;\n" + "import mod1;\n" + "export fn main() int = {\n" + " let s: mod1.stream = mod1.mk();\n" + " return io2.take(s);\n" + "};\n"; + +int +main(void) +{ + const char *bin = getenv("BIN"); + if (!bin) bin = "out/bin"; + char absbin[1024]; + if (bin[0] != '/') { + char cwd[1024]; + if (getcwd(cwd, sizeof cwd) == NULL) return 1; + snprintf(absbin, sizeof absbin, "%s/%s", cwd, bin); + bin = absbin; + } + + char cdrv[1024], wdrv[1024], wcomp[1024]; + snprintf(cdrv, sizeof cdrv, "%s/ww", bin); + snprintf(wdrv, sizeof wdrv, "%s/ww_ww", bin); + snprintf(wcomp, sizeof wcomp, "%s/w6c_ww", bin); + + struct { const char *name; const char *drv; int gated; } + drivers[] = { + { "cstage", cdrv, 0 }, + { "wwstage", wdrv, 1 }, + { NULL, NULL, 0 }, + }; + + int n = (int)(sizeof rows / sizeof rows[0]); + int tn = (int)(sizeof trows / sizeof trows[0]); + int total = 0, fail = 0; + + for (int d = 0; drivers[d].name; d++) { + if (drivers[d].gated && access(drivers[d].drv, X_OK) != 0) { + fprintf(stderr, "callarg_typecheck: skip %s (no %s)\n", + drivers[d].name, drivers[d].drv); + continue; + } + for (int i = 0; i < n; i++) { + total++; + if (rows[i].expect_build) { + int got = run_build(drivers[d].drv, &rows[i], i); + if (got != rows[i].want_exit) { + fprintf(stderr, "callarg_typecheck[%s][%s]: " + "exit=%d want=%d\n", drivers[d].name, + rows[i].label, got, rows[i].want_exit); + fail++; + } + } else { + if (build_should_fail(drivers[d].drv, rows[i].src, + 100 + i) != 0) { + fprintf(stderr, "callarg_typecheck[%s][%s]: " + "built ok, expected a loud reject\n", + drivers[d].name, rows[i].label); + fail++; + } + } + } + } + + /* -T faces: comp ∈ {w6c (cstage), w6c_ww (wwstage)}; bundle is built + * by the cstage driver (compiler-neutral). wwstage gated. */ + for (int i = 0; i < tn; i++) { + total++; + if (tbundle_reject(bin, "w6c", "ww", &trows[i], i) != 0) + fail++; + } + if (access(wcomp, X_OK) == 0) { + for (int i = 0; i < tn; i++) { + total++; + if (tbundle_reject(bin, "w6c_ww", "ww", &trows[i], + 10 + i) != 0) + fail++; + } + } + + /* #24-B reachable win: the SHAPE-MISMATCH collision ([]int slice into a + * (file|stream) tagged with no slice variant) REJECTS on BOTH stages — + * dual-stage row (cstage nominal + c3's shape-matched-lenient leg). */ + total++; + if (multimod_build_fail(cdrv, COLLIDE_SHAPE_MISMATCH, 0, 1) != 0) { + fprintf(stderr, "callarg_typecheck[cstage][collide_shape_mismatch]:" + " built ok, expected reject\n"); + fail++; + } + if (access(wcomp, X_OK) == 0) { /* wwstage gated (ww_ww present) */ + total++; + if (multimod_build_fail(wdrv, COLLIDE_SHAPE_MISMATCH, 0, 2) != 0) { + fprintf(stderr, "callarg_typecheck[wwstage]" + "[collide_shape_mismatch]: built ok, expected " + "reject (#24-B shape-matched-lenient)\n"); + fail++; + } + } + /* #37 cs-side pin: the SAME-SHAPE cross-module collision rejects on + * cstage; the wwstage over-accept is the documented #10/#66/#37 nominal + * residual (NOT asserted — would be dark until #37 lands). */ + total++; + if (multimod_build_fail(cdrv, COLLIDE_SAME_SHAPE, 1, 3) != 0) { + fprintf(stderr, "callarg_typecheck[cstage][collide_same_shape]: " + "built ok, expected cstage to reject the same-leaf " + "collision (#37 cs-side pin)\n"); + fail++; + } + + if (fail) { + fprintf(stderr, "callarg_typecheck: %d/%d fixtures failed\n", + fail, total); + return 1; + } + printf("callarg_typecheck: %d/%d ok\n", total, total); + return 0; +}