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

@@ -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 `<comp> -T <combined>` 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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/wait.h>
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 `<comp> -T <combined>` 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;
}