w6c,ww: re-emit ... union-spread marker in .wwi producer (M4 E3, #95)

The N_TTAGGED serializer emitted each variant via wwi_type but never
re-emitted the `...` prefix for TK_ELLIPSIS spread variants, so an
exported `(...inner | str)` round-tripped through .wwi as `(inner | str)`.
The consumer's checker then could not flatten inner's members into the
alias and variadic-assignability rejected bare members — under separate
compilation this broke fmt/log/getopt. Re-emit `...` before the variant
type, both stages; the producer stays purely syntactic (flatten/dedup
remain the consumer's type-store job, per ref/hare/hare/unparse/type.ha:290-300).

Gate: test/wcc/989_wwispread_sep.c — table-driven (2-arm + 3-arm spreads)
x both stages, asserts the marker survives the .wwi, the consumer binds
bare members under --sep (exit 0), and cs==ww .wwi byte-identity.
This commit is contained in:
2026-06-18 09:52:19 +09:00
parent f4d809dc79
commit 24ca570a7e
9 changed files with 244 additions and 1 deletions

View File

@@ -537,7 +537,7 @@ TESTS = $(BIN)/test_smoke $(BIN)/test_lex $(BIN)/test_parse $(BIN)/test_check \
$(BIN)/test_w6c_ww $(BIN)/test_ww_ww $(BIN)/test_self_rebuild \ $(BIN)/test_w6c_ww $(BIN)/test_ww_ww $(BIN)/test_self_rebuild \
$(BIN)/test_dyn_ww $(BIN)/test_selfcheck \ $(BIN)/test_dyn_ww $(BIN)/test_selfcheck \
$(BIN)/test_attest_record $(BIN)/test_attest_drop \ $(BIN)/test_attest_record $(BIN)/test_attest_drop \
$(BIN)/test_declns_sep \ $(BIN)/test_declns_sep $(BIN)/test_wwispread_sep \
$(BIN)/test_fmt_run $(BIN)/test_log_run $(BIN)/test_fnmatch_run \ $(BIN)/test_fmt_run $(BIN)/test_log_run $(BIN)/test_fnmatch_run \
$(BIN)/test_shlex_run $(BIN)/test_getenv_run $(BIN)/test_dirs_run \ $(BIN)/test_shlex_run $(BIN)/test_getenv_run $(BIN)/test_dirs_run \
$(BIN)/test_dirs_toolong_run \ $(BIN)/test_dirs_toolong_run \
@@ -2967,6 +2967,13 @@ $(BIN)/test_declns_sep: test/wcc/989_declns_sep.c $(BIN)/ww $(BIN)/ww_ww \
$(BIN)/w6l $(BIN)/w6l_ww $(LIB)/libwwrt.a | $(BIN) $(BIN)/w6l $(BIN)/w6l_ww $(LIB)/libwwrt.a | $(BIN)
$(CC) $(CFLAGS) -o $@ $< $(CC) $(CFLAGS) -o $@ $<
# #95 — .wwi producer must preserve the `...` union-spread marker; dir-package
# `ww build --sep` + run, both driver stages, 2-arm + 3-arm shapes + cs==ww.
$(BIN)/test_wwispread_sep: test/wcc/989_wwispread_sep.c $(BIN)/ww $(BIN)/ww_ww \
$(BIN)/w6c $(BIN)/w6c_ww $(BIN)/w6a $(BIN)/w6a_ww \
$(BIN)/w6l $(BIN)/w6l_ww $(LIB)/libwwrt.a | $(BIN)
$(CC) $(CFLAGS) -o $@ $<
$(BIN)/test_fmt_run: test/wcc/970_fmt_run.c $(BIN)/ww $(BIN)/w6c \ $(BIN)/test_fmt_run: test/wcc/970_fmt_run.c $(BIN)/ww $(BIN)/w6c \
$(BIN)/w6a $(BIN)/w6l $(LIB)/libwwrt.a | $(BIN) $(BIN)/w6a $(BIN)/w6l $(LIB)/libwwrt.a | $(BIN)
$(CC) $(CFLAGS) -o $@ $< $(CC) $(CFLAGS) -o $@ $<

View File

@@ -272,6 +272,11 @@ wwi_type(FILE *of, Node *t)
fputc('(', of); fputc('(', of);
for (Node *e = t->list; e; e = e->next) { for (Node *e = t->list; e; e = e->next) {
if (e != t->list) fputs(" | ", of); if (e != t->list) fputs(" | ", of);
/* #95: re-emit the spread marker purely syntactically;
* the consumer's type-store flattens (ref/hare/hare/
* unparse/type.ha:290-300). */
if (e->op == TK_ELLIPSIS)
fputs("...", of);
wwi_type(of, e); wwi_type(of, e);
} }
fputc(')', of); fputc(')', of);

View File

@@ -45855,6 +45855,10 @@ fn wwitype(fd: i32, t: *syntax.node) void = {
let e: *syntax.node = t.list; let e: *syntax.node = t.list;
for (e != nil) { for (e != nil) {
if (e != t.list) { wputs(fd, " | "); }; if (e != t.list) { wputs(fd, " | "); };
// #95: re-emit the spread marker purely syntactically;
// the consumer's type-store flattens
// (ref/hare/hare/unparse/type.ha:290-300).
if (e.op == syntax.tkind.TK_ELLIPSIS) { wputs(fd, "..."); };
wwitype(fd, e); wwitype(fd, e);
e = e.next; e = e.next;
}; };

View File

@@ -365,6 +365,10 @@ fn wwitype(fd: i32, t: *syntax.node) void = {
let e: *syntax.node = t.list; let e: *syntax.node = t.list;
for (e != nil) { for (e != nil) {
if (e != t.list) { wputs(fd, " | "); }; if (e != t.list) { wputs(fd, " | "); };
// #95: re-emit the spread marker purely syntactically;
// the consumer's type-store flattens
// (ref/hare/hare/unparse/type.ha:290-300).
if (e.op == syntax.tkind.TK_ELLIPSIS) { wputs(fd, "..."); };
wwitype(fd, e); wwitype(fd, e);
e = e.next; e = e.next;
}; };

View File

@@ -0,0 +1,183 @@
/*
* 989_wwispread_sep — #95 .wwi producer must preserve the `...` union-spread
* marker when serializing a tagged-union type under `ww build --sep`.
*
* A spread variant `(...inner | str)` carries Node.op == TK_ELLIPSIS on the
* variant node (parse.c:315-324). The N_TTAGGED serializer loop must re-emit
* the `...` prefix purely syntactically (cstage wwi.c, selfhost wwi.ww) —
* mirroring Hare's unparse (ref/hare/hare/unparse/type.ha:290-300), which
* leaves flattening to the consumer's type-store. Before the fix the marker
* was dropped: liba.wwi read back as `(inner | str)`, a consumer never
* flattened inner's arms, and a bare i32/i64 member was rejected — so fmt /
* log / getopt failed under the sep flip. (The old combined.ww path never
* round-trips through .wwi, so it accepted; the bug was latent until the flip.)
*
* TABLE-DRIVEN over two spread shapes (2-arm `inner` and 3-arm `three`). For
* each shape and each driver stage (cs `ww`, ww `ww_ww`) the test:
* (a) builds the consumer via `ww build --sep` and runs it — clean compile
* + exit 0 proves the imported alias's arms flattened into the spread
* type (the consumer assigns BARE members the spread must expose);
* (b) greps the produced <pkg>.wwi and asserts the spread marker survived;
* (c) cs==ww (rule 10): the .wwi from `ww` vs `ww_ww` must be byte-identical.
*
* NON-VACUITY: reverting the wwi.c/.ww edit reddens BOTH legs — the consumer
* build fails with `init i32 not assignable to declared outer` AND the marker
* grep finds nothing (verified by hand at fix time).
*
* Light wwstage-driver test (CLAUDE.md rule 14): every build's intermediates
* are `-o`-redirected to a private /tmp dir, so it is phase-1 parallel-safe.
* Models 989_declns_sep conventions; 989 prefix per the sep-gate precedent.
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/stat.h>
static int
runwait(const char *cmd)
{
int rc = system(cmd);
if (rc == -1) return -1;
if (WIFEXITED(rc)) return WEXITSTATUS(rc);
return 1;
}
static const char *
absbin(void)
{
const char *b = getenv("BIN");
if (!b) b = "out/bin";
if (b[0] == '/') return b;
static char buf[2048];
char cwd[1024];
if (getcwd(cwd, sizeof cwd) == NULL) return NULL;
snprintf(buf, sizeof buf, "%s/%s", cwd, b);
return buf;
}
static int
slurp(const char *path, char **outbuf, size_t *outlen)
{
FILE *f = fopen(path, "rb");
if (!f) return -1;
fseek(f, 0, SEEK_END);
long n = ftell(f);
fseek(f, 0, SEEK_SET);
if (n < 0) { fclose(f); return -1; }
char *b = malloc((size_t)n + 1);
if (!b) { fclose(f); return -1; }
if (fread(b, 1, (size_t)n, f) != (size_t)n) { free(b); fclose(f); return -1; }
b[n] = '\0';
fclose(f);
*outbuf = b;
*outlen = (size_t)n;
return 0;
}
static int
files_eq(const char *a, const char *b)
{
char *ba = NULL, *bb = NULL;
size_t na = 0, nb = 0;
if (slurp(a, &ba, &na) < 0 || slurp(b, &bb, &nb) < 0) {
free(ba); free(bb);
return -1;
}
int eq = (na == nb && memcmp(ba, bb, na) == 0);
free(ba); free(bb);
return eq ? 0 : 1;
}
/* 0 if `needle` occurs in the file at `path`, else 1 (or -1 on read err). */
static int
file_contains(const char *path, const char *needle)
{
char *b = NULL;
size_t n = 0;
if (slurp(path, &b, &n) < 0) return -1;
int found = (strstr(b, needle) != NULL);
free(b);
return found ? 0 : 1;
}
struct shape {
const char *name; /* label */
const char *main; /* consumer main.ww (imports the spread package) */
const char *wwi; /* produced interface basename for the imported pkg */
const char *marker; /* the spread marker that must survive serialization */
};
static const struct shape shapes[] = {
{ "twoarm", "test/wcc/data/wwispread/main.ww", "liba.wwi", "...inner" },
{ "threearm", "test/wcc/data/wwispread3/main.ww", "libb.wwi", "...three" },
};
static const struct { const char *drv; const char *tag; } drivers[] = {
{ "ww", "cs" },
{ "ww_ww", "ww" },
};
int
main(void)
{
const char *bin = absbin();
if (!bin) { fprintf(stderr, "wwispread FAIL: getcwd\n"); return 1; }
char td[64], cmd[4096];
snprintf(td, sizeof td, "/tmp/wwwwispread_%d", getpid());
snprintf(cmd, sizeof cmd, "rm -rf %s", td);
runwait(cmd);
mkdir(td, 0755);
int fail = 0;
for (size_t s = 0; s < sizeof shapes / sizeof shapes[0]; s++) {
char wwi[2][256];
for (size_t d = 0; d < sizeof drivers / sizeof drivers[0]; d++) {
char prog[160];
snprintf(prog, sizeof prog, "%s/%s.%s", td, shapes[s].name,
drivers[d].tag);
snprintf(cmd, sizeof cmd, "%s/%s build --sep -o %s %s 2>/dev/null",
bin, drivers[d].drv, prog, shapes[s].main);
if (runwait(cmd) != 0) {
fprintf(stderr, "wwispread FAIL: %s build --sep %s "
"(spread member not assignable → marker dropped?)\n",
drivers[d].drv, shapes[s].name);
fail++;
continue;
}
int got = runwait(prog);
if (got != 0) {
fprintf(stderr, "wwispread FAIL: %s %s run exit=%d want=0\n",
drivers[d].drv, shapes[s].name, got);
fail++;
}
snprintf(wwi[d], sizeof wwi[d], "%s/%s.%s.sepwork/%s", td,
shapes[s].name, drivers[d].tag, shapes[s].wwi);
if (file_contains(wwi[d], shapes[s].marker) != 0) {
fprintf(stderr, "wwispread FAIL: %s %s — %s missing `%s` "
"(spread marker dropped in serializer)\n", drivers[d].drv,
shapes[s].name, shapes[s].wwi, shapes[s].marker);
fail++;
}
}
/* cs==ww (rule 10): both stages emit the same interface text. */
if (files_eq(wwi[0], wwi[1]) != 0) {
fprintf(stderr, "wwispread FAIL: %s cs!=ww for %s (rule 10)\n",
shapes[s].name, shapes[s].wwi);
fail++;
}
}
snprintf(cmd, sizeof cmd, "rm -rf %s", td);
runwait(cmd);
if (fail) {
fprintf(stderr, "wwispread: %d check(s) failed\n", fail);
return 1;
}
printf("wwispread: `...` union-spread marker survives .wwi serialization "
"(2-arm + 3-arm, both stages cs==ww), consumer flattens bare members "
"→ exit 0 (#95)\n");
return 0;
}

View File

@@ -0,0 +1,8 @@
package liba;
// `outer` spreads the named alias `inner` (a 2-arm union). The `...` marker
// must survive serialization to liba.wwi so a consumer flattens inner's arms
// into outer (#95). Without it, outer reads back as (inner | str) and a bare
// i32/i64 is not assignable.
export type inner = (i32 | i64);
export type outer = (...inner | str);

View File

@@ -0,0 +1,13 @@
package main;
import liba;
// Each assignment binds a BARE member of inner (i32, i64) or the outer-direct
// arm (str) to liba.outer — typechecks ONLY if the spread flattened inner's
// arms into outer across the liba.wwi round-trip (#95).
export fn main() void = {
let x: liba.outer = 42i32;
x = 99i64;
x = "hi";
return;
};

View File

@@ -0,0 +1,6 @@
package libb;
// 3-arm spread: proves ALL of three's arms (i32, i64, rune) flatten into
// wide, not just the first (#95).
export type three = (i32 | i64 | rune);
export type wide = (...three | str);

View File

@@ -0,0 +1,13 @@
package main;
import libb;
// Bind each of three's arms (i32, i64, rune) plus the str arm to libb.wide:
// typechecks ONLY if all three flattened across the libb.wwi round-trip (#95).
export fn main() void = {
let a: libb.wide = 1i32;
a = 2i64;
a = 9i32: rune;
a = "z";
return;
};