cstage+selfhost+test: refuse same-block let / param redecl (#32)

cmd/wcc/check.c silently accepted `let a; let a;` in the same block
and similar redecls. Pre-#27 the localoff dedup masked it; post-#27
last-write-wins via head-first localfind. Surfaced by worker-27
during the #27 review.

Cstage: 5 guard sites (check_scope_define-NULL → err) covering
N_LET block-bind, N_MLET tuple binders (incl. same-tuple
`let (a,a)`), N_FORRANGE tuple binders, top-level let, fn param.
Voice: "<kind> '<name>' redeclared in same scope" for inner;
"duplicate let %s" for top-let, matching the existing
"duplicate <kind>" idiom at 1812/1851/1872.

Wwstage: TODO(#11) comments at the 4 mirror sites (installdecl,
N_FORRANGE, N_LET, installparams). Full enforcement waits on the
checkfile pass per rob.

**Unmasked by #32 (worth flagging):** selfhost/cmd/wcc/cgenexpr.ww
cgcall had `let callee: *node = n.lhs;` twice at fn-body scope
(copy-paste, identical value). Pre-fix silent-redecl absorbed it;
post-fix the new guard rejects. Removed the second decl — outer
`callee` stays visible across the intermediate block.

Test 712 (redecl): 10 rows (6 neg + 4 pos), cstage-only per rob.
Negative rows cover all 5 guard sites + same-tuple-dup. Positive
rows pin the legal counter-shapes (cross-block, name-only bucket,
forrange body, mcase-per-arm).

Test 300 row 34 ("shadowing in inner scope; same scope flagged")
was incorrectly asserting the bug; flipped to expect "redeclared"
and added a sibling row pinning cross-block shadow stays ok. Test
709's `same_block_redecl_pin` canary (explicitly documented as
flipping under #32) removed; pointer to 712 left in its place.
This commit is contained in:
2026-05-16 11:17:20 +09:00
parent ed94467a0c
commit b8b32ab80c
9 changed files with 432 additions and 34 deletions

View File

@@ -5304,6 +5304,14 @@ fn checkmoduleshadow(c: *checker, name: str, kindstr: str) void = {
// the wwstage cgen amalloc-undersize trap (rob-pike). #11 (wwstage
// checkfile pass) will reconsider this when wwstage grows a real check
// pass on the cgen path.
// TODO(#11): cstage check.c errors on duplicate top-level type/def/fn
// (see cmd/wcc/check.c L1800/L1839/L1860 "duplicate <kind>") and on
// duplicate top-level let (cmd/wcc/check.c L1880, "duplicate let %s")
// once #32 lands. Wwstage's installdecl just drops the second insert
// silently. Add `if (s == nil) err(...)` here once #11 wires checkfile
// into w6c_ww. Silent-accept matches the deferred-check design — see
// test/wcc/708 and test/wcc/696 for the same cstage-only neg-case
// precedent.
fn installdecl(c: *checker, file: *node, d: *node) void = {
if (d == nil) { return; };
let k: nkind = d.kind;
@@ -5398,6 +5406,14 @@ fn resolvewalk(c: *checker, n: *node) void = {
// each binding name becomes a fresh local. Walk the slice expr first
// so its idents resolve before the bindings shadow anything, then
// install bindings and walk the body/else.
//
// TODO(#11): cstage check.c (post-#32) errors `binding '%s'
// redeclared in same scope` when the tuple-pattern lists the same
// name twice (`for (let (a, a) .. xs)`). Wwstage's resolvewalk has
// no per-block scope (see resolvefnbody's docstring) and is used
// only by wwdump_ww as a diagnostic, so silent-accept here avoids
// false-positives on legal cross-block shadow until #11 adds the
// scoping infrastructure.
if (k == nkind.N_FORRANGE) {
if (n.lhs != nil) { resolvewalk(c, n.lhs); };
if (n.list != nil) {
@@ -5478,6 +5494,15 @@ fn resolvewalk(c: *checker, n: *node) void = {
// `X` so subsequent statements can resolve it. Top-level lets
// are installed in installdecl, so this duplicate install at
// the file scope just no-ops (scopedefine returns nil on dup).
//
// TODO(#11): cstage check.c (post-#32) errors `let '%s' redeclared
// in same scope` here. Wwstage resolvewalk has no per-block scope
// (see resolvefnbody's docstring) so a same-fn-body
// `let a=1; { let a=2; };` would falsely trip if we guarded
// scopedefine's nil return today. Silent-accept matches the
// deferred-check design until #11 adds per-block scoping; see
// test/wcc/708 and test/wcc/696 for the same cstage-only neg-case
// precedent.
if (k == nkind.N_LET) {
let nm: str = n.str;
if (nm.len > 0) {
@@ -6121,6 +6146,14 @@ fn checktryprop(c: *checker, n: *node) void = {
// install_param — when entering a fn body, define its params in a
// fresh local scope.
//
// TODO(#11): cstage check.c (post-#32) errors `param '%s' redeclared`
// when two params share a name. The fn body's scope IS fresh here
// (resolvefnbody opens it before calling us), so guarding scopedefine's
// nil return would be sound — but we defer until #11 wires checkfile
// into w6c_ww so the diagnostic class lands as a single coordinated
// step rather than dribbling in. Matches the cstage-only neg-case
// precedent at test/wcc/708 + test/wcc/696.
fn installparams(c: *checker, params: *node) void = {
let p: *node = params;
for (p != nil) {
@@ -12431,7 +12464,9 @@ fn cgcall(c: *cgen, n: *node) void = {
};
i += 1;
};
let callee: *node = n.lhs;
// `callee` is already in scope from line 2827; reuse it. Pre-#32
// silent-redecl masked the second `let callee` here as a no-op
// (same value, same fn-body scope post-#27).
let calleename: str;
calleename.ptr = nil; calleename.len = 0;
// Detect fn-pointer field call: `w.emit(args)` where `w` is

View File

@@ -3064,7 +3064,9 @@ fn cgcall(c: *cgen, n: *node) void = {
};
i += 1;
};
let callee: *node = n.lhs;
// `callee` is already in scope from line 2827; reuse it. Pre-#32
// silent-redecl masked the second `let callee` here as a no-op
// (same value, same fn-body scope post-#27).
let calleename: str;
calleename.ptr = nil; calleename.len = 0;
// Detect fn-pointer field call: `w.emit(args)` where `w` is

View File

@@ -179,6 +179,14 @@ fn checkmoduleshadow(c: *checker, name: str, kindstr: str) void = {
// the wwstage cgen amalloc-undersize trap (rob-pike). #11 (wwstage
// checkfile pass) will reconsider this when wwstage grows a real check
// pass on the cgen path.
// TODO(#11): cstage check.c errors on duplicate top-level type/def/fn
// (see cmd/wcc/check.c L1800/L1839/L1860 "duplicate <kind>") and on
// duplicate top-level let (cmd/wcc/check.c L1880, "duplicate let %s")
// once #32 lands. Wwstage's installdecl just drops the second insert
// silently. Add `if (s == nil) err(...)` here once #11 wires checkfile
// into w6c_ww. Silent-accept matches the deferred-check design — see
// test/wcc/708 and test/wcc/696 for the same cstage-only neg-case
// precedent.
fn installdecl(c: *checker, file: *node, d: *node) void = {
if (d == nil) { return; };
let k: nkind = d.kind;
@@ -273,6 +281,14 @@ fn resolvewalk(c: *checker, n: *node) void = {
// each binding name becomes a fresh local. Walk the slice expr first
// so its idents resolve before the bindings shadow anything, then
// install bindings and walk the body/else.
//
// TODO(#11): cstage check.c (post-#32) errors `binding '%s'
// redeclared in same scope` when the tuple-pattern lists the same
// name twice (`for (let (a, a) .. xs)`). Wwstage's resolvewalk has
// no per-block scope (see resolvefnbody's docstring) and is used
// only by wwdump_ww as a diagnostic, so silent-accept here avoids
// false-positives on legal cross-block shadow until #11 adds the
// scoping infrastructure.
if (k == nkind.N_FORRANGE) {
if (n.lhs != nil) { resolvewalk(c, n.lhs); };
if (n.list != nil) {
@@ -353,6 +369,15 @@ fn resolvewalk(c: *checker, n: *node) void = {
// `X` so subsequent statements can resolve it. Top-level lets
// are installed in installdecl, so this duplicate install at
// the file scope just no-ops (scopedefine returns nil on dup).
//
// TODO(#11): cstage check.c (post-#32) errors `let '%s' redeclared
// in same scope` here. Wwstage resolvewalk has no per-block scope
// (see resolvefnbody's docstring) so a same-fn-body
// `let a=1; { let a=2; };` would falsely trip if we guarded
// scopedefine's nil return today. Silent-accept matches the
// deferred-check design until #11 adds per-block scoping; see
// test/wcc/708 and test/wcc/696 for the same cstage-only neg-case
// precedent.
if (k == nkind.N_LET) {
let nm: str = n.str;
if (nm.len > 0) {
@@ -996,6 +1021,14 @@ fn checktryprop(c: *checker, n: *node) void = {
// install_param — when entering a fn body, define its params in a
// fresh local scope.
//
// TODO(#11): cstage check.c (post-#32) errors `param '%s' redeclared`
// when two params share a name. The fn body's scope IS fresh here
// (resolvefnbody opens it before calling us), so guarding scopedefine's
// nil return would be sound — but we defer until #11 wires checkfile
// into w6c_ww so the diagnostic class lands as a single coordinated
// step rather than dribbling in. Matches the cstage-only neg-case
// precedent at test/wcc/708 + test/wcc/696.
fn installparams(c: *checker, params: *node) void = {
let p: *node = params;
for (p != nil) {

View File

@@ -5304,6 +5304,14 @@ fn checkmoduleshadow(c: *checker, name: str, kindstr: str) void = {
// the wwstage cgen amalloc-undersize trap (rob-pike). #11 (wwstage
// checkfile pass) will reconsider this when wwstage grows a real check
// pass on the cgen path.
// TODO(#11): cstage check.c errors on duplicate top-level type/def/fn
// (see cmd/wcc/check.c L1800/L1839/L1860 "duplicate <kind>") and on
// duplicate top-level let (cmd/wcc/check.c L1880, "duplicate let %s")
// once #32 lands. Wwstage's installdecl just drops the second insert
// silently. Add `if (s == nil) err(...)` here once #11 wires checkfile
// into w6c_ww. Silent-accept matches the deferred-check design — see
// test/wcc/708 and test/wcc/696 for the same cstage-only neg-case
// precedent.
fn installdecl(c: *checker, file: *node, d: *node) void = {
if (d == nil) { return; };
let k: nkind = d.kind;
@@ -5398,6 +5406,14 @@ fn resolvewalk(c: *checker, n: *node) void = {
// each binding name becomes a fresh local. Walk the slice expr first
// so its idents resolve before the bindings shadow anything, then
// install bindings and walk the body/else.
//
// TODO(#11): cstage check.c (post-#32) errors `binding '%s'
// redeclared in same scope` when the tuple-pattern lists the same
// name twice (`for (let (a, a) .. xs)`). Wwstage's resolvewalk has
// no per-block scope (see resolvefnbody's docstring) and is used
// only by wwdump_ww as a diagnostic, so silent-accept here avoids
// false-positives on legal cross-block shadow until #11 adds the
// scoping infrastructure.
if (k == nkind.N_FORRANGE) {
if (n.lhs != nil) { resolvewalk(c, n.lhs); };
if (n.list != nil) {
@@ -5478,6 +5494,15 @@ fn resolvewalk(c: *checker, n: *node) void = {
// `X` so subsequent statements can resolve it. Top-level lets
// are installed in installdecl, so this duplicate install at
// the file scope just no-ops (scopedefine returns nil on dup).
//
// TODO(#11): cstage check.c (post-#32) errors `let '%s' redeclared
// in same scope` here. Wwstage resolvewalk has no per-block scope
// (see resolvefnbody's docstring) so a same-fn-body
// `let a=1; { let a=2; };` would falsely trip if we guarded
// scopedefine's nil return today. Silent-accept matches the
// deferred-check design until #11 adds per-block scoping; see
// test/wcc/708 and test/wcc/696 for the same cstage-only neg-case
// precedent.
if (k == nkind.N_LET) {
let nm: str = n.str;
if (nm.len > 0) {
@@ -6121,6 +6146,14 @@ fn checktryprop(c: *checker, n: *node) void = {
// install_param — when entering a fn body, define its params in a
// fresh local scope.
//
// TODO(#11): cstage check.c (post-#32) errors `param '%s' redeclared`
// when two params share a name. The fn body's scope IS fresh here
// (resolvefnbody opens it before calling us), so guarding scopedefine's
// nil return would be sound — but we defer until #11 wires checkfile
// into w6c_ww so the diagnostic class lands as a single coordinated
// step rather than dribbling in. Matches the cstage-only neg-case
// precedent at test/wcc/708 + test/wcc/696.
fn installparams(c: *checker, params: *node) void = {
let p: *node = params;
for (p != nil) {
@@ -12431,7 +12464,9 @@ fn cgcall(c: *cgen, n: *node) void = {
};
i += 1;
};
let callee: *node = n.lhs;
// `callee` is already in scope from line 2827; reuse it. Pre-#32
// silent-redecl masked the second `let callee` here as a no-op
// (same value, same fn-body scope post-#27).
let calleename: str;
calleename.ptr = nil; calleename.len = 0;
// Detect fn-pointer field call: `w.emit(args)` where `w` is