diff --git a/compiler/analysis/typechecker.c b/compiler/analysis/typechecker.c index 5bdcb4d63..c5ca0825c 100644 --- a/compiler/analysis/typechecker.c +++ b/compiler/analysis/typechecker.c @@ -14174,6 +14174,23 @@ int typecheck_statement(ASTNode* stmt, SymbolTable* table) { var->node_type = clone_type(rhs_type->tuple_types[j]); // Don't register _ (discard) in symbol table if (var->value && strcmp(var->value, "_") != 0) { + /* A destructure slot re-binds a visible name, or declares + * a new local. It must not recover an outer binding that + * this scope hid or sealed out. A local already visible + * below the directive remains a valid re-bind, and a + * genuinely new local remains valid in a sealed block. */ + Symbol* visible = lookup_symbol(table, var->value); + if (!visible && + (scope_name_is_hidden(table, var->value) || + name_blocked_by_hide(table, var->value))) { + char msg[256]; + snprintf(msg, sizeof(msg), + "'%s' is hidden in this scope by `hide` or `seal except`", + var->value); + type_error(msg, var->line, var->column); + slots_ok = 0; + continue; + } /* #2762. A refused slot is bound already; the others are * still registered, so their uses report nothing more. */ if (!destructure_slot_rebind(var, table, stmt->children[var_count])) { diff --git a/new_changelogs/20261029T123000Z-fixed-hide-seal-destructure.md b/new_changelogs/20261029T123000Z-fixed-hide-seal-destructure.md new file mode 100644 index 000000000..785fc3d0b --- /dev/null +++ b/new_changelogs/20261029T123000Z-fixed-hide-seal-destructure.md @@ -0,0 +1,3 @@ +- **Tuple destructuring now respects lexical containment.** A slot can no + longer re-bind an outer name excluded by `hide` or `seal except`; visible + locals and new locals inside a sealed block keep their existing behavior. diff --git a/tests/integration/hide_seal_directives/test_hide_reject.sh b/tests/integration/hide_seal_directives/test_hide_reject.sh index fb4a8c07b..db2c712a8 100755 --- a/tests/integration/hide_seal_directives/test_hide_reject.sh +++ b/tests/integration/hide_seal_directives/test_hide_reject.sh @@ -332,6 +332,44 @@ main() { EOF run_reject "hide blocks DSL receiver fallback across nested scopes" $AE_TMP/ae_hide_dsl_receiver_outer.ae +# Cases 20-22: tuple destructuring is a write to every named slot. It must +# not re-bind an outer name that the current scope hid or sealed out. +cat > $AE_TMP/ae_hide_destructure.ae << 'EOF' +pair() -> (string, int) { return "changed", 1 } +main() { + secret = "original" + { + hide secret + secret, _ = pair() + } +} +EOF +run_reject "hide blocks tuple-destructure write" $AE_TMP/ae_hide_destructure.ae + +cat > $AE_TMP/ae_seal_destructure.ae << 'EOF' +pair() -> (string, int) { return "changed", 1 } +main() { + secret = "original" + { + seal except pair + secret, _ = pair() + } +} +EOF +run_reject "seal except blocks tuple-destructure write" $AE_TMP/ae_seal_destructure.ae + +cat > $AE_TMP/ae_seal_local_destructure.ae << 'EOF' +pair() -> (string, int) { return "changed", 1 } +main() { + { + seal except pair + local = "original" + local, fresh = pair() + } +} +EOF +run_accept "seal except permits local tuple-destructure bindings" $AE_TMP/ae_seal_local_destructure.ae + # Cleanup rm -f $AE_TMP/ae_hide_*.ae $AE_TMP/ae_seal_*.ae $AE_TMP/ae_hide_out