Files
ww/test/wcc/929_match_4arm_cross_module_run.c
Hojun-Cho b787641ef9 selfhost+test: route N_DOT match scrutinee through fnretlookupmod (#31)
Wwstage matchscrutt now mirrors cstage's typed-AST scrutinee-type
lookup for module-qualified mod.fn(...) callees, restoring per-arm
tag dispatch on cross-module shadowed-name 4-arm matches. Class A
runtime miscompile, silent across collectfnrets shadowing — was
the 8th unmask of session 5.

Pre-fix: wwstage's matchscrutt N_DOT branch (cgenutil.ww:2061)
called `fnretlookup(c, callee.str)` — name-only resolution.
collectfnrets prepends to c.fnrets, so when a caller fn (e.g.
lib/strings's `next`) shadows a callee fn-name (utf8's `next`),
the prepend chain has the caller's narrower tagged return at the
head. matchscrutt then resolved the scrutinee type to the WRONG
tagged shape, and variantindex lookups for arms past the
shadowing caller's variant count returned -1 → want=0 →
match-arm `CMPQ $0, AX` for arms 2 and 3 on a (rune | done |
more | invalid) probe. Effect: arms 2/3 silently unreachable
even when the runtime tag matched, falling through to default.

Cstage gets the scrutinee type via the checker-set callee type
on the N_DOT node, so picks the correct utf8.next return shape.

Polarity catalog: wwstage UNDER — fnretlookup missing module-
preferring discipline. **Third leaf in the same trio**: #27
(aliaslookupmod), #28 (fnparamslookupmod), #31 (fnretlookupmod).
Pattern is recurring; full graduation of all leaf-name lookups
to same-module-first is a candidate for STATUS-3 task #1
variant-widen consolidation refactor (deferred to next session
opener per rob).

Fix: new fnretlookupmod helper in cgen.ww (same-module-first
walk, fallback to existing first-match — cell-for-cell mirror
of fnparamslookupmod from #28). matchscrutt N_DOT branch
extracts `cmod` from callee.lhs.str and routes through the
helper. Other 13 fnretlookup callsites untouched per #28's
"fix only what has a real consumer" discipline. fnret.fmod
field + collectfnrets f.fmod assignment already landed in #28.

Surfaced by lib/strings commit-2 pre-flight: probe iter+next
shape calls utf8.next; the probe's own `fn next` shadows
utf8.next at the c.fnrets head. Bootstrap-stable because no
selfhost-corpus path shadows a fn name across modules with a
wider tagged return on the shadowed side; lib/strings.iter
pulling utf8.next under wwstage was the first exerciser.

Filed follow-up (NOT in scope here): #32 wwstage runtime stomp
on utf8.next via *iterator caller — separate Class A surfaced
by 929 direct utf8.next regression row design. #31's fix is
correct in isolation; #32 blocks lib/strings commit 2 (#30).

Tests:
  - 728_match_4arm_cross_module pins distinct CMPQ $K, AX tags
    in TEXT b.next via bitmap covering [0..arms), robust to
    arm ordering. Three cross-module shadowed-name shapes × cmp
    -s byte-id. Sentinel-flip-verified: revert fnretlookupmod
    route → 3/6 wwstage fixtures fail "arm K repeats tag $0
    (collapse)".
  - 929_match_4arm_cross_module_run runtime-pins 6 rows × 2
    stages per-arm exit-code shape: 3/4/5/6-arm boundary,
    mixed (i32|str|rune|u8), reverse arm-order in match source.
    Confirms bug follows fnretlookup-resolved type, not match
    source order.

102/102 ok. 995_self_rebuild stays green (ww2==ww3==ww4 byte-id).
2026-05-18 11:12:14 +09:00

345 lines
11 KiB
C

/*
* 929_match_4arm_cross_module_run — Class B semantic test for task #31.
* Pre-fix wwstage's matchscrutt resolved `match (mod.fn(...))` by
* name-only fnretlookup, so when the caller fn shadowed the callee's
* name across modules the match dispatch saw the wrong (caller's)
* tagged type and arms past the caller's variant count silently
* collapsed onto tag 0 (their bodies were unreachable even when the
* runtime tag matched).
*
* Pure runtime test: build through both drivers (cstage `ww`, wwstage
* `ww_ww`) and assert each arm's body actually fires for its matching
* input. 728_match_4arm_cross_module pins the asm-level distinct-CMPQ
* sentinel; this file pins end-to-end behavior across the rob matrix:
* - 3-arm boundary (does arm 2 collapse?).
* - 4-arm canonical (the probe shape).
* - 5/6-arm scaling.
* - Mixed variant kinds.
* - Reverse arm-order in match source.
*/
#include <stdio.h>
#include <stdlib.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 *a_src; /* callee module source — written as a/a.ww */
const char *b_src; /* caller module + main — written as b.ww */
int want;
};
static const struct row rows[] = {
/* Canonical 4-arm with shadowing `next`. Drives the callee to
* return each of the 4 variants in turn (via a selector arg) and
* checks the caller dispatched to the right arm. Arm 2/3 reaching
* their bodies is the post-fix invariant. */
{ "4arm_shadowed_canonical",
/* a.ww */
"export type more = void;\n"
"export type invalid = !void;\n"
"export type done = void;\n"
"export fn next(k: i32) (rune | done | more | invalid) = {\n"
" if (k == 0) { return 0x41u32: rune; };\n"
" if (k == 1) { let v: done; return v; };\n"
" if (k == 2) { let v: more; return v; };\n"
" let v: invalid; return v;\n"
"};\n",
/* b.ww */
"use a;\n"
"type done = void;\n"
"fn next(k: i32) i32 = {\n"
" match (a.next(k)) {\n"
" case let r: rune => return 100i32 + (r: i32);\n"
" case let dn: a.done => return 200;\n"
" case let m: a.more => return 300;\n"
" case let e: a.invalid => return 400;\n"
" };\n"
"};\n"
"export fn main() i32 = {\n"
" if (next(0) != 165) { return 11; };\n"
" if (next(1) != 200) { return 12; };\n"
" if (next(2) != 300) { return 13; };\n"
" if (next(3) != 400) { return 14; };\n"
" return 0;\n"
"};\n",
0 },
/* 3-arm boundary: arm 2 must reach its body. */
{ "3arm_shadowed",
"export type more = void;\n"
"export type done = void;\n"
"export fn next(k: i32) (rune | done | more) = {\n"
" if (k == 0) { return 0x42u32: rune; };\n"
" if (k == 1) { let v: done; return v; };\n"
" let v: more; return v;\n"
"};\n",
"use a;\n"
"type done = void;\n"
"fn next(k: i32) i32 = {\n"
" match (a.next(k)) {\n"
" case let r: rune => return 1i32 + (r: i32);\n"
" case let dn: a.done => return 2;\n"
" case let m: a.more => return 3;\n"
" };\n"
"};\n"
"export fn main() i32 = {\n"
" if (next(0) != 67) { return 11; };\n"
" if (next(1) != 2) { return 12; };\n"
" if (next(2) != 3) { return 13; };\n"
" return 0;\n"
"};\n",
0 },
/* 5-arm scaling: arms 3 and 4 must each reach their body. */
{ "5arm_shadowed",
"export type more = void;\n"
"export type invalid = !void;\n"
"export type done = void;\n"
"export type stop = void;\n"
"export fn next(k: i32) (rune | done | more | invalid | stop) = {\n"
" if (k == 0) { return 0x43u32: rune; };\n"
" if (k == 1) { let v: done; return v; };\n"
" if (k == 2) { let v: more; return v; };\n"
" if (k == 3) { let v: invalid; return v; };\n"
" let v: stop; return v;\n"
"};\n",
"use a;\n"
"type done = void;\n"
"fn next(k: i32) i32 = {\n"
" match (a.next(k)) {\n"
" case let r: rune => return 100i32 + (r: i32);\n"
" case let dn: a.done => return 2;\n"
" case let m: a.more => return 3;\n"
" case let e: a.invalid => return 4;\n"
" case let s: a.stop => return 5;\n"
" };\n"
"};\n"
"export fn main() i32 = {\n"
" if (next(0) != 167) { return 11; };\n"
" if (next(1) != 2) { return 12; };\n"
" if (next(2) != 3) { return 13; };\n"
" if (next(3) != 4) { return 14; };\n"
" if (next(4) != 5) { return 15; };\n"
" return 0;\n"
"};\n",
0 },
/* 6-arm: extra row to demonstrate the bug doesn't scale with arm
* count — every arm beyond the caller's variant count was broken,
* not just arm 2 / arm 3. */
{ "6arm_shadowed",
"export type more = void;\n"
"export type invalid = !void;\n"
"export type done = void;\n"
"export type stop = void;\n"
"export type eof = void;\n"
"export fn next(k: i32) (rune | done | more | invalid | stop | eof) = {\n"
" if (k == 0) { return 0x44u32: rune; };\n"
" if (k == 1) { let v: done; return v; };\n"
" if (k == 2) { let v: more; return v; };\n"
" if (k == 3) { let v: invalid; return v; };\n"
" if (k == 4) { let v: stop; return v; };\n"
" let v: eof; return v;\n"
"};\n",
"use a;\n"
"type done = void;\n"
"fn next(k: i32) i32 = {\n"
" match (a.next(k)) {\n"
" case let r: rune => return 100i32 + (r: i32);\n"
" case let dn: a.done => return 2;\n"
" case let m: a.more => return 3;\n"
" case let e: a.invalid => return 4;\n"
" case let s: a.stop => return 5;\n"
" case let f: a.eof => return 6;\n"
" };\n"
"};\n"
"export fn main() i32 = {\n"
" if (next(0) != 168) { return 11; };\n"
" if (next(1) != 2) { return 12; };\n"
" if (next(2) != 3) { return 13; };\n"
" if (next(3) != 4) { return 14; };\n"
" if (next(4) != 5) { return 15; };\n"
" if (next(5) != 6) { return 16; };\n"
" return 0;\n"
"};\n",
0 },
/* Mixed variant kinds. Callee returns (i32 | str | rune | u8);
* caller `next` shadows. Confirms the shadowed-resolution fix
* isn't shape-specific. */
{ "4arm_mixed_kinds",
"export fn next(k: i32) (i32 | str | rune | u8) = {\n"
" if (k == 0) { return 7; };\n"
" if (k == 1) { return \"hi\"; };\n"
" if (k == 2) { return 0x45u32: rune; };\n"
" return 9u8;\n"
"};\n",
"use a;\n"
"fn next(k: i32) i32 = {\n"
" match (a.next(k)) {\n"
" case let n: i32 => return 100 + n;\n"
" case let s: str => return 200i32 + (s.len: i32);\n"
" case let r: rune => return 300i32 + (r: i32);\n"
" case let c: u8 => return 400i32 + (c: i32);\n"
" };\n"
"};\n"
"export fn main() i32 = {\n"
" if (next(0) != 107) { return 11; };\n"
" if (next(1) != 202) { return 12; };\n"
" if (next(2) != 369) { return 13; };\n"
" if (next(3) != 409) { return 14; };\n"
" return 0;\n"
"};\n",
0 },
/* Reverse arm-order in the match source. Confirms the bug
* follows scrutinee-resolution (fnretlookupmod), not source
* order — emitted CMPQ tags follow the callee's variant indices
* regardless of how the arms were written. */
{ "4arm_shadowed_reverse",
"export type more = void;\n"
"export type invalid = !void;\n"
"export type done = void;\n"
"export fn next(k: i32) (rune | done | more | invalid) = {\n"
" if (k == 0) { return 0x46u32: rune; };\n"
" if (k == 1) { let v: done; return v; };\n"
" if (k == 2) { let v: more; return v; };\n"
" let v: invalid; return v;\n"
"};\n",
"use a;\n"
"type done = void;\n"
"fn next(k: i32) i32 = {\n"
" match (a.next(k)) {\n"
" case let e: a.invalid => return 4;\n"
" case let m: a.more => return 3;\n"
" case let dn: a.done => return 2;\n"
" case let r: rune => return 100i32 + (r: i32);\n"
" };\n"
"};\n"
"export fn main() i32 = {\n"
" if (next(0) != 170) { return 11; };\n"
" if (next(1) != 2) { return 12; };\n"
" if (next(2) != 3) { return 13; };\n"
" if (next(3) != 4) { return 14; };\n"
" return 0;\n"
"};\n",
0 },
/* utf8.next regression row deferred: a direct repro of the
* originally-failing probe_strings_iter shape (caller next(rune|done)
* shadows callee utf8.next(rune|done|more|invalid)) compiles cleanly
* post-#31 but wwstage segfaults at runtime through utf8.next.
* Cstage runs fine. Separate latent wwstage stomp in the bigger utf8
* iterator shape — task #31's fix is correct in isolation; the 4arm
* canonical row above covers the structural pattern. Filed as a
* follow-up so the utf8 regression marker doesn't gate this commit. */
};
static int
write_file(const char *dir, const char *name, const char *body)
{
char path[512];
snprintf(path, sizeof path, "%s/%s", dir, name);
FILE *f = fopen(path, "wb");
if (!f) return -1;
fputs(body, f);
fclose(f);
return 0;
}
static int
run_driver(const char *driver, const struct row *r, int i)
{
char tmpdir[96], adir[128], cmd[2048];
snprintf(tmpdir, sizeof tmpdir, "/tmp/m4cmr_%d_%d", getpid(), i);
snprintf(adir, sizeof adir, "%s/a", tmpdir);
mkdir(tmpdir, 0755);
mkdir(adir, 0755);
if (write_file(adir, "a.ww", r->a_src) != 0) return -1;
if (write_file(tmpdir, "b.ww", r->b_src) != 0) return -1;
/* Build via the driver from inside tmpdir so `use a;` resolves to
* ./a/a.ww and ww's source-dir search hits b's siblings first. */
snprintf(cmd, sizeof cmd,
"cd %s && %s build b.ww 2>/dev/null", tmpdir, driver);
if (runwait(cmd) != 0) {
fprintf(stderr, "row[%s]: build via %s failed\n",
r->label, driver);
snprintf(cmd, sizeof cmd, "rm -rf %s", tmpdir);
runwait(cmd);
return -1;
}
char outbin[160];
snprintf(outbin, sizeof outbin, "%s/b", tmpdir);
int got = runwait(outbin);
snprintf(cmd, sizeof cmd, "rm -rf %s", tmpdir);
runwait(cmd);
return got;
}
int
main(void)
{
const char *bin = getenv("BIN");
if (!bin) bin = "out/bin";
char absbin[512];
if (bin[0] != '/') {
char cwd[256];
if (getcwd(cwd, sizeof cwd) == NULL) return 1;
snprintf(absbin, sizeof absbin, "%s/%s", cwd, bin);
bin = absbin;
}
char cdrv[640];
snprintf(cdrv, sizeof cdrv, "%s/ww", bin);
char wdrv[640];
snprintf(wdrv, sizeof wdrv, "%s/ww_ww", bin);
struct { const char *name; const char *path; int gated_on_existence; }
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_on_existence
&& access(drivers[d].path, X_OK) != 0) {
fprintf(stderr,
"match_4arm_cross_module_run: skip %s (no %s)\n",
drivers[d].name, drivers[d].path);
continue;
}
for (int i = 0; i < n; i++) {
int got = run_driver(drivers[d].path, &rows[i], i);
total++;
if (got != rows[i].want) {
fprintf(stderr,
"match_4arm_cross_module_run[%s][%s]: exit=%d want=%d\n",
drivers[d].name, rows[i].label,
got, rows[i].want);
fail++;
}
}
}
if (fail) {
fprintf(stderr,
"match_4arm_cross_module_run: %d/%d fixtures failed\n",
fail, total);
return 1;
}
printf("match_4arm_cross_module_run: %d/%d ok\n", total, total);
return 0;
}