From 08cfb5b2dd50601043ba27607f7ce80caf3dd168 Mon Sep 17 00:00:00 2001 From: Hojun-Cho Date: Wed, 17 Jun 2026 21:25:06 +0900 Subject: [PATCH] wcc,ww: bare-module fn mangles bare, not an imported same-leaf (M4 E2, #84) A package-less primary's bare fn (module="") whose leaf collided with an imported module's same-leaf exported fn was mis-mangled to the imported qualified name (a user `fn run` emitted as `test.run`), producing a dead-duplicate symbol the linker silently shadowed -- a #263-class silent miscompile, gate-blind and symmetric across both stages. cgen now registers bare-module fns and resolves a bare-ident reference to its own bare leaf: mod_lookup_for_fn prefers the bare entry when the call carries no module hint and skips bare entries when it does, so the moduled-caller path stays byte-identical. The moduled `main` entry carve-out is an orthogonal rule (the linker entry is force-bared) and is retained. Prereq for the @test user-`run` coexist (#80). The bare non-fn (let/def/type) sibling is the same class but hint-less; deferred as #85, noted at the retained skip. --- Makefile | 11 ++ cmd/w6c/cgen.c | 51 ++++-- selfhost/cmd/w6c/main.combined.ww | 88 +++++++---- selfhost/cmd/wcc/cgen.ww | 73 ++++++--- selfhost/cmd/wcc/cgendecl.ww | 15 +- selfhost/cmd/wwdump/main.combined.ww | 88 +++++++---- test/wcc/989_barefn_collide_run.c | 222 +++++++++++++++++++++++++++ 7 files changed, 452 insertions(+), 96 deletions(-) create mode 100644 test/wcc/989_barefn_collide_run.c diff --git a/Makefile b/Makefile index 3fab64ed..a56cd0c5 100644 --- a/Makefile +++ b/Makefile @@ -560,6 +560,7 @@ TESTS = $(BIN)/test_smoke $(BIN)/test_lex $(BIN)/test_parse $(BIN)/test_check \ $(BIN)/test_lib_byteid \ $(BIN)/test_m2wwi_run \ $(BIN)/test_m3sep_run \ + $(BIN)/test_barefn_collide_run \ $(BIN)/test_sepbuild_run \ $(BIN)/test_sepdotpath_run \ $(BIN)/test_seproot_export_run \ @@ -3133,6 +3134,16 @@ $(BIN)/test_sepbuild_run: test/wcc/989_sepbuild_run.c $(BIN)/ww $(BIN)/ww_ww \ $(BIN)/w6l $(BIN)/w6l_ww $(LIB)/libwwrt.a | $(BIN) $(CC) $(CFLAGS) -o $@ $< +# 989_barefn_collide_run — #84 cgen bare-module fn-leaf collision gate. A +# package-less root's bare `fn run` must stay BARE (distinct from an +# imported `aa.run`), not mis-mangle onto the import (a #263-class silent +# dead-dup). Drives BOTH driver stages on a real dir-package import, so it +# needs both driver + both compiler + both linker stages + libwwrt. +$(BIN)/test_barefn_collide_run: test/wcc/989_barefn_collide_run.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 $@ $< + # 989_sepdotpath_run — sep-build of a DOTTED-path (multi-component) package # (#57): the producer must tag a primary body by its full dotted import path # (`//ww:module-reset a.b`), not the leaf `package` clause, so definer == diff --git a/cmd/w6c/cgen.c b/cmd/w6c/cgen.c index 65b5eb03..aa13d0f0 100644 --- a/cmd/w6c/cgen.c +++ b/cmd/w6c/cgen.c @@ -1458,18 +1458,31 @@ mod_collect(Cg *c, Node *file) * like fns — `export def MAX` becomes `.MAX`, not a bare * `MAX` that two packages could clash on under sep-compile. No * export skip: every decl with a module mangles identically. */ - if (d->module == NULL || d->module[0] == '\0') continue; if (decl_has_ffisym(d)) continue; - /* `main` is the linker entry-point convention. Even when not - * marked `export`, it must keep its bare name so w6l can - * resolve `_start`'s `CALL main(SB)`. M1 #32: only the ROOT - * unit's main stays bare; an IMPORTED `fn main` mangles on its - * path (closes #31's dup-main by construction). */ + int bare = (d->module == NULL || d->module[0] == '\0'); + /* #84: register bare-module (package-less `//ww:module-reset`) + * FNS too — previously ALL bare decls were skipped here, so a + * bare fn never entered mod_map and a bare-ident ref to it + * first-matched an imported module's same-leaf fn (mod_lookup_ + * for_fn) → a #40 residual / #263-class silent dead-dup. With + * the bare entry present, mod_lookup_for_fn prefers it on a NULL + * hint. Non-fn bare decls keep the legacy skip (their collision + * class is outside #84's fn scope; pre-bind proven). */ + /* #85 (deferred): the bare-NON-fn sibling (a package-less let/def/ + * type leaf colliding with an imported same-kind export) needs a + * different fix — mod_lookup (non-fn) is hint-LESS — and has no + * corpus repro; retained skip until then (rule-11). */ + if (bare && !isfn) continue; + /* `main` is the linker entry-point convention. A `package main` + * primary's decls are MODULED "main", so main is NOT bare here; + * skip it (cgfn force-emits the bare `main` label). M1 #32: only + * the ROOT main (imported==0) stays bare; an IMPORTED `fn main` + * mangles on its path (closes #31's dup-main by construction). */ if (d->str && strcmp(d->str, "main") == 0 && !d->imported) continue; Mod *m = amalloc(c->a, sizeof *m); m->name = d->str; - m->module = d->module; + m->module = bare ? NULL : d->module; m->next = mod_map; mod_map = m; } @@ -1502,8 +1515,18 @@ mod_lookup_for_fn(const char *name, const char *hint) const char *first = NULL; for (Mod *m = mod_map; m; m = m->next) { if (strcmp(m->name, name) != 0) continue; - if (hint != NULL && m->module != NULL - && strcmp(m->module, hint) == 0) + if (m->module == NULL) { + /* #84: a bare-module entry (now registered). A bare-ident + * ref (hint==NULL: the caller is itself in the bare/root + * module) resolves to it — bare wins over an imported + * same-leaf fn, by construction, order-independent. With a + * non-NULL hint the caller named a module, so a bare decl + * is irrelevant: skip it, leaving the legacy first-imported + * -match untouched (byte-id-neutral for moduled callers). */ + if (hint == NULL) return NULL; + continue; + } + if (hint != NULL && strcmp(m->module, hint) == 0) return m->module; if (first == NULL) first = m->module; } @@ -14931,9 +14954,13 @@ cgfn(Cg *c, FILE *out, Node *fn) /* Mangle the label using the fn's own module as the hint — picks * the right entry when multiple modules export the same leaf. M1 #32: * the ROOT-unit main (imported==0) is the bare `_start` entry — emit - * it bare directly, mirroring mod_collect's skip; without this its - * NULL cur_mod would fall through mafn's first-leaf match onto an - * IMPORTED package's now-registered `pkg.main`. */ + * it bare directly, mirroring mod_collect's skip. This is irreducibly + * name-based: a `package main` primary's decls are MODULED "main" + * (the package clause sets cur_mod; cmd/ww/main.c:394), so main is + * NOT a bare-module decl — removing this carve-out mangles it to + * `main.main` (undefined `main`). #84's bare-module handling is the + * orthogonal axis (a package-LESS `//ww:module-reset` fn, cur_mod + * NULL), resolved in mod_lookup_for_fn, NOT here. */ if (fn->str && strcmp(fn->str, "main") == 0 && !fn->imported) text->to = asym("main"); else diff --git a/selfhost/cmd/w6c/main.combined.ww b/selfhost/cmd/w6c/main.combined.ww index 9b2497db..7414c4ac 100644 --- a/selfhost/cmd/w6c/main.combined.ww +++ b/selfhost/cmd/w6c/main.combined.ww @@ -41304,12 +41304,15 @@ fn cgfn(c: *cgen, fn_: *syntax.node) void = { if ((frame & 15) != 0) { frame = (frame + 15) & ~15; }; // Emit the TEXT label via emitfnname so the def site picks up the - // same skip rule (FFI / `main` / empty-module) and the same module - // hint (this fn's own module) that the call sites use. M1 #32: the - // ROOT-unit main (imported==0) is the bare `_start` entry — emit it - // bare directly, mirroring collectmods' skip; without this its - // fn_.nmod hint would fall through modlookupforfn's first-leaf match - // onto an IMPORTED package's now-registered `pkg.main`. + // same module hint (this fn's own module) the call sites use. M1 #32: + // the ROOT-unit main (imported==0) is the bare `_start` entry — emit + // it bare directly, mirroring collectmods' skip. Irreducibly name- + // based: a `package main` primary's decls are MODULED "main" (the + // package clause sets curmod; cmd/ww/main.c:394), so main is NOT a + // bare-module decl — dropping this carve-out mangles it to `main.main` + // (undefined `main`). #84's bare-module handling is the orthogonal + // axis (a package-LESS `//ww:module-reset` fn, nmod empty), resolved + // in modlookupforfn, NOT here. emitline("TEXT "); if (syntax.streq(fn_.str, "main") && fn_.imported == 0) { emitbytes(fn_.str.ptr, fn_.str.len: u64); @@ -45224,23 +45227,32 @@ fn collectmods(c: *cgen, file: *syntax.node) void = { if (d.kind == syntax.nkind.N_FNDECL) { // Fns mangle regardless of export status — covers // lib/os.read vs lib/io.read collision. - if (d.nmod.len > 0) { - let isffi: bool = false; - let a: *syntax.node = d.attr; - for (a != nil) { - if (a.kind == syntax.nkind.N_ATTR) { - let an: str = a.str; - if (syntax.streq(an, "symbol")) { isffi = true; }; - }; - a = a.next; + let isffi: bool = false; + let a: *syntax.node = d.attr; + for (a != nil) { + if (a.kind == syntax.nkind.N_ATTR) { + let an: str = a.str; + if (syntax.streq(an, "symbol")) { isffi = true; }; }; - if (!isffi) { - // M1 #32: the ROOT main (imported==0) stays bare; - // an IMPORTED `fn main` mangles on its path. - if (!syntax.streq(d.str, "main") || d.imported != 0) { - let m: *modent = alloc(modent{mname=d.str, nmod=d.nmod, omod=d.nmod, mnext=c.mods})!; - c.mods = m; - }; + a = a.next; + }; + if (!isffi) { + // #84: register bare-module (package-less `//ww:module- + // reset`) fns TOO (nmod.len==0) — previously the + // `nmod.len > 0` gate skipped ALL bare fns, so a bare fn + // never entered c.mods and a bare-ident ref to it first- + // matched an imported same-leaf fn (modlookupforfn) → a + // #40 residual / #263-class silent dead-dup. With the bare + // entry present, modlookupforfn prefers it on an empty + // hint. main is the linker entry convention: a `package + // main` primary's decls are MODULED "main" so main is NOT + // bare here — skip it (cgfn force-emits the bare `main` + // label). M1 #32: only ROOT main (imported==0) stays bare; + // an IMPORTED `fn main` mangles on its path. Mirrors + // cstage cgen.c mod_collect. + if (!syntax.streq(d.str, "main") || d.imported != 0) { + let m: *modent = alloc(modent{mname=d.str, nmod=d.nmod, omod=d.nmod, mnext=c.mods})!; + c.mods = m; }; }; }; @@ -45248,6 +45260,11 @@ fn collectmods(c: *cgen, file: *syntax.node) void = { // like fns — `export def MAX` becomes `.MAX`, not a bare // `MAX` two packages could clash on under sep-compile. No export // guard: every decl with a module mangles identically. + // #85 (deferred): the `nmod.len > 0` gate below retains the + // bare-NON-fn skip — the sibling of #84 (a package-less let/def/ + // type leaf colliding with an imported same-kind export) needs a + // different fix (modlookup is hint-LESS) and has no corpus repro; + // retained until then (rule-11). if (d.kind == syntax.nkind.N_DEF) { if (d.nmod.len > 0) { let m: *modent = alloc(modent{mname=d.str, nmod=d.nmod, omod=d.nmod, mnext=c.mods})!; @@ -45323,12 +45340,27 @@ fn modlookupforfn(c: *cgen, name: str, hint: str) str = { first.len = 0; for (m != nil) { if (syntax.streq(m.mname, name)) { - if (hint.len > 0 && m.nmod.len > 0 - && syntax.streq(m.nmod, hint)) { - return m.nmod; - }; - if (first.len == 0 && first.ptr == nil) { - first = m.nmod; + if (m.nmod.len == 0) { + // #84: a bare-module entry (now registered). A bare-ident + // ref (hint.len==0: the caller is itself in the bare/root + // module) resolves to it — bare wins over an imported + // same-leaf fn, by construction, order-independent. A + // non-empty hint named a module, so a bare decl is + // irrelevant: skip it, leaving the legacy first-imported- + // match untouched (byte-id-neutral for moduled callers). + if (hint.len == 0) { + let empty: str; + empty.ptr = nil; + empty.len = 0; + return empty; + }; + } else { + if (hint.len > 0 && syntax.streq(m.nmod, hint)) { + return m.nmod; + }; + if (first.len == 0 && first.ptr == nil) { + first = m.nmod; + }; }; }; m = m.mnext; diff --git a/selfhost/cmd/wcc/cgen.ww b/selfhost/cmd/wcc/cgen.ww index 32123fce..c8f40d65 100644 --- a/selfhost/cmd/wcc/cgen.ww +++ b/selfhost/cmd/wcc/cgen.ww @@ -3852,23 +3852,32 @@ fn collectmods(c: *cgen, file: *syntax.node) void = { if (d.kind == syntax.nkind.N_FNDECL) { // Fns mangle regardless of export status — covers // lib/os.read vs lib/io.read collision. - if (d.nmod.len > 0) { - let isffi: bool = false; - let a: *syntax.node = d.attr; - for (a != nil) { - if (a.kind == syntax.nkind.N_ATTR) { - let an: str = a.str; - if (syntax.streq(an, "symbol")) { isffi = true; }; - }; - a = a.next; + let isffi: bool = false; + let a: *syntax.node = d.attr; + for (a != nil) { + if (a.kind == syntax.nkind.N_ATTR) { + let an: str = a.str; + if (syntax.streq(an, "symbol")) { isffi = true; }; }; - if (!isffi) { - // M1 #32: the ROOT main (imported==0) stays bare; - // an IMPORTED `fn main` mangles on its path. - if (!syntax.streq(d.str, "main") || d.imported != 0) { - let m: *modent = alloc(modent{mname=d.str, nmod=d.nmod, omod=d.nmod, mnext=c.mods})!; - c.mods = m; - }; + a = a.next; + }; + if (!isffi) { + // #84: register bare-module (package-less `//ww:module- + // reset`) fns TOO (nmod.len==0) — previously the + // `nmod.len > 0` gate skipped ALL bare fns, so a bare fn + // never entered c.mods and a bare-ident ref to it first- + // matched an imported same-leaf fn (modlookupforfn) → a + // #40 residual / #263-class silent dead-dup. With the bare + // entry present, modlookupforfn prefers it on an empty + // hint. main is the linker entry convention: a `package + // main` primary's decls are MODULED "main" so main is NOT + // bare here — skip it (cgfn force-emits the bare `main` + // label). M1 #32: only ROOT main (imported==0) stays bare; + // an IMPORTED `fn main` mangles on its path. Mirrors + // cstage cgen.c mod_collect. + if (!syntax.streq(d.str, "main") || d.imported != 0) { + let m: *modent = alloc(modent{mname=d.str, nmod=d.nmod, omod=d.nmod, mnext=c.mods})!; + c.mods = m; }; }; }; @@ -3876,6 +3885,11 @@ fn collectmods(c: *cgen, file: *syntax.node) void = { // like fns — `export def MAX` becomes `.MAX`, not a bare // `MAX` two packages could clash on under sep-compile. No export // guard: every decl with a module mangles identically. + // #85 (deferred): the `nmod.len > 0` gate below retains the + // bare-NON-fn skip — the sibling of #84 (a package-less let/def/ + // type leaf colliding with an imported same-kind export) needs a + // different fix (modlookup is hint-LESS) and has no corpus repro; + // retained until then (rule-11). if (d.kind == syntax.nkind.N_DEF) { if (d.nmod.len > 0) { let m: *modent = alloc(modent{mname=d.str, nmod=d.nmod, omod=d.nmod, mnext=c.mods})!; @@ -3951,12 +3965,27 @@ fn modlookupforfn(c: *cgen, name: str, hint: str) str = { first.len = 0; for (m != nil) { if (syntax.streq(m.mname, name)) { - if (hint.len > 0 && m.nmod.len > 0 - && syntax.streq(m.nmod, hint)) { - return m.nmod; - }; - if (first.len == 0 && first.ptr == nil) { - first = m.nmod; + if (m.nmod.len == 0) { + // #84: a bare-module entry (now registered). A bare-ident + // ref (hint.len==0: the caller is itself in the bare/root + // module) resolves to it — bare wins over an imported + // same-leaf fn, by construction, order-independent. A + // non-empty hint named a module, so a bare decl is + // irrelevant: skip it, leaving the legacy first-imported- + // match untouched (byte-id-neutral for moduled callers). + if (hint.len == 0) { + let empty: str; + empty.ptr = nil; + empty.len = 0; + return empty; + }; + } else { + if (hint.len > 0 && syntax.streq(m.nmod, hint)) { + return m.nmod; + }; + if (first.len == 0 && first.ptr == nil) { + first = m.nmod; + }; }; }; m = m.mnext; diff --git a/selfhost/cmd/wcc/cgendecl.ww b/selfhost/cmd/wcc/cgendecl.ww index 387ef01f..96b81c28 100644 --- a/selfhost/cmd/wcc/cgendecl.ww +++ b/selfhost/cmd/wcc/cgendecl.ww @@ -598,12 +598,15 @@ fn cgfn(c: *cgen, fn_: *syntax.node) void = { if ((frame & 15) != 0) { frame = (frame + 15) & ~15; }; // Emit the TEXT label via emitfnname so the def site picks up the - // same skip rule (FFI / `main` / empty-module) and the same module - // hint (this fn's own module) that the call sites use. M1 #32: the - // ROOT-unit main (imported==0) is the bare `_start` entry — emit it - // bare directly, mirroring collectmods' skip; without this its - // fn_.nmod hint would fall through modlookupforfn's first-leaf match - // onto an IMPORTED package's now-registered `pkg.main`. + // same module hint (this fn's own module) the call sites use. M1 #32: + // the ROOT-unit main (imported==0) is the bare `_start` entry — emit + // it bare directly, mirroring collectmods' skip. Irreducibly name- + // based: a `package main` primary's decls are MODULED "main" (the + // package clause sets curmod; cmd/ww/main.c:394), so main is NOT a + // bare-module decl — dropping this carve-out mangles it to `main.main` + // (undefined `main`). #84's bare-module handling is the orthogonal + // axis (a package-LESS `//ww:module-reset` fn, nmod empty), resolved + // in modlookupforfn, NOT here. emitline("TEXT "); if (syntax.streq(fn_.str, "main") && fn_.imported == 0) { emitbytes(fn_.str.ptr, fn_.str.len: u64); diff --git a/selfhost/cmd/wwdump/main.combined.ww b/selfhost/cmd/wwdump/main.combined.ww index 6e539e8e..44ef631d 100644 --- a/selfhost/cmd/wwdump/main.combined.ww +++ b/selfhost/cmd/wwdump/main.combined.ww @@ -41304,12 +41304,15 @@ fn cgfn(c: *cgen, fn_: *syntax.node) void = { if ((frame & 15) != 0) { frame = (frame + 15) & ~15; }; // Emit the TEXT label via emitfnname so the def site picks up the - // same skip rule (FFI / `main` / empty-module) and the same module - // hint (this fn's own module) that the call sites use. M1 #32: the - // ROOT-unit main (imported==0) is the bare `_start` entry — emit it - // bare directly, mirroring collectmods' skip; without this its - // fn_.nmod hint would fall through modlookupforfn's first-leaf match - // onto an IMPORTED package's now-registered `pkg.main`. + // same module hint (this fn's own module) the call sites use. M1 #32: + // the ROOT-unit main (imported==0) is the bare `_start` entry — emit + // it bare directly, mirroring collectmods' skip. Irreducibly name- + // based: a `package main` primary's decls are MODULED "main" (the + // package clause sets curmod; cmd/ww/main.c:394), so main is NOT a + // bare-module decl — dropping this carve-out mangles it to `main.main` + // (undefined `main`). #84's bare-module handling is the orthogonal + // axis (a package-LESS `//ww:module-reset` fn, nmod empty), resolved + // in modlookupforfn, NOT here. emitline("TEXT "); if (syntax.streq(fn_.str, "main") && fn_.imported == 0) { emitbytes(fn_.str.ptr, fn_.str.len: u64); @@ -45224,23 +45227,32 @@ fn collectmods(c: *cgen, file: *syntax.node) void = { if (d.kind == syntax.nkind.N_FNDECL) { // Fns mangle regardless of export status — covers // lib/os.read vs lib/io.read collision. - if (d.nmod.len > 0) { - let isffi: bool = false; - let a: *syntax.node = d.attr; - for (a != nil) { - if (a.kind == syntax.nkind.N_ATTR) { - let an: str = a.str; - if (syntax.streq(an, "symbol")) { isffi = true; }; - }; - a = a.next; + let isffi: bool = false; + let a: *syntax.node = d.attr; + for (a != nil) { + if (a.kind == syntax.nkind.N_ATTR) { + let an: str = a.str; + if (syntax.streq(an, "symbol")) { isffi = true; }; }; - if (!isffi) { - // M1 #32: the ROOT main (imported==0) stays bare; - // an IMPORTED `fn main` mangles on its path. - if (!syntax.streq(d.str, "main") || d.imported != 0) { - let m: *modent = alloc(modent{mname=d.str, nmod=d.nmod, omod=d.nmod, mnext=c.mods})!; - c.mods = m; - }; + a = a.next; + }; + if (!isffi) { + // #84: register bare-module (package-less `//ww:module- + // reset`) fns TOO (nmod.len==0) — previously the + // `nmod.len > 0` gate skipped ALL bare fns, so a bare fn + // never entered c.mods and a bare-ident ref to it first- + // matched an imported same-leaf fn (modlookupforfn) → a + // #40 residual / #263-class silent dead-dup. With the bare + // entry present, modlookupforfn prefers it on an empty + // hint. main is the linker entry convention: a `package + // main` primary's decls are MODULED "main" so main is NOT + // bare here — skip it (cgfn force-emits the bare `main` + // label). M1 #32: only ROOT main (imported==0) stays bare; + // an IMPORTED `fn main` mangles on its path. Mirrors + // cstage cgen.c mod_collect. + if (!syntax.streq(d.str, "main") || d.imported != 0) { + let m: *modent = alloc(modent{mname=d.str, nmod=d.nmod, omod=d.nmod, mnext=c.mods})!; + c.mods = m; }; }; }; @@ -45248,6 +45260,11 @@ fn collectmods(c: *cgen, file: *syntax.node) void = { // like fns — `export def MAX` becomes `.MAX`, not a bare // `MAX` two packages could clash on under sep-compile. No export // guard: every decl with a module mangles identically. + // #85 (deferred): the `nmod.len > 0` gate below retains the + // bare-NON-fn skip — the sibling of #84 (a package-less let/def/ + // type leaf colliding with an imported same-kind export) needs a + // different fix (modlookup is hint-LESS) and has no corpus repro; + // retained until then (rule-11). if (d.kind == syntax.nkind.N_DEF) { if (d.nmod.len > 0) { let m: *modent = alloc(modent{mname=d.str, nmod=d.nmod, omod=d.nmod, mnext=c.mods})!; @@ -45323,12 +45340,27 @@ fn modlookupforfn(c: *cgen, name: str, hint: str) str = { first.len = 0; for (m != nil) { if (syntax.streq(m.mname, name)) { - if (hint.len > 0 && m.nmod.len > 0 - && syntax.streq(m.nmod, hint)) { - return m.nmod; - }; - if (first.len == 0 && first.ptr == nil) { - first = m.nmod; + if (m.nmod.len == 0) { + // #84: a bare-module entry (now registered). A bare-ident + // ref (hint.len==0: the caller is itself in the bare/root + // module) resolves to it — bare wins over an imported + // same-leaf fn, by construction, order-independent. A + // non-empty hint named a module, so a bare decl is + // irrelevant: skip it, leaving the legacy first-imported- + // match untouched (byte-id-neutral for moduled callers). + if (hint.len == 0) { + let empty: str; + empty.ptr = nil; + empty.len = 0; + return empty; + }; + } else { + if (hint.len > 0 && syntax.streq(m.nmod, hint)) { + return m.nmod; + }; + if (first.len == 0 && first.ptr == nil) { + first = m.nmod; + }; }; }; m = m.mnext; diff --git a/test/wcc/989_barefn_collide_run.c b/test/wcc/989_barefn_collide_run.c new file mode 100644 index 00000000..6f4348e0 --- /dev/null +++ b/test/wcc/989_barefn_collide_run.c @@ -0,0 +1,222 @@ +/* + * 989_barefn_collide_run — #84 cgen bare-module fn-leaf collision gate. + * + * A bare-module fn (module==NULL — a package-LESS `//ww:module-reset` + * primary, e.g. a user test file) whose leaf collides with an IMPORTED + * module's same-leaf fn was MIS-MANGLED to the imported qualified name: + * mod_collect skipped bare decls, so the bare fn never entered mod_map, + * and at emission mod_lookup_for_fn(leaf, hint=NULL) first-matched the + * imported entry → emitted `.` instead of bare ``. That + * produced a DUPLICATE symbol with the imported fn (w6l-tolerated, #31- + * class) and the bare fn became silently dead — a #40 residual / #263- + * class silent miscompile, gate-blind and symmetric cs==ww. Surfaced by + * #80's `ww test` coexist (lib/test exports `run`). + * + * Fixture (the minimal real repro): a dir-package `aa` exporting `run`, + * imported by a PACKAGE-LESS root that defines its OWN bare `fn run` and + * calls it from `main`. Package-less is REQUIRED: a `package main` root's + * decls mangle `main.` (the package clause sets the module), so its + * `run` would be `main.run` — no collision with `aa.run`, vacuous. Only a + * package-less primary yields a truly BARE `run`, the #84 trigger. + * + * Asserts (combined `ww build`, BOTH driver stages; the fix lives in + * mod_collect/mod_lookup_for_fn which run in every mode, so combined is + * the minimal repro — `--sep` reproduces identically): + * 1. Build + run, BOTH stages → exit 9 (the USER's bare `run`, return 9), + * never aa.run (return 5). + * 2. cs==ww (rule 10): the combined `.s` is byte-identical between the + * two driver stages. + * 3. NON-VACUITY + SOUNDNESS (the #84-specific signal): the `.s` has + * EXACTLY ONE `TEXT run` (the user's, bare) AND EXACTLY ONE `TEXT + * aa.run` (the import) — i.e. DISTINCT symbols, no dup. Pre-fix this + * row fails: two `TEXT aa.run` (the user's `run` mis-mangled onto the + * import) and zero bare `TEXT run`. (Exit alone is not a reliable + * signal: pre-fix the dup happened to bind the user's copy under this + * link order and still exited 9 — the label count is the proof.) + * + * Light wwstage-driver test (CLAUDE.md rule 14): all intermediates are + * `-o`-redirected to /tmp, phase-parallel-safe. Models 989_sepbuild_run.c. + */ +#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; +} + +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; +} + +static int +write_file(const char *path, const char *body) +{ + FILE *f = fopen(path, "wb"); + if (!f) return -1; + fputs(body, f); + fclose(f); + return 0; +} + +/* Count occurrences of a line beginning with `^TEXT ,` in the .s. */ +static int +count_text_label(const char *sfile, const char *sym) +{ + char *buf = NULL; + size_t n = 0; + if (slurp(sfile, &buf, &n) < 0) return -1; + char needle[128]; + snprintf(needle, sizeof needle, "TEXT %s,", sym); + size_t nl = strlen(needle); + int count = 0; + size_t i = 0; + while (i < n) { + size_t j = i; + while (j < n && buf[j] != '\n') j++; + if (j - i >= nl && strncmp(buf + i, needle, nl) == 0) + count++; + i = j + 1; + } + free(buf); + return count; +} + +static const char *aa_src = + "package aa;\n" + "export fn run() i32 = { return 5; };\n"; + +/* PACKAGE-LESS root (no `package` clause) → its `fn run` is bare (the #84 + * trigger); a `package main` root would mangle it `main.run` (vacuous). */ +static const char *root_src = + "import aa;\n" + "fn run() i32 = { return 9; };\n" + "export fn main() i32 = { return run(); };\n"; + +int +main(void) +{ + const char *bin = absbin(); + if (!bin) { fprintf(stderr, "84 FAIL: getcwd\n"); return 1; } + + char td[64], cmd[8192]; + int fail = 0; + snprintf(td, sizeof td, "/tmp/wwbare84_%d", getpid()); + snprintf(cmd, sizeof cmd, "rm -rf %s", td); + runwait(cmd); + mkdir(td, 0755); + /* All paths derive from `td` (a small fixed 64-byte buffer) so the + * snprintfs are provably non-truncating (warning-clean). */ + char aadir[1024], aaww[1024], rootww[1024]; + snprintf(aadir, sizeof aadir, "%s/aa", td); + mkdir(aadir, 0755); + snprintf(aaww, sizeof aaww, "%s/aa/aa.ww", td); + snprintf(rootww, sizeof rootww, "%s/root.ww", td); + if (write_file(aaww, aa_src) || write_file(rootww, root_src)) { + fprintf(stderr, "84 FAIL: write fixture\n"); + fail++; + goto out; + } + + struct { const char *drv, *tag; char prog[1024], sfile[1024]; } + stg[] = { { "ww", "cs", {0}, {0} }, { "ww_ww", "ww", {0}, {0} } }; + + for (int s = 0; s < 2; s++) { + snprintf(stg[s].prog, sizeof stg[s].prog, "%s/prog.%s", td, stg[s].tag); + snprintf(stg[s].sfile, sizeof stg[s].sfile, "%s/prog.%s.s", td, stg[s].tag); + snprintf(cmd, sizeof cmd, + "timeout 240 %s/%s build -o %s -I %s %s >/dev/null 2>&1", + bin, stg[s].drv, stg[s].prog, td, rootww); + if (runwait(cmd) != 0) { + fprintf(stderr, "84 FAIL: %s build\n", stg[s].drv); + fail++; + continue; + } + /* (1) the user's bare `run` (9) must win over imported aa.run (5). */ + int rc = runwait(stg[s].prog); + if (rc != 9) { + fprintf(stderr, "84 FAIL: %s prog exit=%d expected 9 " + "(bare user run, not aa.run=5)\n", stg[s].drv, rc); + fail++; + } + /* (3) distinct symbols, no dup: exactly one bare `run` + one + * `aa.run`. Pre-fix: two `aa.run` (user run mis-mangled) + zero + * bare `run`. */ + int nrun = count_text_label(stg[s].sfile, "run"); + int naa = count_text_label(stg[s].sfile, "aa.run"); + if (nrun != 1 || naa != 1) { + fprintf(stderr, "84 FAIL: %s labels TEXT run=%d aa.run=%d " + "(want 1/1 — bare user run distinct from import, no dup)\n", + stg[s].drv, nrun, naa); + fail++; + } + } + + /* (2) cs==ww (rule 10): the combined .s is byte-identical. */ + if (files_eq(stg[0].sfile, stg[1].sfile) != 0) { + fprintf(stderr, "84 FAIL: cs .s != ww .s (rule 10)\n"); + fail++; + } + +out: + snprintf(cmd, sizeof cmd, "rm -rf %s", td); + runwait(cmd); + if (fail) { + fprintf(stderr, "84: %d check(s) failed\n", fail); + return 1; + } + printf("barefn_collide: package-less root `fn run` (bare) coexists with " + "imported aa.run — distinct labels, no dup, user run wins (exit 9), " + "cs==ww, both driver stages (#84)\n"); + return 0; +}