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

@@ -14,6 +14,8 @@
static void cstmt(Checker*, Node*);
static Type *cexpr(Checker*, Node*);
static Type *resolve_type(Checker*, Node*);
static void check_module_shadow(Checker*, const char *name, Pos,
const char *kindstr);
static Type *
err(Checker *c, Pos p, const char *fmt, ...)
@@ -1217,8 +1219,11 @@ cexpr(Checker *c, Node *n)
type_name(c->a, alt->type),
type_name(c->a, st));
}
if (cs->str && cs->str[0])
if (cs->str && cs->str[0]) {
check_module_shadow(c, cs->str,
cs->pos, "binding");
scope_define(c->cur, cs->str, SK_VAR, vt, cs);
}
}
cstmt(c, cs->body);
c->cur = saved;
@@ -1434,6 +1439,7 @@ clet(Checker *c, Node *n)
type_name(c->a, initt), type_name(c->a, declared));
n->type = t;
if (n->str && n->str[0]) {
check_module_shadow(c, n->str, n->pos, "let");
Sym *s = scope_define(c->cur, n->str, SK_VAR, t, n);
if (s && n->op == TK_CONST) s->is_const = 1;
}
@@ -1493,12 +1499,16 @@ cstmt(Checker *c, Node *n)
Tparam *tp = (etu && etu->kind == TY_TUPLE) ? etu->params : NULL;
for (Node *nm = n->list; nm; nm = nm->next) {
Type *ft = tp ? tp->type : ty_err;
if (nm->str && nm->str[0])
if (nm->str && nm->str[0]) {
check_module_shadow(c, nm->str,
nm->pos, "binding");
scope_define(c->cur, nm->str,
SK_VAR, ft, nm);
}
if (tp) tp = tp->next;
}
} else if (n->str && n->str[0]) {
check_module_shadow(c, n->str, n->pos, "binding");
scope_define(c->cur, n->str, SK_VAR,
elem ? elem : ty_err, n);
}
@@ -1548,6 +1558,7 @@ cstmt(Checker *c, Node *n)
type_name(c->a, declared));
l->type = t;
if (l->str && l->str[0]) {
check_module_shadow(c, l->str, l->pos, "let");
Sym *s = scope_define(c->cur, l->str, SK_VAR, t, l);
if (s && n->op == TK_CONST) s->is_const = 1;
}
@@ -1667,10 +1678,88 @@ decl_mod(Node *file, Node *d)
return NULL;
}
/*
* src_imports — does the source file that contributed decl-module
* `modtag` carry `use <name>;` somewhere? With driver concatenation
* the combined N_FILE collects N_USE nodes from every contributing
* source; each carries its origin module tag on n->module. Filter
* by `modtag` so lib/log's `use fmt;` only colours decls whose
* d->module == "log", not lib/fmt's own decls.
*
* modtag == NULL → primary compilation unit's own use directives
* (N_USE nodes with module == NULL).
*/
static int
src_imports(Node *file, const char *modtag, const char *name)
{
if (file == NULL || name == NULL || name[0] == '\0') return 0;
for (Node *u = file->list; u; u = u->next) {
if (u->kind != N_USE) continue;
/* Skip self-imports: lib/fmt/fmttest.ww carries `use fmt;`
* even though its module tag is also "fmt"; that directive
* doesn't introduce a foreign module bareword and lib/fmt's
* own `fn bsprintf(fmt: str, ...)` is not a shadow of it. */
if (u->module && u->str && strcmp(u->module, u->str) == 0)
continue;
/* decl_mod normalises the raw `// MODULE:` tag back to NULL
* for primary-source N_USEs (the primary's own tag won't
* appear as a `use` import elsewhere). modtag matches the
* same convention from decl_mod called on the binding decl. */
const char *um = decl_mod(file, u);
if (modtag == NULL) {
if (um != NULL) continue;
} else {
if (um == NULL || strcmp(um, modtag) != 0) continue;
}
if (u->str && strcmp(u->str, name) == 0) return 1;
}
return 0;
}
/*
* check_module_shadow — refuse value bindings that shadow an
* in-scope imported module bareword. "Value names and module names
* are disjoint": a fn param / let / mcase binding named `fmt` while
* the declaring source carries `use fmt;` would silently miscompile
* any `fmt.X` body lookup through the shadow's value bits (the
* cstage cexpr N_DOT path resolves the inner ident as the shadow
* and emits CALL through its bytes — task #19).
*
* Scope:
* - Fires only for nested-scope binds (c->cur != c->top). Same-leaf
* top-level decls (`use foo; fn foo(...)`) are intentional and
* handled by the SK_USE→SK_X promotion path with use_alias=1.
* - Filters by the declaring source's own use directives. lib/fmt's
* `fn fprintf(fmt: str, ...)` is fine because lib/fmt doesn't
* import itself.
* - Walks every scope (not just innermost) so a deeper shadow that
* happens to mask the SK_USE entry can't suppress the check.
*/
static void
check_module_shadow(Checker *c, const char *name, Pos pos,
const char *kindstr)
{
if (name == NULL || name[0] == '\0') return;
if (c == NULL || c->cur == c->top) return;
int seen_use = 0;
for (Scope *s = c->cur; s; s = s->parent) {
Sym *r = scope_lookup_local(s, name);
if (r && (r->kind == SK_USE || r->use_alias)) {
seen_use = 1;
break;
}
}
if (!seen_use) return;
if (!src_imports(c->file, c->cur_mod, name)) return;
err(c, pos, "%s '%s' shadows imported module '%s'",
kindstr, name, name);
}
void
check_file(Checker *c, Node *file)
{
if (file == NULL || file->kind != N_FILE) return;
c->file = file;
/* pass 1: install names (types first, then defs/fns).
* For self-referential types we install the named-type placeholder
@@ -1819,8 +1908,12 @@ check_file(Checker *c, Node *file)
c->cur = newscope(c->a, saved);
Type *fnt = d->type;
for (Tparam *p = fnt->params; p; p = p->next) {
if (p->name && p->name[0])
scope_define(c->cur, p->name, SK_PARAM, p->type, d);
if (p->name && p->name[0]) {
check_module_shadow(c, p->name,
d->pos, "param");
scope_define(c->cur, p->name,
SK_PARAM, p->type, d);
}
}
Type *prev = c->ret;
c->ret = fnt->ret;

View File

@@ -524,6 +524,10 @@ struct Checker {
* preference in bare-leaf lookups so a bare
* `read` inside lib/os resolves to os.read
* rather than colliding io.read. */
Node *file; /* current N_FILE root; used by check_module_shadow
* to consult the declaring source file's own `use`
* directives when refusing param/let names that
* would shadow an imported module bareword. */
int loops; /* nesting count for break/continue */
int errs;
};