diff --git a/Makefile b/Makefile index d84e3f05..32790b61 100644 --- a/Makefile +++ b/Makefile @@ -253,6 +253,7 @@ TESTS = $(BIN)/test_smoke $(BIN)/test_lex $(BIN)/test_parse $(BIN)/test_check \ $(BIN)/test_intoverflow_reject \ $(BIN)/test_unknowndecl_reject \ $(BIN)/test_nullableglobal_reject \ + $(BIN)/test_taggedcompoundderef_reject \ $(BIN)/test_idxarg_run \ $(BIN)/test_chainidx_run \ $(BIN)/test_tupfieldsize_run \ @@ -738,6 +739,21 @@ $(BIN)/test_nullableglobal_reject: test/wcc/989_nullableglobal_reject.c \ $(LIB)/libwwrt.a | $(BIN) $(CC) $(CFLAGS) -o $@ $< +# 989_taggedcompoundderef_reject (#18, last of the tagged-payload WRITE +# class): `*p OP= v` where p is a *tagged is nonsense and must loud-reject. +# cstage already gated (sz>=16 falls to the assign-resolver fatal); wwstage +# silently miscompiled (single MOVQ on the tag word). The align-down adds +# the same size gate so both stages reach the identical resolver reject. +# Reject rows assert build-fail + shared diagnostic; control rows (scalar +# compound + #17 plain deref store) build + run. Needs both tool sets + +# libwwrt for the control links. +$(BIN)/test_taggedcompoundderef_reject: test/wcc/989_taggedcompoundderef_reject.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 $@ $< + # 989_idxarg_run (F7-c2, #45/#46): an indexed slice/str element passed as # a call arg must push its full multi-word header. Builds+runs each fixture # on BOTH the cstage `ww` and wwstage `ww_ww` drivers (rule-10), so it needs diff --git a/selfhost/cmd/w6c/main.combined.ww b/selfhost/cmd/w6c/main.combined.ww index f16e3bd8..98edff6a 100644 --- a/selfhost/cmd/w6c/main.combined.ww +++ b/selfhost/cmd/w6c/main.combined.ww @@ -31931,7 +31931,18 @@ fn cgassign(c: *cgen, n: *node) void = { if (lhs != nil) { if (lhs.kind == nkind.N_UN) { if (lhs.op == tkind.TK_STAR) { - if (n.op != tkind.TK_ASSIGN) { + // Size gate (mirror cstage cgen.c handled=sz∈{1,2,4,8}): + // a tagged (or any non-scalar) pointee is not a + // meaningful compound target — skip this single-word + // store-and-return arm so `*p OP= v` on *tagged falls + // through to the assign-resolver's loud TY_TAGGED reject + // (#18). Without it the MOVQ default below clobbers the + // tag word and returns: a silent miscompile. + let psz: i32 = 8; + let lt: *tinfo = lhs.type_: *tinfo; + if (lt != nil) { psz = lt.size: i32; }; + let scalarpointee: bool = (psz == 1 || psz == 2 || psz == 4 || psz == 8); + if (n.op != tkind.TK_ASSIGN && scalarpointee) { let inner: *node = lhs.lhs; let loadop: str = "MOVQ"; let storeop: str = "MOVQ"; diff --git a/selfhost/cmd/wcc/cgenexpr.ww b/selfhost/cmd/wcc/cgenexpr.ww index dfb34cc7..26e86265 100644 --- a/selfhost/cmd/wcc/cgenexpr.ww +++ b/selfhost/cmd/wcc/cgenexpr.ww @@ -8585,7 +8585,18 @@ fn cgassign(c: *cgen, n: *node) void = { if (lhs != nil) { if (lhs.kind == nkind.N_UN) { if (lhs.op == tkind.TK_STAR) { - if (n.op != tkind.TK_ASSIGN) { + // Size gate (mirror cstage cgen.c handled=sz∈{1,2,4,8}): + // a tagged (or any non-scalar) pointee is not a + // meaningful compound target — skip this single-word + // store-and-return arm so `*p OP= v` on *tagged falls + // through to the assign-resolver's loud TY_TAGGED reject + // (#18). Without it the MOVQ default below clobbers the + // tag word and returns: a silent miscompile. + let psz: i32 = 8; + let lt: *tinfo = lhs.type_: *tinfo; + if (lt != nil) { psz = lt.size: i32; }; + let scalarpointee: bool = (psz == 1 || psz == 2 || psz == 4 || psz == 8); + if (n.op != tkind.TK_ASSIGN && scalarpointee) { let inner: *node = lhs.lhs; let loadop: str = "MOVQ"; let storeop: str = "MOVQ"; diff --git a/selfhost/cmd/wwdump/main.combined.ww b/selfhost/cmd/wwdump/main.combined.ww index 384ecb3c..a67e0b35 100644 --- a/selfhost/cmd/wwdump/main.combined.ww +++ b/selfhost/cmd/wwdump/main.combined.ww @@ -31931,7 +31931,18 @@ fn cgassign(c: *cgen, n: *node) void = { if (lhs != nil) { if (lhs.kind == nkind.N_UN) { if (lhs.op == tkind.TK_STAR) { - if (n.op != tkind.TK_ASSIGN) { + // Size gate (mirror cstage cgen.c handled=sz∈{1,2,4,8}): + // a tagged (or any non-scalar) pointee is not a + // meaningful compound target — skip this single-word + // store-and-return arm so `*p OP= v` on *tagged falls + // through to the assign-resolver's loud TY_TAGGED reject + // (#18). Without it the MOVQ default below clobbers the + // tag word and returns: a silent miscompile. + let psz: i32 = 8; + let lt: *tinfo = lhs.type_: *tinfo; + if (lt != nil) { psz = lt.size: i32; }; + let scalarpointee: bool = (psz == 1 || psz == 2 || psz == 4 || psz == 8); + if (n.op != tkind.TK_ASSIGN && scalarpointee) { let inner: *node = lhs.lhs; let loadop: str = "MOVQ"; let storeop: str = "MOVQ"; diff --git a/test/wcc/989_taggedcompoundderef_reject.c b/test/wcc/989_taggedcompoundderef_reject.c new file mode 100644 index 00000000..8fcee68c --- /dev/null +++ b/test/wcc/989_taggedcompoundderef_reject.c @@ -0,0 +1,256 @@ +/* + * 989_taggedcompoundderef_reject — #18, the last of the tagged-payload + * WRITE class. A compound-assign through a deref, `*p OP= v` where p is a + * pointer to a tagged union (`*(int|bool)`), is semantically nonsense: you + * cannot `+=` a whole tagged value. It MUST be a loud compile-time reject. + * + * cstage (cmd/w6c/cgen.c) already rejected it: its `*p OP= v` arm gates on + * `handled = sz in {1,2,4,8}`, so a tagged pointee (sz>=16) falls through + * to the assign-resolver, which fatals "tagged field not wired (rule-7)". + * + * wwstage (selfhost/cmd/wcc/cgenexpr.ww) SILENTLY MISCOMPILED it: its + * compound-deref arm narrowed the store op for ps in {1,2,4} else fell to a + * single MOVQ store-and-return — emitting one 8-byte op on the TAG word and + * returning, never reaching its own (already-present) resolver reject. + * Result: compiled clean, ran WRONG. This is a wwstage align-DOWN (rule-10): + * the fix adds the same size gate so a non-scalar pointee falls through to + * the identical resolver reject. wwstage-ONLY change; cstage untouched. + * + * The diagnostic core text is identical on both stages + * ("assign-resolver: tagged field not wired (rule-7)"); cstage's fatal() + * adds the harness-wide "ww: " prefix that err.ww does not — the same + * per-stage prefix asymmetry every existing both-stage reject carries. The + * matrix asserts rc!=0 AND the shared core substring on each driver. + * + * Reject-matrix idiom (sibling 989_nullableglobal_reject.c): a REJECT row + * must FAIL to build with the diagnostic on every driver; an ACCEPT control + * must build + run to its expected exit. Rows run on cstage `ww` and, when + * present, wwstage `ww_ww`; both must agree. + */ +#include +#include +#include +#include +#include +#include + +static const char *DIAG = "assign-resolver: tagged field not wired (rule-7)"; + +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 REJECT */ + int want_exit; +}; + +/* `let p = &v;` binds p:*(int|bool); `*p OP= 1` compounds the WHOLE tagged + * value -> nonsense -> loud reject on BOTH stages. */ +#define TAGCOMPOUND(OP) \ + "package main;\n" \ + "export fn main() int = {\n" \ + "\tlet v: (int | bool) = 7;\n" \ + "\tlet p = &v;\n" \ + "\t*p " OP " 1;\n" \ + "\treturn 0;\n" \ + "};\n" + +static const struct row rows[] = { + /* Every compound OP on a *tagged deref rejects on both stages. */ + { "pluseq_tagged_deref", TAGCOMPOUND("+="), 0, 0 }, + { "minuseq_tagged_deref", TAGCOMPOUND("-="), 0, 0 }, + { "stareq_tagged_deref", TAGCOMPOUND("*="), 0, 0 }, + { "pipeeq_tagged_deref", TAGCOMPOUND("|="), 0, 0 }, + { "ampeq_tagged_deref", TAGCOMPOUND("&="), 0, 0 }, + { "careteq_tagged_deref", TAGCOMPOUND("^="), 0, 0 }, + { "lshifteq_tagged_deref", TAGCOMPOUND("<<="), 0, 0 }, + { "rshifteq_tagged_deref", TAGCOMPOUND(">>="), 0, 0 }, + { "slasheq_tagged_deref", TAGCOMPOUND("/="), 0, 0 }, + { "percenteq_tagged_deref",TAGCOMPOUND("%="), 0, 0 }, + + /* CONTROL — scalar compound-deref on *int still compiles + runs (the + * gate keys on the non-scalar pointee size; an 8-byte int is handled). + * 7 += 1 -> 8. */ + { "scalar_int_compound_ok", + "package main;\n" + "export fn main() int = {\n" + "\tlet v: int = 7;\n" + "\tlet p = &v;\n" + "\t*p += 1;\n" + "\treturn *p;\n" + "};\n", + 1, 8 }, + + /* CONTROL — narrow scalar compound-deref on *u8 still compiles + runs + * (1-byte pointee is handled; the narrowing path is undisturbed). */ + { "scalar_u8_compound_ok", + "package main;\n" + "export fn main() int = {\n" + "\tlet v: u8 = 7;\n" + "\tlet p = &v;\n" + "\t*p += 1;\n" + "\treturn (*p): int;\n" + "};\n", + 1, 8 }, + + /* CONTROL — the #17 PLAIN deref store on a *tagged still compiles + + * runs (only the COMPOUND form is nonsense; plain `*p = v` widens the + * payload). 9 stored, `v is int` -> 0. */ + { "plain_tagged_deref_store_ok", + "package main;\n" + "export fn main() int = {\n" + "\tlet v: (int | bool) = 7;\n" + "\tlet p = &v;\n" + "\t*p = 9;\n" + "\tif (v is int) { return 0; };\n" + "\treturn 1;\n" + "};\n", + 1, 0 }, +}; + +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/tcd_%d_%d.ww", getpid(), i); + snprintf(tmpdir, sizeof tmpdir, "/tmp/tcd_%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; +} + +/* A reject row must (a) fail to build and (b) emit the shared diagnostic + * core text. Returns 0 on the expected reject, -1 otherwise. */ +static int +build_should_reject(const char *driver, const char *src, int i) +{ + char s[64], tmpdir[64], errf[80], cmd[1280]; + snprintf(s, sizeof s, "/tmp/tcn_%d_%d.ww", getpid(), i); + snprintf(tmpdir, sizeof tmpdir, "/tmp/tcn_%d_d_%d", getpid(), i); + snprintf(errf, sizeof errf, "/tmp/tcn_%d_%d.err", 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>%s", + tmpdir, driver, s, errf); + int rc = runwait(cmd); + + int have_diag = 0; + FILE *e = fopen(errf, "rb"); + if (e) { + char buf[4096]; + size_t n = fread(buf, 1, sizeof buf - 1, e); + buf[n] = '\0'; + fclose(e); + have_diag = (strstr(buf, DIAG) != NULL); + } + + unlink(s); unlink(errf); + 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); + + /* build must NOT succeed AND the shared diagnostic must appear. */ + return (rc != 0 && have_diag) ? 0 : -1; +} + +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]; + snprintf(cdrv, sizeof cdrv, "%s/ww", bin); + snprintf(wdrv, sizeof wdrv, "%s/ww_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 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, "taggedcompoundderef_reject: 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, "taggedcompoundderef_reject[%s][%s]: " + "exit=%d want=%d\n", drivers[d].name, + rows[i].label, got, rows[i].want_exit); + fail++; + } + } else { + if (build_should_reject(drivers[d].drv, rows[i].src, + 100 + i) != 0) { + fprintf(stderr, "taggedcompoundderef_reject[%s][%s]: " + "expected a loud reject with \"%s\"\n", + drivers[d].name, rows[i].label, DIAG); + fail++; + } + } + } + } + + if (fail) { + fprintf(stderr, "taggedcompoundderef_reject: %d/%d fixtures failed\n", + fail, total); + return 1; + } + printf("taggedcompoundderef_reject: %d/%d ok\n", total, total); + return 0; +}