cstage+selfhost+test: refuse let/param shadow of imported module (#19)

When `use fmt;` is in scope and a local/param named `fmt` shadows it,
`fmt.X` in the body silently resolved to the str-typed value sym and
emitted `CALL AX` through str.ptr → runtime crash. Surfaced during
#15 (lib/log's printfln family); worked around by renaming the param
`fmt`→`format`.

Per rob + user, option (C): "value names and module names are
disjoint." Refuse the shadow at the decl site. Single rule, no
non-local reasoning, no silent footgun if a future lib/X exports a
new leaf.

cstage: src_imports walks file->list for N_USE entries (skipping
self-imports where u->module == u->str — same-module fixtures like
lib/fmt/fmttest.ww carry these); check_module_shadow runs before
each SK_PARAM / SK_VAR scope_define (param, clet, mlet, forrange
single + tuple, mcase). Wwstage mirror in check.ww; wwdump-only
diagnostic today, full enforcement waits on #11 checkfile pass.

Bootstrap byte-id holds — no codegen change. One source patch in
selfhost/cmd/w6a/main.ww renames an outer `let asm: asm_;` to `s` to
sidestep task #27 (cstage localoff scope-blind dedup); unrelated to
#19 but the new rule's first run flagged it as a self-shadow.

Test 708 (param_shadow_mod): 4 rows — neg_param (param shadow errs
at fn decl line), neg_let (let shadow errs at let decl), pos_rename
(rename compiles + runs), pos_selfimp (in-module use is skipped).
4 wired sites without dedicated rows deferred to task #28.

Follow-up: lib/log can revert format→fmt now that the silent
crash is impossible.
This commit is contained in:
2026-05-16 08:39:35 +09:00
parent 1aece29d53
commit c9bbfcb6a6
15 changed files with 584 additions and 14 deletions

View File

@@ -0,0 +1,10 @@
// neg_let — local `let shadowmod: i32 = ...` shadows the imported
// module from inside a fn body. Same rule fires for nested-scope
// let binds, not just params.
use shadowmod;
export fn main() i32 = {
let shadowmod: i32 = 0i32;
return shadowmod;
};

View File

@@ -0,0 +1,13 @@
// neg_param — fn param `shadowmod: str` shadows the imported module.
// Under the "value names and module names are disjoint" rule the
// build must fail with a clear diagnostic at the param decl site.
use shadowmod;
fn probe(shadowmod: str) i32 = {
return shadowmod.len;
};
export fn main() i32 = {
return probe("hi");
};

View File

@@ -0,0 +1,14 @@
// pos_rename — positive case. The fn param is renamed away from the
// imported module's bareword, so the rule doesn't fire and the body
// can call `shadowmod.say()` cleanly. Built + run; exit code = 42.
use shadowmod;
fn probe(s: str) i32 = {
let _ = s;
return shadowmod.say();
};
export fn main() i32 = {
return probe("hi");
};

View File

@@ -0,0 +1,7 @@
// pos_selfimp/selfimp.ww — minimal "module" body. The interesting
// scenario lives in the sibling selfimptest.ww file, which carries
// `use selfimp;` from inside the same module.
export fn touch() i32 = {
return 0i32;
};

View File

@@ -0,0 +1,19 @@
// pos_selfimp/selfimptest.ww — same-module self-import case. This
// file's MODULE tag is "selfimp" (parent dir basename), and it
// carries `use selfimp;` — exactly the lib/fmt/fmttest.ww shape that
// originally surfaced check_module_shadow's over-trigger on
// `fn bsprintf(... fmt: str, ...)`.
//
// src_imports' self-import skip (u->module == u->str) drops these
// entries from the import scan, so the param `selfimp: str` here
// must NOT be flagged as shadowing — build + run, exit = 7.
use selfimp;
fn probe(selfimp: str) i32 = {
return selfimp.len;
};
export fn main() i32 = {
return probe("regress");
};

View File

@@ -0,0 +1,7 @@
// paramshadowmod/shadowmod — a tiny module the negative/positive
// fixtures import as `use shadowmod;`. Carries one fn so the leaf
// resolves through the module dot path when name resolution succeeds.
export fn say() i32 = {
return 42i32;
};