From 3a6a7f453d880d2b8175afeafcf95f3cf8fa1baa Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Thu, 28 May 2026 10:10:02 +0200 Subject: [PATCH 01/29] Implement WIP new initialization checker --- vyper/semantics/analysis/module.py | 70 ++++++++++++++++++++++++++++++ vyper/semantics/types/module.py | 4 +- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/vyper/semantics/analysis/module.py b/vyper/semantics/analysis/module.py index 8537658140..b1b33d75fd 100644 --- a/vyper/semantics/analysis/module.py +++ b/vyper/semantics/analysis/module.py @@ -180,6 +180,73 @@ def _validate_used_modules(module_ast: vy_ast.Module, module_t: ModuleT) -> None err_list.raise_if_not_empty() +# TODO: Make this use a dict of other_module_info to initializing_node, +# and use that info + module dependencies to check __init__ are called in the dependency order +def is_initialized(block: list[vy_ast.VyperNode], other_module_info: ModuleInfo) -> list[vy_ast.VyperNode]: + """ + Check a block for wether it calls other_module.__init__() + """ + + initializing_nodes = [] + """ + All inescapable call nodes to the constructor above this one. + Contains multiple in case of branching. + """ + + + def is_relevant_init_call(node: vy_ast.Call) -> bool: + """ + Is this node a call to other_module.__init__() ? + """ + expr_info = node.func._expr_info + + if expr_info is None: + # this can happen for range() calls; CMC 2024-02-05 try to + # refactor so that range() is properly tagged. + return False + + call_t = expr_info.typ + + if not isinstance(call_t, ContractFunctionT): + return False + + if not call_t.is_constructor: + return False + + # XXX: check this works as expected for nested attributes + initialized_module = node.func.value._expr_info.module_info # type: ignore + + return initialized_module == other_module_info + + for node in block: + if isinstance(node, vy_ast.Call) and is_relevant_init_call(node): + if initializing_nodes: + msg = f"tried to initialize `{other_module_info.alias}`, " + msg += "but its __init__() function was already called!" + raise InitializerException(msg, node.func, initializing_nodes) + + initializing_nodes += [node] + + if isinstance(node, vy_ast.If): + then_nodes = is_initialized(node.body, other_module_info) + else_nodes = is_initialized(node.orelse, other_module_info) if node.orelse is not None else [] + + if bool(then_nodes) != bool(else_nodes): + msg = f"`{other_module_info.alias}`.__init__() is not guaranteed to be reachable: " + msg += "present only in a single branch of an if" + raise InitializerException(msg, node.func, node) + + initializing_nodes += then_nodes + else_nodes + + if isinstance(node, vy_ast.For): + # call is_initialized for its side effects + if is_initialized(node.body, other_module_info): + msg = f"`{other_module_info.alias}`.__init__() is not guaranteed to be reachable: " + msg += "present in for loop" + raise InitializerException(msg, node.func, node) + + return initializing_nodes + def _validate_initialized_modules(module_ast: vy_ast.Module, module_t: ModuleT) -> None: """Check all `initializes:` modules have `__init__()` called exactly once.""" @@ -197,6 +264,8 @@ def _validate_initialized_modules(module_ast: vy_ast.Module, module_t: ModuleT) init_calls: list[vy_ast.Call] = [] if constructor is not None: init_calls = constructor.ast_def.get_descendants(vy_ast.Call) # type: ignore + for other_module_t in should_initialize: + is_initialized(constructor.ast_def.body, should_initialize[other_module_t].module_info) seen_initializers: dict[ModuleT, vy_ast.VyperNode] = {} for call_node in init_calls: @@ -218,6 +287,7 @@ def _validate_initialized_modules(module_ast: vy_ast.Module, module_t: ModuleT) initialized_module = call_node.func.value._expr_info.module_info # type: ignore if initialized_module.module_t in seen_initializers: + assert False seen_location = seen_initializers[initialized_module.module_t] msg = f"tried to initialize `{initialized_module.alias}`, " msg += "but its __init__() function was already called!" diff --git a/vyper/semantics/types/module.py b/vyper/semantics/types/module.py index 896dc22b58..ac04aa69b6 100644 --- a/vyper/semantics/types/module.py +++ b/vyper/semantics/types/module.py @@ -10,7 +10,7 @@ StructureException, UnfoldableNode, ) -from vyper.semantics.analysis.base import Modifiability +from vyper.semantics.analysis.base import InitializesInfo, Modifiability from vyper.semantics.analysis.utils import ( check_modifiability, validate_expected_type, @@ -509,7 +509,7 @@ def used_modules(self): return ret @property - def initialized_modules(self): + def initialized_modules(self) -> list[InitializesInfo]: # modules which are initialized to ret = [] for node in self.initializes_decls: From afefcb5c48b9bf4c30b7b77c9782b1f37e2dc7df Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Thu, 28 May 2026 14:54:25 +0200 Subject: [PATCH 02/29] Fix bugs --- .../syntax/modules/test_initializers.py | 54 ++++++++++++++ vyper/semantics/analysis/module.py | 72 +++++++++++-------- 2 files changed, 96 insertions(+), 30 deletions(-) diff --git a/tests/functional/syntax/modules/test_initializers.py b/tests/functional/syntax/modules/test_initializers.py index 69d24d18a6..c3d6d1b5f1 100644 --- a/tests/functional/syntax/modules/test_initializers.py +++ b/tests/functional/syntax/modules/test_initializers.py @@ -1811,3 +1811,57 @@ def foo() -> uint256: data = CompilerData(main, input_bundle=input_bundle) foo_t = data.function_signatures["foo"] assert len(foo_t._variable_reads) == 0 + + +def test_init_in_both_if_branches(make_input_bundle): + other = """ +counter: uint256 + +@deploy +def __init__(): + pass + """ + main = """ +import other + +initializes: other + +@deploy +def __init__(): + if True: + other.__init__() + else: + other.__init__() + """ + input_bundle = make_input_bundle({"other.vy": other}) + assert compile_code(main, input_bundle=input_bundle) is not None + + +def test_double_init_if_else(make_input_bundle): + other = """ +counter: uint256 + +@deploy +def __init__(): + pass + """ + main = """ +import other + +initializes: other + +@deploy +def __init__(): + other.__init__() + if True: + other.__init__() + else: + other.__init__() + """ + input_bundle = make_input_bundle({"other.vy": other}) + with pytest.raises(InitializerException) as e: + compile_code(main, input_bundle=input_bundle) + assert ( + e.value.message + == "tried to initialize `other`, but its __init__() function was already called!" + ) diff --git a/vyper/semantics/analysis/module.py b/vyper/semantics/analysis/module.py index b1b33d75fd..3bd07844d2 100644 --- a/vyper/semantics/analysis/module.py +++ b/vyper/semantics/analysis/module.py @@ -180,19 +180,24 @@ def _validate_used_modules(module_ast: vy_ast.Module, module_t: ModuleT) -> None err_list.raise_if_not_empty() + # TODO: Make this use a dict of other_module_info to initializing_node, # and use that info + module dependencies to check __init__ are called in the dependency order -def is_initialized(block: list[vy_ast.VyperNode], other_module_info: ModuleInfo) -> list[vy_ast.VyperNode]: +def is_initialized( + block: list[vy_ast.VyperNode], + other_module_info: ModuleInfo, + initializing_nodes: list[vy_ast.VyperNode] | None = None, +) -> list[vy_ast.VyperNode]: """ Check a block for wether it calls other_module.__init__() - """ - initializing_nodes = [] + Args: + TODO: + initializing_nodes: All inescapable call nodes to the constructor + above this one. Contains multiple in case of branching. """ - All inescapable call nodes to the constructor above this one. - Contains multiple in case of branching. - """ - + + initializing_nodes = initializing_nodes.copy() if initializing_nodes is not None else [] def is_relevant_init_call(node: vy_ast.Call) -> bool: """ @@ -219,31 +224,41 @@ def is_relevant_init_call(node: vy_ast.Call) -> bool: return initialized_module == other_module_info for node in block: - if isinstance(node, vy_ast.Call) and is_relevant_init_call(node): + # TODO: This assumes a specific AST shape for init calls, + # but it seems to be correct in practice + if ( + isinstance(node, vy_ast.Expr) + and isinstance(node.value, vy_ast.Call) + and is_relevant_init_call(node.value) + ): if initializing_nodes: msg = f"tried to initialize `{other_module_info.alias}`, " msg += "but its __init__() function was already called!" - raise InitializerException(msg, node.func, initializing_nodes) - - initializing_nodes += [node] + raise InitializerException(msg, node.value.func, initializing_nodes) - if isinstance(node, vy_ast.If): - then_nodes = is_initialized(node.body, other_module_info) - else_nodes = is_initialized(node.orelse, other_module_info) if node.orelse is not None else [] + initializing_nodes += [node.value] + + elif isinstance(node, vy_ast.If): + then_nodes = is_initialized(node.body, other_module_info, initializing_nodes) + else_nodes = ( + is_initialized(node.orelse, other_module_info, initializing_nodes) + if node.orelse is not None + else [] + ) if bool(then_nodes) != bool(else_nodes): msg = f"`{other_module_info.alias}`.__init__() is not guaranteed to be reachable: " msg += "present only in a single branch of an if" - raise InitializerException(msg, node.func, node) + raise InitializerException(msg, node) initializing_nodes += then_nodes + else_nodes - if isinstance(node, vy_ast.For): + elif isinstance(node, vy_ast.For): # call is_initialized for its side effects - if is_initialized(node.body, other_module_info): + if is_initialized(node.body, other_module_info, initializing_nodes): msg = f"`{other_module_info.alias}`.__init__() is not guaranteed to be reachable: " msg += "present in for loop" - raise InitializerException(msg, node.func, node) + raise InitializerException(msg, node) return initializing_nodes @@ -265,6 +280,7 @@ def _validate_initialized_modules(module_ast: vy_ast.Module, module_t: ModuleT) if constructor is not None: init_calls = constructor.ast_def.get_descendants(vy_ast.Call) # type: ignore for other_module_t in should_initialize: + assert isinstance(constructor.ast_def, vy_ast.FunctionDef) # help mypy is_initialized(constructor.ast_def.body, should_initialize[other_module_t].module_info) seen_initializers: dict[ModuleT, vy_ast.VyperNode] = {} @@ -286,19 +302,15 @@ def _validate_initialized_modules(module_ast: vy_ast.Module, module_t: ModuleT) # XXX: check this works as expected for nested attributes initialized_module = call_node.func.value._expr_info.module_info # type: ignore - if initialized_module.module_t in seen_initializers: - assert False - seen_location = seen_initializers[initialized_module.module_t] - msg = f"tried to initialize `{initialized_module.alias}`, " - msg += "but its __init__() function was already called!" - raise InitializerException(msg, call_node.func, seen_location) - if initialized_module.module_t not in should_initialize: - msg = f"tried to initialize `{initialized_module.alias}`, " - msg += "but it is not in initializer list!" - hint = f"add `initializes: {initialized_module.alias}` " - hint += "as a top-level statement to your contract" - raise InitializerException(msg, call_node.func, hint=hint) + if initialized_module.module_t not in seen_initializers: + msg = f"tried to initialize `{initialized_module.alias}`, " + msg += "but it is not in initializer list!" + hint = f"add `initializes: {initialized_module.alias}` " + hint += "as a top-level statement to your contract" + raise InitializerException(msg, call_node.func, hint=hint) + # already seen (e.g. from another branch of an if/else) + continue del should_initialize[initialized_module.module_t] seen_initializers[initialized_module.module_t] = call_node.func From 956571fbcf1f9ef5511322d95b99e862e9c51582 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Tue, 2 Jun 2026 13:44:03 +0200 Subject: [PATCH 03/29] Add handling for multiple modules at once --- .../syntax/modules/test_initializers.py | 23 +++ vyper/semantics/analysis/base.py | 5 + vyper/semantics/analysis/module.py | 142 +++++++----------- 3 files changed, 86 insertions(+), 84 deletions(-) diff --git a/tests/functional/syntax/modules/test_initializers.py b/tests/functional/syntax/modules/test_initializers.py index c3d6d1b5f1..7b2a5da995 100644 --- a/tests/functional/syntax/modules/test_initializers.py +++ b/tests/functional/syntax/modules/test_initializers.py @@ -1865,3 +1865,26 @@ def __init__(): e.value.message == "tried to initialize `other`, but its __init__() function was already called!" ) + + +def test_init_followed_by_for_loop(make_input_bundle): + other = """ +counter: uint256 + +@deploy +def __init__(): + pass + """ + main = """ +import other + +initializes: other + +@deploy +def __init__(): + other.__init__() + for i: uint256 in range(10): + pass + """ + input_bundle = make_input_bundle({"other.vy": other}) + assert compile_code(main, input_bundle=input_bundle) is not None diff --git a/vyper/semantics/analysis/base.py b/vyper/semantics/analysis/base.py index 80c9731853..19ff9a964e 100644 --- a/vyper/semantics/analysis/base.py +++ b/vyper/semantics/analysis/base.py @@ -119,6 +119,11 @@ def set_ownership(self, module_ownership: ModuleOwnership, node: Optional[vy_ast def __hash__(self): return hash(id(self.module_t)) + def __eq__(self, other): + if not isinstance(other, ModuleInfo): + return NotImplemented + return self.module_t is other.module_t + @dataclass class ImportInfo(AnalysisResult): diff --git a/vyper/semantics/analysis/module.py b/vyper/semantics/analysis/module.py index 3bd07844d2..1284250723 100644 --- a/vyper/semantics/analysis/module.py +++ b/vyper/semantics/analysis/module.py @@ -181,14 +181,14 @@ def _validate_used_modules(module_ast: vy_ast.Module, module_t: ModuleT) -> None err_list.raise_if_not_empty() -# TODO: Make this use a dict of other_module_info to initializing_node, -# and use that info + module dependencies to check __init__ are called in the dependency order +# TODO: use module dependencies to check __init__ are called in the dependency order +# TODO: add handling of one side of an if-then-else reverting def is_initialized( block: list[vy_ast.VyperNode], - other_module_info: ModuleInfo, - initializing_nodes: list[vy_ast.VyperNode] | None = None, -) -> list[vy_ast.VyperNode]: + initializing_nodes: dict[ModuleInfo, list[vy_ast.VyperNode]], +) -> dict[ModuleInfo, list[vy_ast.VyperNode]]: """ + TODO: Outdated Check a block for wether it calls other_module.__init__() Args: @@ -197,68 +197,79 @@ def is_initialized( above this one. Contains multiple in case of branching. """ - initializing_nodes = initializing_nodes.copy() if initializing_nodes is not None else [] + initializing_nodes = {k: v.copy() for k, v in initializing_nodes.items()} + + def extract_init_call(node: vy_ast.Call) -> ModuleInfo | None: - def is_relevant_init_call(node: vy_ast.Call) -> bool: - """ - Is this node a call to other_module.__init__() ? - """ expr_info = node.func._expr_info if expr_info is None: # this can happen for range() calls; CMC 2024-02-05 try to # refactor so that range() is properly tagged. - return False + return None call_t = expr_info.typ if not isinstance(call_t, ContractFunctionT): - return False + return None if not call_t.is_constructor: - return False + return None # XXX: check this works as expected for nested attributes - initialized_module = node.func.value._expr_info.module_info # type: ignore - - return initialized_module == other_module_info + return node.func.value._expr_info.module_info # type: ignore for node in block: + # TODO: This assumes a specific AST shape for init calls, # but it seems to be correct in practice if ( - isinstance(node, vy_ast.Expr) - and isinstance(node.value, vy_ast.Call) - and is_relevant_init_call(node.value) + isinstance(node, vy_ast.Expr) and isinstance(node.value, vy_ast.Call) ): - if initializing_nodes: + other_module_info = extract_init_call(node.value) + + if other_module_info is None: + continue + + if other_module_info not in initializing_nodes: + msg = f"tried to initialize `{other_module_info.alias}`, " + msg += "but it is not in initializer list!" + hint = f"add `initializes: {other_module_info.alias}` " + hint += "as a top-level statement to your contract" + raise InitializerException(msg, node.value.func, hint=hint) + + init_calls = initializing_nodes[other_module_info] + + if len(init_calls) != 0: msg = f"tried to initialize `{other_module_info.alias}`, " msg += "but its __init__() function was already called!" - raise InitializerException(msg, node.value.func, initializing_nodes) + raise InitializerException(msg, node.value.func, init_calls) - initializing_nodes += [node.value] + init_calls += [node.value] elif isinstance(node, vy_ast.If): - then_nodes = is_initialized(node.body, other_module_info, initializing_nodes) + then_nodes = is_initialized(node.body, initializing_nodes) else_nodes = ( - is_initialized(node.orelse, other_module_info, initializing_nodes) + is_initialized(node.orelse, initializing_nodes) if node.orelse is not None - else [] + else {k: [] for k in initializing_nodes} ) - - if bool(then_nodes) != bool(else_nodes): - msg = f"`{other_module_info.alias}`.__init__() is not guaranteed to be reachable: " - msg += "present only in a single branch of an if" - raise InitializerException(msg, node) - - initializing_nodes += then_nodes + else_nodes + for module_info in initializing_nodes: + if bool(then_nodes[module_info]) != bool(else_nodes[module_info]): + msg = f"`{module_info.alias}`.__init__() is not guaranteed to be reachable: " + msg += "present only in a single branch of an if" + raise InitializerException(msg, node) + else: + initializing_nodes[module_info] += then_nodes[module_info] + else_nodes[module_info] elif isinstance(node, vy_ast.For): - # call is_initialized for its side effects - if is_initialized(node.body, other_module_info, initializing_nodes): - msg = f"`{other_module_info.alias}`.__init__() is not guaranteed to be reachable: " - msg += "present in for loop" - raise InitializerException(msg, node) + # TODO: This forbids any calls to __init__() in a for loop, implement it more directly + loop_nodes = is_initialized(node.body, initializing_nodes) + for module_info in initializing_nodes: + if bool(initializing_nodes[module_info]) != bool(loop_nodes[module_info]): + msg = f"`{module_info.alias}`.__init__() is not guaranteed to be reachable: " + msg += "present in for loop" + raise InitializerException(msg, node) return initializing_nodes @@ -267,59 +278,22 @@ def _validate_initialized_modules(module_ast: vy_ast.Module, module_t: ModuleT) """Check all `initializes:` modules have `__init__()` called exactly once.""" # only call `__init__()` for modules which have an # `__init__()` function - should_initialize = { - t.module_info.module_t: t - for t in module_t.initialized_modules + initializing_nodes: dict[ModuleInfo, list[vy_ast.VyperNode]] = { + t.module_info: [] for t in module_t.initialized_modules if t.module_info.module_t.init_function is not None } constructor = module_t.init_function - # Methods called by the constructor - init_calls: list[vy_ast.Call] = [] if constructor is not None: - init_calls = constructor.ast_def.get_descendants(vy_ast.Call) # type: ignore - for other_module_t in should_initialize: - assert isinstance(constructor.ast_def, vy_ast.FunctionDef) # help mypy - is_initialized(constructor.ast_def.body, should_initialize[other_module_t].module_info) - - seen_initializers: dict[ModuleT, vy_ast.VyperNode] = {} - for call_node in init_calls: - expr_info = call_node.func._expr_info - if expr_info is None: - # this can happen for range() calls; CMC 2024-02-05 try to - # refactor so that range() is properly tagged. - continue - - call_t = expr_info.typ - - if not isinstance(call_t, ContractFunctionT): - continue - - if not call_t.is_constructor: - continue - - # XXX: check this works as expected for nested attributes - initialized_module = call_node.func.value._expr_info.module_info # type: ignore - - if initialized_module.module_t not in should_initialize: - if initialized_module.module_t not in seen_initializers: - msg = f"tried to initialize `{initialized_module.alias}`, " - msg += "but it is not in initializer list!" - hint = f"add `initializes: {initialized_module.alias}` " - hint += "as a top-level statement to your contract" - raise InitializerException(msg, call_node.func, hint=hint) - # already seen (e.g. from another branch of an if/else) - continue - - del should_initialize[initialized_module.module_t] - seen_initializers[initialized_module.module_t] = call_node.func + assert isinstance(constructor.ast_def, vy_ast.FunctionDef) # help mypy + initializing_nodes = is_initialized(constructor.ast_def.body, initializing_nodes) - if len(should_initialize) > 0: - err_list = ExceptionList() - for s in should_initialize.values(): + err_list = ExceptionList() + for module_info, init_calls in initializing_nodes.items(): + if len(init_calls) == 0: msg = "not initialized!" - hint = f"add `{s.module_info.alias}.__init__()` to " + hint = f"add `{module_info.alias}.__init__()` to " hint += "your `__init__()` function" # grab the init function AST node for error message @@ -327,9 +301,9 @@ def _validate_initialized_modules(module_ast: vy_ast.Module, module_t: ModuleT) init_func_node = None if constructor is not None: init_func_node = constructor.decl_node - err_list.append(InitializerException(msg, init_func_node, s.node, hint=hint)) + err_list.append(InitializerException(msg, init_func_node, module_info.ownership_decl, hint=hint)) - err_list.raise_if_not_empty() + err_list.raise_if_not_empty() def _validate_exports_uses(module_ast: vy_ast.Module, module_t: ModuleT) -> None: From 692afe841cebc35993c89ecf8a1f18e14dfff1eb Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Wed, 3 Jun 2026 14:33:14 +0200 Subject: [PATCH 04/29] Sanity check --- vyper/semantics/analysis/module.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/vyper/semantics/analysis/module.py b/vyper/semantics/analysis/module.py index 1284250723..858eb7f953 100644 --- a/vyper/semantics/analysis/module.py +++ b/vyper/semantics/analysis/module.py @@ -229,6 +229,7 @@ def extract_init_call(node: vy_ast.Call) -> ModuleInfo | None: other_module_info = extract_init_call(node.value) if other_module_info is None: + # Not an init call, nothing to do continue if other_module_info not in initializing_nodes: @@ -260,6 +261,9 @@ def extract_init_call(node: vy_ast.Call) -> ModuleInfo | None: msg += "present only in a single branch of an if" raise InitializerException(msg, node) else: + # If the context and the branches had init calls, + # then we would already have errored: "__init__() function was already called!" + assert initializing_nodes[module_info] == [] or (then_nodes[module_info] == [] and else_nodes[module_info] == []) initializing_nodes[module_info] += then_nodes[module_info] + else_nodes[module_info] elif isinstance(node, vy_ast.For): From 8832525349f07f73497521b765d0271ca3bea33a Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Wed, 3 Jun 2026 14:33:19 +0200 Subject: [PATCH 05/29] Make lint --- vyper/semantics/analysis/module.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/vyper/semantics/analysis/module.py b/vyper/semantics/analysis/module.py index 858eb7f953..4c995bb0f2 100644 --- a/vyper/semantics/analysis/module.py +++ b/vyper/semantics/analysis/module.py @@ -184,8 +184,7 @@ def _validate_used_modules(module_ast: vy_ast.Module, module_t: ModuleT) -> None # TODO: use module dependencies to check __init__ are called in the dependency order # TODO: add handling of one side of an if-then-else reverting def is_initialized( - block: list[vy_ast.VyperNode], - initializing_nodes: dict[ModuleInfo, list[vy_ast.VyperNode]], + block: list[vy_ast.VyperNode], initializing_nodes: dict[ModuleInfo, list[vy_ast.VyperNode]] ) -> dict[ModuleInfo, list[vy_ast.VyperNode]]: """ TODO: Outdated @@ -223,9 +222,7 @@ def extract_init_call(node: vy_ast.Call) -> ModuleInfo | None: # TODO: This assumes a specific AST shape for init calls, # but it seems to be correct in practice - if ( - isinstance(node, vy_ast.Expr) and isinstance(node.value, vy_ast.Call) - ): + if isinstance(node, vy_ast.Expr) and isinstance(node.value, vy_ast.Call): other_module_info = extract_init_call(node.value) if other_module_info is None: @@ -261,10 +258,14 @@ def extract_init_call(node: vy_ast.Call) -> ModuleInfo | None: msg += "present only in a single branch of an if" raise InitializerException(msg, node) else: - # If the context and the branches had init calls, + # If the context and the branches had init calls, # then we would already have errored: "__init__() function was already called!" - assert initializing_nodes[module_info] == [] or (then_nodes[module_info] == [] and else_nodes[module_info] == []) - initializing_nodes[module_info] += then_nodes[module_info] + else_nodes[module_info] + assert initializing_nodes[module_info] == [] or ( + then_nodes[module_info] == [] and else_nodes[module_info] == [] + ) + initializing_nodes[module_info] += ( + then_nodes[module_info] + else_nodes[module_info] + ) elif isinstance(node, vy_ast.For): # TODO: This forbids any calls to __init__() in a for loop, implement it more directly @@ -283,7 +284,8 @@ def _validate_initialized_modules(module_ast: vy_ast.Module, module_t: ModuleT) # only call `__init__()` for modules which have an # `__init__()` function initializing_nodes: dict[ModuleInfo, list[vy_ast.VyperNode]] = { - t.module_info: [] for t in module_t.initialized_modules + t.module_info: [] + for t in module_t.initialized_modules if t.module_info.module_t.init_function is not None } @@ -305,7 +307,9 @@ def _validate_initialized_modules(module_ast: vy_ast.Module, module_t: ModuleT) init_func_node = None if constructor is not None: init_func_node = constructor.decl_node - err_list.append(InitializerException(msg, init_func_node, module_info.ownership_decl, hint=hint)) + err_list.append( + InitializerException(msg, init_func_node, module_info.ownership_decl, hint=hint) + ) err_list.raise_if_not_empty() From 640085e95cce8a953c27c067913b9d9005d02888 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Wed, 3 Jun 2026 14:46:10 +0200 Subject: [PATCH 06/29] Add TODOs --- vyper/semantics/analysis/base.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/vyper/semantics/analysis/base.py b/vyper/semantics/analysis/base.py index 19ff9a964e..9698725932 100644 --- a/vyper/semantics/analysis/base.py +++ b/vyper/semantics/analysis/base.py @@ -150,6 +150,8 @@ def to_dict(self): return ret +# TODO: Remove, the dependencies field is never used, +# and the node field is redundant with ModuleInfo.ownership_decl # analysis result of InitializesDecl @dataclass class InitializesInfo(AnalysisResult): @@ -158,6 +160,7 @@ class InitializesInfo(AnalysisResult): node: Optional[vy_ast.VyperNode] = None +# TODO: Remove, the node field is redundant with ModuleInfo.ownership_decl # analysis result of UsesDecl @dataclass class UsesInfo(AnalysisResult): From 4de08b9dd4eddb17f32655714c3af7e96aaf9913 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Wed, 3 Jun 2026 16:03:53 +0200 Subject: [PATCH 07/29] Add inter-module dependency check --- .../syntax/modules/test_initializers.py | 294 +++++++++++++++++- vyper/semantics/analysis/module.py | 16 + vyper/semantics/types/module.py | 2 +- 3 files changed, 310 insertions(+), 2 deletions(-) diff --git a/tests/functional/syntax/modules/test_initializers.py b/tests/functional/syntax/modules/test_initializers.py index 7b2a5da995..27ec3e3970 100644 --- a/tests/functional/syntax/modules/test_initializers.py +++ b/tests/functional/syntax/modules/test_initializers.py @@ -200,10 +200,10 @@ def foo(): @deploy def __init__(): - lib2.__init__() # demonstrate we can call lib1.__init__ through lib2.lib1 # (not sure this should be allowed, really. lib2.lib1.__init__() + lib2.__init__() """ input_bundle = make_input_bundle({"lib1.vy": lib1, "lib2.vy": lib2}) @@ -1888,3 +1888,295 @@ def __init__(): """ input_bundle = make_input_bundle({"other.vy": other}) assert compile_code(main, input_bundle=input_bundle) is not None + + +# --- Dependency-ordering checks (`is_initialized` enforces that +# a module's `uses:` dependencies are initialized before it) --- + + +# Shared module sources for the dependency-ordering tests below. +# Each `uses:` declaration must be backed by real state access (otherwise +# BorrowException fires before `is_initialized` ever runs). +_LIB1 = """ +counter: uint256 + +@deploy +def __init__(): + pass +""" + +_LIB2_USES_LIB1 = """ +import lib1 + +uses: lib1 + +counter: uint256 + +@deploy +def __init__(): + pass + +@internal +def touch(): + lib1.counter += 1 +""" + +_LIB1_NO_INIT = """ +counter: uint256 +""" + +_LIB2_NO_DEPS = """ +counter: uint256 + +@deploy +def __init__(): + pass +""" + +_LIB3_USES_BOTH = """ +import lib1 +import lib2 + +uses: (lib1, lib2) + +@deploy +def __init__(): + pass + +@internal +def touch(): + lib1.counter += 1 + lib2.counter += 1 +""" + + +def test_init_before_dependency(make_input_bundle): + main = """ +import lib1 +import lib2 + +initializes: lib2[lib1 := lib1] +initializes: lib1 + +@deploy +def __init__(): + lib2.__init__() + lib1.__init__() + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1, "lib2.vy": _LIB2_USES_LIB1}) + with pytest.raises(InitializerException) as e: + compile_code(main, input_bundle=input_bundle) + msg = ( + "tried to initialize `lib2`, but it depends on modules whose " + "__init__() functions were not called: lib1" + ) + assert e.value._message == msg + + +def test_init_before_multiple_dependencies(make_input_bundle): + main = """ +import lib1 +import lib2 +import lib3 + +initializes: lib1 +initializes: lib2 +initializes: lib3[lib1 := lib1, lib2 := lib2] + +@deploy +def __init__(): + lib3.__init__() + lib1.__init__() + lib2.__init__() + """ + input_bundle = make_input_bundle( + {"lib1.vy": _LIB1, "lib2.vy": _LIB2_NO_DEPS, "lib3.vy": _LIB3_USES_BOTH} + ) + with pytest.raises(InitializerException) as e: + compile_code(main, input_bundle=input_bundle) + msg = ( + "tried to initialize `lib3`, but it depends on modules whose " + "__init__() functions were not called: lib1, lib2" + ) + assert e.value._message == msg + + +def test_init_before_some_dependencies(make_input_bundle): + main = """ +import lib1 +import lib2 +import lib3 + +initializes: lib1 +initializes: lib2 +initializes: lib3[lib1 := lib1, lib2 := lib2] + +@deploy +def __init__(): + lib1.__init__() + lib3.__init__() + lib2.__init__() + """ + input_bundle = make_input_bundle( + {"lib1.vy": _LIB1, "lib2.vy": _LIB2_NO_DEPS, "lib3.vy": _LIB3_USES_BOTH} + ) + with pytest.raises(InitializerException) as e: + compile_code(main, input_bundle=input_bundle) + msg = ( + "tried to initialize `lib3`, but it depends on modules whose " + "__init__() functions were not called: lib2" + ) + assert e.value._message == msg + + +def test_dep_init_in_only_one_if_branch_then_parent(make_input_bundle): + # The existing "not guaranteed to be reachable" check fires before + # the new ordering check is reached: lib1 is only initialized in + # one branch of the `if`, which is detected when the branches merge. + main = """ +import lib1 +import lib2 + +initializes: lib2[lib1 := lib1] +initializes: lib1 + +@deploy +def __init__(use: bool): + if use: + lib1.__init__() + lib2.__init__() + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1, "lib2.vy": _LIB2_USES_LIB1}) + with pytest.raises(InitializerException) as e: + compile_code(main, input_bundle=input_bundle) + assert "not guaranteed to be reachable" in e.value._message + + +def test_init_attribute_path_before_parent(make_input_bundle): + # `lib2.lib1.__init__()` reaches lib1 through lib2's namespace; the + # ordering rule must still treat it as a lib1 init and reject the + # reverse order. + main = """ +import lib1 +import lib2 + +initializes: lib2[lib1 := lib1] +initializes: lib1 + +@deploy +def __init__(): + lib2.__init__() + lib2.lib1.__init__() + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1, "lib2.vy": _LIB2_USES_LIB1}) + with pytest.raises(InitializerException) as e: + compile_code(main, input_bundle=input_bundle) + msg = ( + "tried to initialize `lib2`, but it depends on modules whose " + "__init__() functions were not called: lib1" + ) + assert e.value._message == msg + + +def test_init_after_dependency(make_input_bundle): + main = """ +import lib1 +import lib2 + +initializes: lib2[lib1 := lib1] +initializes: lib1 + +@deploy +def __init__(): + lib1.__init__() + lib2.__init__() + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1, "lib2.vy": _LIB2_USES_LIB1}) + assert compile_code(main, input_bundle=input_bundle) is not None + + +def test_init_after_dependency_in_both_branches(make_input_bundle): + main = """ +import lib1 +import lib2 + +initializes: lib2[lib1 := lib1] +initializes: lib1 + +@deploy +def __init__(use: bool): + if use: + lib1.__init__() + lib2.__init__() + else: + lib1.__init__() + lib2.__init__() + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1, "lib2.vy": _LIB2_USES_LIB1}) + assert compile_code(main, input_bundle=input_bundle) is not None + + +def test_init_chain_of_dependencies(make_input_bundle): + main = """ +import lib1 +import lib2 +import lib3 + +initializes: lib1 +initializes: lib2[lib1 := lib1] +initializes: lib3[lib1 := lib1, lib2 := lib2] + +@deploy +def __init__(): + lib1.__init__() + lib2.__init__() + lib3.__init__() + """ + input_bundle = make_input_bundle( + {"lib1.vy": _LIB1, "lib2.vy": _LIB2_USES_LIB1, "lib3.vy": _LIB3_USES_BOTH} + ) + assert compile_code(main, input_bundle=input_bundle) is not None + + +def test_init_with_dependency_without_init_function(make_input_bundle): + # lib1 has no __init__(); it is filtered out of `initializing_nodes`. + # The dependency loop in `is_initialized` must skip it (otherwise the + # dict lookup would KeyError when processing `lib2.__init__()`). + main = """ +import lib1 +import lib2 + +initializes: lib2[lib1 := lib1] +initializes: lib1 + +@deploy +def __init__(): + lib2.__init__() + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1_NO_INIT, "lib2.vy": _LIB2_USES_LIB1}) + assert compile_code(main, input_bundle=input_bundle) is not None + + +def test_init_transitive_dependency_unchecked(make_input_bundle): + # The dependency-ordering check is one level deep: when initializing + # lib3 (direct deps: lib1, lib2), only those direct deps' init must + # have run. Correct order compiles cleanly. + main = """ +import lib1 +import lib2 +import lib3 + +initializes: lib1 +initializes: lib2[lib1 := lib1] +initializes: lib3[lib1 := lib1, lib2 := lib2] + +@deploy +def __init__(): + lib1.__init__() + lib2.__init__() + lib3.__init__() + """ + input_bundle = make_input_bundle( + {"lib1.vy": _LIB1, "lib2.vy": _LIB2_USES_LIB1, "lib3.vy": _LIB3_USES_BOTH} + ) + assert compile_code(main, input_bundle=input_bundle) is not None diff --git a/vyper/semantics/analysis/module.py b/vyper/semantics/analysis/module.py index 4c995bb0f2..a9b56a2049 100644 --- a/vyper/semantics/analysis/module.py +++ b/vyper/semantics/analysis/module.py @@ -243,6 +243,22 @@ def extract_init_call(node: vy_ast.Call) -> ModuleInfo | None: msg += "but its __init__() function was already called!" raise InitializerException(msg, node.value.func, init_calls) + uninitialized_dependents: list[str] = [] + """ + Modules which the other module initializes, but whose init are not called beforehand + """ + + for dependent in other_module_info.module_t.used_modules: + dependent_init_calls = initializing_nodes.get(dependent) + if dependent_init_calls is not None and len(dependent_init_calls) == 0: + uninitialized_dependents.append(dependent.alias) + + if len(uninitialized_dependents) != 0: + msg = f"tried to initialize `{other_module_info.alias}`, " + msg += "but it depends on modules whose __init__() functions were not called: " + msg += ", ".join(uninitialized_dependents) + raise InitializerException(msg, node.value.func, init_calls) + init_calls += [node.value] elif isinstance(node, vy_ast.If): diff --git a/vyper/semantics/types/module.py b/vyper/semantics/types/module.py index ac04aa69b6..801279a498 100644 --- a/vyper/semantics/types/module.py +++ b/vyper/semantics/types/module.py @@ -500,7 +500,7 @@ def exports_decls(self): return self._module.get_children(vy_ast.ExportsDecl) @cached_property - def used_modules(self): + def used_modules(self) -> list["ModuleInfo"]: # modules which are written to ret = [] for node in self.uses_decls: From 868a17de0d4f52575b500fd92131edb07561de94 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Thu, 4 Jun 2026 14:13:16 +0200 Subject: [PATCH 08/29] Add tests (xfail need to be fixed) --- .../syntax/modules/test_initializers.py | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/tests/functional/syntax/modules/test_initializers.py b/tests/functional/syntax/modules/test_initializers.py index 27ec3e3970..3e048dd862 100644 --- a/tests/functional/syntax/modules/test_initializers.py +++ b/tests/functional/syntax/modules/test_initializers.py @@ -2180,3 +2180,121 @@ def __init__(): {"lib1.vy": _LIB1, "lib2.vy": _LIB2_USES_LIB1, "lib3.vy": _LIB3_USES_BOTH} ) assert compile_code(main, input_bundle=input_bundle) is not None + + +@pytest.mark.xfail(raises=AssertionError) +def test_init_then_if_else_no_inner_init(make_input_bundle): + main = """ +import lib1 + +initializes: lib1 + +@deploy +def __init__(): + lib1.__init__() + if True: + x: uint256 = 1 + else: + x: uint256 = 2 + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1}) + assert compile_code(main, input_bundle=input_bundle) is not None + + +@pytest.mark.xfail(raises=AssertionError) +def test_init_then_if_no_else(make_input_bundle): + main = """ +import lib1 + +initializes: lib1 + +@deploy +def __init__(): + lib1.__init__() + if True: + x: uint256 = 1 + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1}) + assert compile_code(main, input_bundle=input_bundle) is not None + + +def test_init_inside_for_loop(make_input_bundle): + main = """ +import lib1 + +initializes: lib1 + +@deploy +def __init__(): + for i: uint256 in range(10): + lib1.__init__() + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1}) + with pytest.raises(InitializerException) as e: + compile_code(main, input_bundle=input_bundle) + assert "not guaranteed to be reachable" in e.value._message + assert "present in for loop" in e.value._message + + +def test_duplicate_init_in_single_branch(make_input_bundle): + main = """ +import lib1 + +initializes: lib1 + +@deploy +def __init__(): + if True: + lib1.__init__() + lib1.__init__() + else: + lib1.__init__() + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1}) + with pytest.raises(InitializerException) as e: + compile_code(main, input_bundle=input_bundle) + assert ( + e.value._message + == "tried to initialize `lib1`, but its __init__() function was already called!" + ) + + +def test_init_only_in_if_no_else(make_input_bundle): + main = """ +import lib1 + +initializes: lib1 + +@deploy +def __init__(): + if True: + lib1.__init__() + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1}) + with pytest.raises(InitializerException) as e: + compile_code(main, input_bundle=input_bundle) + assert "not guaranteed to be reachable" in e.value._message + assert "present only in a single branch of an if" in e.value._message + + +def test_nested_if_asymmetric(make_input_bundle): + # Outer if/else is balanced (both branches reach init), but the inner if + # only initializes in one branch — the inner asymmetry must be detected. + main = """ +import lib1 + +initializes: lib1 + +@deploy +def __init__(): + if True: + if True: + lib1.__init__() + else: + lib1.__init__() + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1}) + with pytest.raises(InitializerException) as e: + compile_code(main, input_bundle=input_bundle) + assert "not guaranteed to be reachable" in e.value._message + assert "present only in a single branch of an if" in e.value._message From ebe2c9a224cfb1e47b66a11740b487af31fe0de5 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Thu, 4 Jun 2026 15:41:14 +0200 Subject: [PATCH 09/29] Fix not checking correctly --- .../syntax/modules/test_initializers.py | 2 - vyper/semantics/analysis/module.py | 62 +++++++++++-------- 2 files changed, 35 insertions(+), 29 deletions(-) diff --git a/tests/functional/syntax/modules/test_initializers.py b/tests/functional/syntax/modules/test_initializers.py index 3e048dd862..70554db7f5 100644 --- a/tests/functional/syntax/modules/test_initializers.py +++ b/tests/functional/syntax/modules/test_initializers.py @@ -2182,7 +2182,6 @@ def __init__(): assert compile_code(main, input_bundle=input_bundle) is not None -@pytest.mark.xfail(raises=AssertionError) def test_init_then_if_else_no_inner_init(make_input_bundle): main = """ import lib1 @@ -2201,7 +2200,6 @@ def __init__(): assert compile_code(main, input_bundle=input_bundle) is not None -@pytest.mark.xfail(raises=AssertionError) def test_init_then_if_no_else(make_input_bundle): main = """ import lib1 diff --git a/vyper/semantics/analysis/module.py b/vyper/semantics/analysis/module.py index a9b56a2049..e5525f6909 100644 --- a/vyper/semantics/analysis/module.py +++ b/vyper/semantics/analysis/module.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections import defaultdict from itertools import zip_longest from typing import Optional @@ -184,7 +185,7 @@ def _validate_used_modules(module_ast: vy_ast.Module, module_t: ModuleT) -> None # TODO: use module dependencies to check __init__ are called in the dependency order # TODO: add handling of one side of an if-then-else reverting def is_initialized( - block: list[vy_ast.VyperNode], initializing_nodes: dict[ModuleInfo, list[vy_ast.VyperNode]] + block: list[vy_ast.VyperNode], init_calls: dict[ModuleInfo, list[vy_ast.VyperNode]] ) -> dict[ModuleInfo, list[vy_ast.VyperNode]]: """ TODO: Outdated @@ -196,7 +197,10 @@ def is_initialized( above this one. Contains multiple in case of branching. """ - initializing_nodes = {k: v.copy() for k, v in initializing_nodes.items()} + init_calls = {k: v.copy() for k, v in init_calls.items()} + + local_init_calls: dict[ModuleInfo, list[vy_ast.VyperNode]] = defaultdict(list) + """Subset of init_calls that happen in this block""" def extract_init_call(node: vy_ast.Call) -> ModuleInfo | None: @@ -229,19 +233,19 @@ def extract_init_call(node: vy_ast.Call) -> ModuleInfo | None: # Not an init call, nothing to do continue - if other_module_info not in initializing_nodes: + if other_module_info not in init_calls: msg = f"tried to initialize `{other_module_info.alias}`, " msg += "but it is not in initializer list!" hint = f"add `initializes: {other_module_info.alias}` " hint += "as a top-level statement to your contract" raise InitializerException(msg, node.value.func, hint=hint) - init_calls = initializing_nodes[other_module_info] + init_calls_m = init_calls[other_module_info] - if len(init_calls) != 0: + if len(init_calls_m) != 0: msg = f"tried to initialize `{other_module_info.alias}`, " msg += "but its __init__() function was already called!" - raise InitializerException(msg, node.value.func, init_calls) + raise InitializerException(msg, node.value.func, init_calls_m) uninitialized_dependents: list[str] = [] """ @@ -249,7 +253,8 @@ def extract_init_call(node: vy_ast.Call) -> ModuleInfo | None: """ for dependent in other_module_info.module_t.used_modules: - dependent_init_calls = initializing_nodes.get(dependent) + # Will be none in the case where the dependent module does not have an init method + dependent_init_calls = init_calls.get(dependent) if dependent_init_calls is not None and len(dependent_init_calls) == 0: uninitialized_dependents.append(dependent.alias) @@ -259,16 +264,14 @@ def extract_init_call(node: vy_ast.Call) -> ModuleInfo | None: msg += ", ".join(uninitialized_dependents) raise InitializerException(msg, node.value.func, init_calls) - init_calls += [node.value] + init_calls_m.append(node.value) + local_init_calls[other_module_info].append(node.value) elif isinstance(node, vy_ast.If): - then_nodes = is_initialized(node.body, initializing_nodes) - else_nodes = ( - is_initialized(node.orelse, initializing_nodes) - if node.orelse is not None - else {k: [] for k in initializing_nodes} - ) - for module_info in initializing_nodes: + then_nodes = is_initialized(node.body, init_calls) + else_nodes = is_initialized(node.orelse, init_calls) if node.orelse is not None else {} + # TODO: UX: instead of raising on the first, batch them all together + for module_info in init_calls: if bool(then_nodes[module_info]) != bool(else_nodes[module_info]): msg = f"`{module_info.alias}`.__init__() is not guaranteed to be reachable: " msg += "present only in a single branch of an if" @@ -276,23 +279,25 @@ def extract_init_call(node: vy_ast.Call) -> ModuleInfo | None: else: # If the context and the branches had init calls, # then we would already have errored: "__init__() function was already called!" - assert initializing_nodes[module_info] == [] or ( + assert init_calls[module_info] == [] or ( then_nodes[module_info] == [] and else_nodes[module_info] == [] ) - initializing_nodes[module_info] += ( - then_nodes[module_info] + else_nodes[module_info] - ) + + both_branches = then_nodes[module_info] + else_nodes[module_info] + + init_calls[module_info] += both_branches + local_init_calls[module_info] += both_branches elif isinstance(node, vy_ast.For): - # TODO: This forbids any calls to __init__() in a for loop, implement it more directly - loop_nodes = is_initialized(node.body, initializing_nodes) - for module_info in initializing_nodes: - if bool(initializing_nodes[module_info]) != bool(loop_nodes[module_info]): + loop_nodes = is_initialized(node.body, init_calls) + for module_info in init_calls: + if len(loop_nodes[module_info]) != 0: msg = f"`{module_info.alias}`.__init__() is not guaranteed to be reachable: " msg += "present in for loop" raise InitializerException(msg, node) - return initializing_nodes + # Only return the local init calls, this simplifies the branching logic + return local_init_calls def _validate_initialized_modules(module_ast: vy_ast.Module, module_t: ModuleT) -> None: @@ -309,11 +314,14 @@ def _validate_initialized_modules(module_ast: vy_ast.Module, module_t: ModuleT) if constructor is not None: assert isinstance(constructor.ast_def, vy_ast.FunctionDef) # help mypy - initializing_nodes = is_initialized(constructor.ast_def.body, initializing_nodes) + init_calls = is_initialized(constructor.ast_def.body, initializing_nodes) + else: + init_calls = {} err_list = ExceptionList() - for module_info, init_calls in initializing_nodes.items(): - if len(init_calls) == 0: + for module_info in initializing_nodes: + init_calls_m = init_calls.get(module_info, []) + if len(init_calls_m) == 0: msg = "not initialized!" hint = f"add `{module_info.alias}.__init__()` to " hint += "your `__init__()` function" From e62c1815684e6e8cf4fd4a9eb89476f7ecb54c9d Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Fri, 5 Jun 2026 09:44:36 +0200 Subject: [PATCH 10/29] Cleanup --- .../syntax/modules/test_initializers.py | 4 --- vyper/semantics/analysis/module.py | 26 ++++++++++--------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/tests/functional/syntax/modules/test_initializers.py b/tests/functional/syntax/modules/test_initializers.py index 70554db7f5..71890db06b 100644 --- a/tests/functional/syntax/modules/test_initializers.py +++ b/tests/functional/syntax/modules/test_initializers.py @@ -1890,10 +1890,6 @@ def __init__(): assert compile_code(main, input_bundle=input_bundle) is not None -# --- Dependency-ordering checks (`is_initialized` enforces that -# a module's `uses:` dependencies are initialized before it) --- - - # Shared module sources for the dependency-ordering tests below. # Each `uses:` declaration must be backed by real state access (otherwise # BorrowException fires before `is_initialized` ever runs). diff --git a/vyper/semantics/analysis/module.py b/vyper/semantics/analysis/module.py index e5525f6909..3e3784be8c 100644 --- a/vyper/semantics/analysis/module.py +++ b/vyper/semantics/analysis/module.py @@ -182,21 +182,23 @@ def _validate_used_modules(module_ast: vy_ast.Module, module_t: ModuleT) -> None err_list.raise_if_not_empty() -# TODO: use module dependencies to check __init__ are called in the dependency order # TODO: add handling of one side of an if-then-else reverting -def is_initialized( +def _validate_init_calls( block: list[vy_ast.VyperNode], init_calls: dict[ModuleInfo, list[vy_ast.VyperNode]] ) -> dict[ModuleInfo, list[vy_ast.VyperNode]]: """ - TODO: Outdated - Check a block for wether it calls other_module.__init__() + Check a block for wether it calls all required __init__() methods, and in the right order. + (Dependencies must be initialized before dependents.) Args: - TODO: - initializing_nodes: All inescapable call nodes to the constructor - above this one. Contains multiple in case of branching. + block: the current block, a list of nodes to analyze + init_calls: a dict from: + * Modules which need to be initialized, to + * Nodes in the containing scope which initialize it + (There can be more than one because of branching) """ + # Make a copy so that branches do not interfere init_calls = {k: v.copy() for k, v in init_calls.items()} local_init_calls: dict[ModuleInfo, list[vy_ast.VyperNode]] = defaultdict(list) @@ -268,8 +270,8 @@ def extract_init_call(node: vy_ast.Call) -> ModuleInfo | None: local_init_calls[other_module_info].append(node.value) elif isinstance(node, vy_ast.If): - then_nodes = is_initialized(node.body, init_calls) - else_nodes = is_initialized(node.orelse, init_calls) if node.orelse is not None else {} + then_nodes = _validate_init_calls(node.body, init_calls) + else_nodes = _validate_init_calls(node.orelse, init_calls) if node.orelse is not None else {} # TODO: UX: instead of raising on the first, batch them all together for module_info in init_calls: if bool(then_nodes[module_info]) != bool(else_nodes[module_info]): @@ -289,7 +291,7 @@ def extract_init_call(node: vy_ast.Call) -> ModuleInfo | None: local_init_calls[module_info] += both_branches elif isinstance(node, vy_ast.For): - loop_nodes = is_initialized(node.body, init_calls) + loop_nodes = _validate_init_calls(node.body, init_calls) for module_info in init_calls: if len(loop_nodes[module_info]) != 0: msg = f"`{module_info.alias}`.__init__() is not guaranteed to be reachable: " @@ -301,7 +303,7 @@ def extract_init_call(node: vy_ast.Call) -> ModuleInfo | None: def _validate_initialized_modules(module_ast: vy_ast.Module, module_t: ModuleT) -> None: - """Check all `initializes:` modules have `__init__()` called exactly once.""" + """Check all `initializes:` modules each have `__init__()` executed exactly once.""" # only call `__init__()` for modules which have an # `__init__()` function initializing_nodes: dict[ModuleInfo, list[vy_ast.VyperNode]] = { @@ -314,7 +316,7 @@ def _validate_initialized_modules(module_ast: vy_ast.Module, module_t: ModuleT) if constructor is not None: assert isinstance(constructor.ast_def, vy_ast.FunctionDef) # help mypy - init_calls = is_initialized(constructor.ast_def.body, initializing_nodes) + init_calls = _validate_init_calls(constructor.ast_def.body, initializing_nodes) else: init_calls = {} From 22784a1bd49fa87133dbd134c8c27d1380e23318 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Fri, 5 Jun 2026 09:59:29 +0200 Subject: [PATCH 11/29] Refactor init call fetching to be more robust --- .../syntax/modules/test_initializers.py | 2 +- vyper/semantics/analysis/module.py | 94 ++++++++++--------- 2 files changed, 49 insertions(+), 47 deletions(-) diff --git a/tests/functional/syntax/modules/test_initializers.py b/tests/functional/syntax/modules/test_initializers.py index 71890db06b..ca614b527d 100644 --- a/tests/functional/syntax/modules/test_initializers.py +++ b/tests/functional/syntax/modules/test_initializers.py @@ -2227,7 +2227,7 @@ def __init__(): with pytest.raises(InitializerException) as e: compile_code(main, input_bundle=input_bundle) assert "not guaranteed to be reachable" in e.value._message - assert "present in for loop" in e.value._message + assert "present in a for loop" in e.value._message def test_duplicate_init_in_single_branch(make_input_bundle): diff --git a/vyper/semantics/analysis/module.py b/vyper/semantics/analysis/module.py index 3e3784be8c..1e17f5930d 100644 --- a/vyper/semantics/analysis/module.py +++ b/vyper/semantics/analysis/module.py @@ -226,52 +226,11 @@ def extract_init_call(node: vy_ast.Call) -> ModuleInfo | None: for node in block: - # TODO: This assumes a specific AST shape for init calls, - # but it seems to be correct in practice - if isinstance(node, vy_ast.Expr) and isinstance(node.value, vy_ast.Call): - other_module_info = extract_init_call(node.value) - - if other_module_info is None: - # Not an init call, nothing to do - continue - - if other_module_info not in init_calls: - msg = f"tried to initialize `{other_module_info.alias}`, " - msg += "but it is not in initializer list!" - hint = f"add `initializes: {other_module_info.alias}` " - hint += "as a top-level statement to your contract" - raise InitializerException(msg, node.value.func, hint=hint) - - init_calls_m = init_calls[other_module_info] - - if len(init_calls_m) != 0: - msg = f"tried to initialize `{other_module_info.alias}`, " - msg += "but its __init__() function was already called!" - raise InitializerException(msg, node.value.func, init_calls_m) - - uninitialized_dependents: list[str] = [] - """ - Modules which the other module initializes, but whose init are not called beforehand - """ - - for dependent in other_module_info.module_t.used_modules: - # Will be none in the case where the dependent module does not have an init method - dependent_init_calls = init_calls.get(dependent) - if dependent_init_calls is not None and len(dependent_init_calls) == 0: - uninitialized_dependents.append(dependent.alias) - - if len(uninitialized_dependents) != 0: - msg = f"tried to initialize `{other_module_info.alias}`, " - msg += "but it depends on modules whose __init__() functions were not called: " - msg += ", ".join(uninitialized_dependents) - raise InitializerException(msg, node.value.func, init_calls) - - init_calls_m.append(node.value) - local_init_calls[other_module_info].append(node.value) - - elif isinstance(node, vy_ast.If): + if isinstance(node, vy_ast.If): then_nodes = _validate_init_calls(node.body, init_calls) - else_nodes = _validate_init_calls(node.orelse, init_calls) if node.orelse is not None else {} + else_nodes = ( + _validate_init_calls(node.orelse, init_calls) if node.orelse is not None else {} + ) # TODO: UX: instead of raising on the first, batch them all together for module_info in init_calls: if bool(then_nodes[module_info]) != bool(else_nodes[module_info]): @@ -295,9 +254,52 @@ def extract_init_call(node: vy_ast.Call) -> ModuleInfo | None: for module_info in init_calls: if len(loop_nodes[module_info]) != 0: msg = f"`{module_info.alias}`.__init__() is not guaranteed to be reachable: " - msg += "present in for loop" + msg += "present in a for loop" raise InitializerException(msg, node) + else: + for call in node.get_descendants(vy_ast.Call): + + other_module_info = extract_init_call(call) + + if other_module_info is None: + # Not an init call, nothing to do + continue + + if other_module_info not in init_calls: + msg = f"tried to initialize `{other_module_info.alias}`, " + msg += "but it is not in initializer list!" + hint = f"add `initializes: {other_module_info.alias}` " + hint += "as a top-level statement to your contract" + raise InitializerException(msg, call.func, hint=hint) + + init_calls_m = init_calls[other_module_info] + + if len(init_calls_m) != 0: + msg = f"tried to initialize `{other_module_info.alias}`, " + msg += "but its __init__() function was already called!" + raise InitializerException(msg, call.func, init_calls_m) + + uninitialized_dependents: list[str] = [] + """ + Modules which the other module initializes, but whose init are not called beforehand + """ + + for dependent in other_module_info.module_t.used_modules: + # None in the case where the dependent module does not have an init method + dependent_init_calls = init_calls.get(dependent) + if dependent_init_calls is not None and len(dependent_init_calls) == 0: + uninitialized_dependents.append(dependent.alias) + + if len(uninitialized_dependents) != 0: + msg = f"tried to initialize `{other_module_info.alias}`, " + msg += "but it depends on modules whose __init__() functions were not called: " + msg += ", ".join(uninitialized_dependents) + raise InitializerException(msg, call.func, init_calls) + + init_calls_m.append(call) + local_init_calls[other_module_info].append(call) + # Only return the local init calls, this simplifies the branching logic return local_init_calls From 66d5482fcf88f68f721bba0161262e7043cf31ad Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Fri, 12 Jun 2026 09:33:11 +0200 Subject: [PATCH 12/29] Make _validate_init_calls unaware of which modules should be initialized --- .../syntax/modules/test_initializers.py | 8 ++-- vyper/semantics/analysis/module.py | 40 +++++++++++-------- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/tests/functional/syntax/modules/test_initializers.py b/tests/functional/syntax/modules/test_initializers.py index ca614b527d..f05afb9e59 100644 --- a/tests/functional/syntax/modules/test_initializers.py +++ b/tests/functional/syntax/modules/test_initializers.py @@ -1964,7 +1964,7 @@ def __init__(): compile_code(main, input_bundle=input_bundle) msg = ( "tried to initialize `lib2`, but it depends on modules whose " - "__init__() functions were not called: lib1" + "__init__() functions were not called beforehand: lib1" ) assert e.value._message == msg @@ -1992,7 +1992,7 @@ def __init__(): compile_code(main, input_bundle=input_bundle) msg = ( "tried to initialize `lib3`, but it depends on modules whose " - "__init__() functions were not called: lib1, lib2" + "__init__() functions were not called beforehand: lib1, lib2" ) assert e.value._message == msg @@ -2020,7 +2020,7 @@ def __init__(): compile_code(main, input_bundle=input_bundle) msg = ( "tried to initialize `lib3`, but it depends on modules whose " - "__init__() functions were not called: lib2" + "__init__() functions were not called beforehand: lib2" ) assert e.value._message == msg @@ -2069,7 +2069,7 @@ def __init__(): compile_code(main, input_bundle=input_bundle) msg = ( "tried to initialize `lib2`, but it depends on modules whose " - "__init__() functions were not called: lib1" + "__init__() functions were not called beforehand: lib1" ) assert e.value._message == msg diff --git a/vyper/semantics/analysis/module.py b/vyper/semantics/analysis/module.py index 1e17f5930d..83fa87d00a 100644 --- a/vyper/semantics/analysis/module.py +++ b/vyper/semantics/analysis/module.py @@ -199,7 +199,7 @@ def _validate_init_calls( """ # Make a copy so that branches do not interfere - init_calls = {k: v.copy() for k, v in init_calls.items()} + init_calls = defaultdict(list, {k: v.copy() for k, v in init_calls.items()}) local_init_calls: dict[ModuleInfo, list[vy_ast.VyperNode]] = defaultdict(list) """Subset of init_calls that happen in this block""" @@ -232,7 +232,7 @@ def extract_init_call(node: vy_ast.Call) -> ModuleInfo | None: _validate_init_calls(node.orelse, init_calls) if node.orelse is not None else {} ) # TODO: UX: instead of raising on the first, batch them all together - for module_info in init_calls: + for module_info in {**then_nodes, **else_nodes}: if bool(then_nodes[module_info]) != bool(else_nodes[module_info]): msg = f"`{module_info.alias}`.__init__() is not guaranteed to be reachable: " msg += "present only in a single branch of an if" @@ -251,7 +251,7 @@ def extract_init_call(node: vy_ast.Call) -> ModuleInfo | None: elif isinstance(node, vy_ast.For): loop_nodes = _validate_init_calls(node.body, init_calls) - for module_info in init_calls: + for module_info in loop_nodes: if len(loop_nodes[module_info]) != 0: msg = f"`{module_info.alias}`.__init__() is not guaranteed to be reachable: " msg += "present in a for loop" @@ -266,13 +266,6 @@ def extract_init_call(node: vy_ast.Call) -> ModuleInfo | None: # Not an init call, nothing to do continue - if other_module_info not in init_calls: - msg = f"tried to initialize `{other_module_info.alias}`, " - msg += "but it is not in initializer list!" - hint = f"add `initializes: {other_module_info.alias}` " - hint += "as a top-level statement to your contract" - raise InitializerException(msg, call.func, hint=hint) - init_calls_m = init_calls[other_module_info] if len(init_calls_m) != 0: @@ -280,21 +273,26 @@ def extract_init_call(node: vy_ast.Call) -> ModuleInfo | None: msg += "but its __init__() function was already called!" raise InitializerException(msg, call.func, init_calls_m) + # If A uses B, make sure B.__init__ is called before A.__init__ + uninitialized_dependents: list[str] = [] """ Modules which the other module initializes, but whose init are not called beforehand """ for dependent in other_module_info.module_t.used_modules: - # None in the case where the dependent module does not have an init method - dependent_init_calls = init_calls.get(dependent) - if dependent_init_calls is not None and len(dependent_init_calls) == 0: + if dependent.module_t.init_function is None: + # no constructor to check + continue + + dependent_init_calls = init_calls[dependent] + if len(dependent_init_calls) == 0: uninitialized_dependents.append(dependent.alias) if len(uninitialized_dependents) != 0: msg = f"tried to initialize `{other_module_info.alias}`, " - msg += "but it depends on modules whose __init__() functions were not called: " - msg += ", ".join(uninitialized_dependents) + msg += "but it depends on modules whose __init__() functions " + msg += "were not called beforehand: " + ", ".join(uninitialized_dependents) raise InitializerException(msg, call.func, init_calls) init_calls_m.append(call) @@ -318,7 +316,7 @@ def _validate_initialized_modules(module_ast: vy_ast.Module, module_t: ModuleT) if constructor is not None: assert isinstance(constructor.ast_def, vy_ast.FunctionDef) # help mypy - init_calls = _validate_init_calls(constructor.ast_def.body, initializing_nodes) + init_calls = _validate_init_calls(constructor.ast_def.body, defaultdict(list)) else: init_calls = {} @@ -338,9 +336,17 @@ def _validate_initialized_modules(module_ast: vy_ast.Module, module_t: ModuleT) err_list.append( InitializerException(msg, init_func_node, module_info.ownership_decl, hint=hint) ) - err_list.raise_if_not_empty() + for module_info in init_calls: + if module_info not in initializing_nodes: + msg = f"tried to initialize `{module_info.alias}`, " + msg += "but it is not in initializer list!" + hint = f"add `initializes: {module_info.alias}` " + hint += "as a top-level statement to your contract" + raise InitializerException(msg, *init_calls[module_info], hint=hint) + + def _validate_exports_uses(module_ast: vy_ast.Module, module_t: ModuleT) -> None: """ From 93b7f6ddb28e565ea35509296c56747afcdd8616 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Fri, 12 Jun 2026 14:26:53 +0200 Subject: [PATCH 13/29] Handle raises --- .../syntax/modules/test_initializers.py | 248 ++++++++++++++++++ vyper/semantics/analysis/module.py | 41 ++- 2 files changed, 285 insertions(+), 4 deletions(-) diff --git a/tests/functional/syntax/modules/test_initializers.py b/tests/functional/syntax/modules/test_initializers.py index f05afb9e59..85de982d5f 100644 --- a/tests/functional/syntax/modules/test_initializers.py +++ b/tests/functional/syntax/modules/test_initializers.py @@ -2292,3 +2292,251 @@ def __init__(): compile_code(main, input_bundle=input_bundle) assert "not guaranteed to be reachable" in e.value._message assert "present only in a single branch of an if" in e.value._message + + +def test_raise_in_then_init_in_else(make_input_bundle): + main = """ +import lib1 + +initializes: lib1 + +@deploy +def __init__(cond: bool): + if cond: + raise "nope" + else: + lib1.__init__() + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1}) + assert compile_code(main, input_bundle=input_bundle) is not None + + +def test_init_in_then_raise_in_else(make_input_bundle): + main = """ +import lib1 + +initializes: lib1 + +@deploy +def __init__(cond: bool): + if cond: + lib1.__init__() + else: + raise "nope" + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1}) + assert compile_code(main, input_bundle=input_bundle) is not None + + +def test_both_branches_raise_with_initializer(make_input_bundle): + main = """ +import lib1 + +initializes: lib1 + +@deploy +def __init__(cond: bool): + if cond: + raise "nope" + else: + raise "still nope" + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1}) + with pytest.raises(InitializerException) as e: + compile_code(main, input_bundle=input_bundle) + assert e.value._message == "not initialized!" + + +def test_init_then_raise_in_one_branch(make_input_bundle): + main = """ +import lib1 + +initializes: lib1 + +@deploy +def __init__(cond: bool): + if cond: + lib1.__init__() + raise "nope" + else: + lib1.__init__() + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1}) + assert compile_code(main, input_bundle=input_bundle) is not None + + +def test_nested_if_both_branches_raise_then_outer_else_inits(make_input_bundle): + main = """ +import lib1 + +initializes: lib1 + +@deploy +def __init__(cond1: bool, cond2: bool): + if cond1: + if cond2: + raise "nope" + else: + raise "still nope" + else: + lib1.__init__() + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1}) + assert compile_code(main, input_bundle=input_bundle) is not None + + +def test_init_body_only_raise_with_initializer(make_input_bundle): + main = """ +import lib1 + +initializes: lib1 + +@deploy +def __init__(): + raise "nope" + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1}) + with pytest.raises(InitializerException) as e: + compile_code(main, input_bundle=input_bundle) + assert e.value._message == "not initialized!" + + +def test_init_body_only_raise_no_initializers(make_input_bundle): + main = """ +@deploy +def __init__(): + raise "nope" + """ + assert compile_code(main) is not None + + +def test_raise_in_for_loop_body_alone(make_input_bundle): + main = """ +@deploy +def __init__(): + for i: uint256 in range(10): + raise "nope" + """ + assert compile_code(main) is not None + + +def test_init_then_for_loop_with_raise(make_input_bundle): + main = """ +import lib1 + +initializes: lib1 + +@deploy +def __init__(): + lib1.__init__() + for i: uint256 in range(10): + raise "nope" + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1}) + assert compile_code(main, input_bundle=input_bundle) is not None + + +def test_dependency_ordering_preserved_with_raise_wildcard(make_input_bundle): + main = """ +import lib1 +import lib2 + +initializes: lib2[lib1 := lib1] +initializes: lib1 + +@deploy +def __init__(cond: bool): + if cond: + raise "nope" + else: + lib2.__init__() + lib1.__init__() + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1, "lib2.vy": _LIB2_USES_LIB1}) + with pytest.raises(InitializerException) as e: + compile_code(main, input_bundle=input_bundle) + msg = ( + "tried to initialize `lib2`, but it depends on modules whose " + "__init__() functions were not called beforehand: lib1" + ) + assert e.value._message == msg + + +def test_dependency_init_split_across_raise_branches(make_input_bundle): + main = """ +import lib1 +import lib2 + +initializes: lib2[lib1 := lib1] +initializes: lib1 + +@deploy +def __init__(cond: bool): + if cond: + raise "nope" + else: + lib1.__init__() + lib2.__init__() + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1, "lib2.vy": _LIB2_USES_LIB1}) + assert compile_code(main, input_bundle=input_bundle) is not None + + +def test_dep_init_after_raise_wildcard_if(make_input_bundle): + main = """ +import lib1 +import lib2 + +initializes: lib1 +initializes: lib2[lib1 := lib1] + +@deploy +def __init__(cond: bool): + if cond: + lib1.__init__() + else: + raise "nope" + lib2.__init__() + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1, "lib2.vy": _LIB2_USES_LIB1}) + assert compile_code(main, input_bundle=input_bundle) is not None + + +def test_nested_if_inner_raise_as_wildcard(make_input_bundle): + main = """ +import lib1 + +initializes: lib1 + +@deploy +def __init__(cond1: bool, cond2: bool): + if cond1: + if cond2: + raise "nope" + else: + lib1.__init__() + else: + lib1.__init__() + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1}) + assert compile_code(main, input_bundle=input_bundle) is not None + + +def test_assert_false_is_not_a_wildcard(make_input_bundle): + main = """ +import lib1 + +initializes: lib1 + +@deploy +def __init__(cond: bool): + if cond: + assert False, "nope" + else: + lib1.__init__() + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1}) + with pytest.raises(InitializerException) as e: + compile_code(main, input_bundle=input_bundle) + assert "not guaranteed to be reachable" in e.value._message + assert "present only in a single branch of an if" in e.value._message diff --git a/vyper/semantics/analysis/module.py b/vyper/semantics/analysis/module.py index 83fa87d00a..89189f77ce 100644 --- a/vyper/semantics/analysis/module.py +++ b/vyper/semantics/analysis/module.py @@ -182,13 +182,14 @@ def _validate_used_modules(module_ast: vy_ast.Module, module_t: ModuleT) -> None err_list.raise_if_not_empty() -# TODO: add handling of one side of an if-then-else reverting def _validate_init_calls( block: list[vy_ast.VyperNode], init_calls: dict[ModuleInfo, list[vy_ast.VyperNode]] -) -> dict[ModuleInfo, list[vy_ast.VyperNode]]: +) -> dict[ModuleInfo, list[vy_ast.VyperNode]] | None: """ Check a block for wether it calls all required __init__() methods, and in the right order. (Dependencies must be initialized before dependents.) + Reverting acts as a wildcard with respects to initialization: when a branch contains a revert, + we count it as initializing whatever the other branch also initialized. Args: block: the current block, a list of nodes to analyze @@ -196,6 +197,10 @@ def _validate_init_calls( * Modules which need to be initialized, to * Nodes in the containing scope which initialize it (There can be more than one because of branching) + + Returns: + Which modules are initialized *in this block* (for example a single branch of an if), + or None if there is a revert. """ # Make a copy so that branches do not interfere @@ -226,11 +231,33 @@ def extract_init_call(node: vy_ast.Call) -> ModuleInfo | None: for node in block: - if isinstance(node, vy_ast.If): + if isinstance(node, vy_ast.Raise): + # If we raise, return the wildcard + return None + + elif isinstance(node, vy_ast.If): then_nodes = _validate_init_calls(node.body, init_calls) else_nodes = ( _validate_init_calls(node.orelse, init_calls) if node.orelse is not None else {} ) + if then_nodes is None and else_nodes is None: + # Both branches revert, the block as a whole reverts + return None + elif then_nodes is None: + # then-branch reverts, use the initializations from the else-branch + assert else_nodes is not None # help mypy + for module_info in else_nodes: + init_calls[module_info] += else_nodes[module_info] + local_init_calls[module_info] += else_nodes[module_info] + continue + elif else_nodes is None: + assert then_nodes is not None # help mypy + # else-branch reverts, use the initializations from the then-branch + for module_info in then_nodes: + init_calls[module_info] += then_nodes[module_info] + local_init_calls[module_info] += then_nodes[module_info] + continue + assert then_nodes is not None and else_nodes is not None # help mypy # TODO: UX: instead of raising on the first, batch them all together for module_info in {**then_nodes, **else_nodes}: if bool(then_nodes[module_info]) != bool(else_nodes[module_info]): @@ -251,6 +278,9 @@ def extract_init_call(node: vy_ast.Call) -> ModuleInfo | None: elif isinstance(node, vy_ast.For): loop_nodes = _validate_init_calls(node.body, init_calls) + if loop_nodes is None: + # Raise in the body of loop is not necessarily reachable + continue for module_info in loop_nodes: if len(loop_nodes[module_info]) != 0: msg = f"`{module_info.alias}`.__init__() is not guaranteed to be reachable: " @@ -317,6 +347,10 @@ def _validate_initialized_modules(module_ast: vy_ast.Module, module_t: ModuleT) if constructor is not None: assert isinstance(constructor.ast_def, vy_ast.FunctionDef) # help mypy init_calls = _validate_init_calls(constructor.ast_def.body, defaultdict(list)) + + if init_calls is None: + # __init__ always reverts, maybe we should throw an error ? + init_calls = {} else: init_calls = {} @@ -347,7 +381,6 @@ def _validate_initialized_modules(module_ast: vy_ast.Module, module_t: ModuleT) raise InitializerException(msg, *init_calls[module_info], hint=hint) - def _validate_exports_uses(module_ast: vy_ast.Module, module_t: ModuleT) -> None: """ Check that exported functions that use state have proper `uses:` declarations. From 2016b1d7af96f16e43ca9ed35d5c694ac70173b1 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Fri, 12 Jun 2026 14:35:13 +0200 Subject: [PATCH 14/29] If.orelse is never None --- vyper/semantics/analysis/local.py | 6 +----- vyper/semantics/analysis/module.py | 5 ++--- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/vyper/semantics/analysis/local.py b/vyper/semantics/analysis/local.py index 643956f883..1af052d87e 100644 --- a/vyper/semantics/analysis/local.py +++ b/vyper/semantics/analysis/local.py @@ -119,11 +119,7 @@ def is_terminated(block: list[vy_ast.VyperNode]) -> bool: terminated = True if isinstance(node, vy_ast.If): - if node.orelse is not None: - terminated = is_terminated(node.body) and is_terminated(node.orelse) - else: - # call is_terminated for its side effects - is_terminated(node.body) + terminated = is_terminated(node.body) and is_terminated(node.orelse) if isinstance(node, vy_ast.For): # call is_terminated for its side effects diff --git a/vyper/semantics/analysis/module.py b/vyper/semantics/analysis/module.py index 89189f77ce..285170a369 100644 --- a/vyper/semantics/analysis/module.py +++ b/vyper/semantics/analysis/module.py @@ -237,9 +237,8 @@ def extract_init_call(node: vy_ast.Call) -> ModuleInfo | None: elif isinstance(node, vy_ast.If): then_nodes = _validate_init_calls(node.body, init_calls) - else_nodes = ( - _validate_init_calls(node.orelse, init_calls) if node.orelse is not None else {} - ) + else_nodes = _validate_init_calls(node.orelse, init_calls) + if then_nodes is None and else_nodes is None: # Both branches revert, the block as a whole reverts return None From 597fdbe6cb12b9e3281b227c15d007ea70822e31 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Fri, 12 Jun 2026 15:06:32 +0200 Subject: [PATCH 15/29] Cleanup --- vyper/semantics/analysis/module.py | 79 +++++++++++++++++------------- 1 file changed, 44 insertions(+), 35 deletions(-) diff --git a/vyper/semantics/analysis/module.py b/vyper/semantics/analysis/module.py index 285170a369..fce7de9ae8 100644 --- a/vyper/semantics/analysis/module.py +++ b/vyper/semantics/analysis/module.py @@ -182,6 +182,26 @@ def _validate_used_modules(module_ast: vy_ast.Module, module_t: ModuleT) -> None err_list.raise_if_not_empty() +def _extract_init_call(node: vy_ast.Call) -> ModuleInfo | None: + + expr_info = node.func._expr_info + + if expr_info is None: + # this can happen for range() calls; CMC 2024-02-05 try to + # refactor so that range() is properly tagged. + return None + + call_t = expr_info.typ + + if not isinstance(call_t, ContractFunctionT): + return None + + if not call_t.is_constructor: + return None + + # XXX: check this works as expected for nested attributes + return node.func.value._expr_info.module_info # type: ignore + def _validate_init_calls( block: list[vy_ast.VyperNode], init_calls: dict[ModuleInfo, list[vy_ast.VyperNode]] ) -> dict[ModuleInfo, list[vy_ast.VyperNode]] | None: @@ -209,26 +229,6 @@ def _validate_init_calls( local_init_calls: dict[ModuleInfo, list[vy_ast.VyperNode]] = defaultdict(list) """Subset of init_calls that happen in this block""" - def extract_init_call(node: vy_ast.Call) -> ModuleInfo | None: - - expr_info = node.func._expr_info - - if expr_info is None: - # this can happen for range() calls; CMC 2024-02-05 try to - # refactor so that range() is properly tagged. - return None - - call_t = expr_info.typ - - if not isinstance(call_t, ContractFunctionT): - return None - - if not call_t.is_constructor: - return None - - # XXX: check this works as expected for nested attributes - return node.func.value._expr_info.module_info # type: ignore - for node in block: if isinstance(node, vy_ast.Raise): @@ -287,9 +287,10 @@ def extract_init_call(node: vy_ast.Call) -> ModuleInfo | None: raise InitializerException(msg, node) else: + # Regular, non-branching node for call in node.get_descendants(vy_ast.Call): - other_module_info = extract_init_call(call) + other_module_info = _extract_init_call(call) if other_module_info is None: # Not an init call, nothing to do @@ -332,31 +333,39 @@ def extract_init_call(node: vy_ast.Call) -> ModuleInfo | None: def _validate_initialized_modules(module_ast: vy_ast.Module, module_t: ModuleT) -> None: - """Check all `initializes:` modules each have `__init__()` executed exactly once.""" + """ + Check all `initializes:` modules each have `__init__()` executed exactly once. + Also checks that the initializes calls are done in the correct order, + if a uses b, init of b is called before init of a. + + This check handles branching by requiring the set of initialized modules to be + the same in both branches. + (If one branch raises, we act as if it initialized all necessary modules.) + """ # only call `__init__()` for modules which have an # `__init__()` function - initializing_nodes: dict[ModuleInfo, list[vy_ast.VyperNode]] = { - t.module_info: [] + modules_to_initialize: list[ModuleInfo] = [ + t.module_info for t in module_t.initialized_modules if t.module_info.module_t.init_function is not None - } + ] constructor = module_t.init_function if constructor is not None: assert isinstance(constructor.ast_def, vy_ast.FunctionDef) # help mypy - init_calls = _validate_init_calls(constructor.ast_def.body, defaultdict(list)) + init_calls_by_module = _validate_init_calls(constructor.ast_def.body, defaultdict(list)) - if init_calls is None: + if init_calls_by_module is None: # __init__ always reverts, maybe we should throw an error ? - init_calls = {} + init_calls_by_module = {} else: - init_calls = {} + init_calls_by_module = {} err_list = ExceptionList() - for module_info in initializing_nodes: - init_calls_m = init_calls.get(module_info, []) - if len(init_calls_m) == 0: + for module_info in modules_to_initialize: + init_calls = init_calls_by_module.get(module_info, []) + if len(init_calls) == 0: msg = "not initialized!" hint = f"add `{module_info.alias}.__init__()` to " hint += "your `__init__()` function" @@ -371,13 +380,13 @@ def _validate_initialized_modules(module_ast: vy_ast.Module, module_t: ModuleT) ) err_list.raise_if_not_empty() - for module_info in init_calls: - if module_info not in initializing_nodes: + for module_info in init_calls_by_module: + if module_info not in modules_to_initialize: msg = f"tried to initialize `{module_info.alias}`, " msg += "but it is not in initializer list!" hint = f"add `initializes: {module_info.alias}` " hint += "as a top-level statement to your contract" - raise InitializerException(msg, *init_calls[module_info], hint=hint) + raise InitializerException(msg, *init_calls_by_module[module_info], hint=hint) def _validate_exports_uses(module_ast: vy_ast.Module, module_t: ModuleT) -> None: From 524c779ddcf4c73a7e923e074ad5ea0b5c1b9c05 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Fri, 12 Jun 2026 16:23:51 +0200 Subject: [PATCH 16/29] Reword error message --- .../syntax/modules/test_initializers.py | 20 +++++++++---------- vyper/semantics/analysis/module.py | 11 ++++++---- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/tests/functional/syntax/modules/test_initializers.py b/tests/functional/syntax/modules/test_initializers.py index 85de982d5f..84452a4c5a 100644 --- a/tests/functional/syntax/modules/test_initializers.py +++ b/tests/functional/syntax/modules/test_initializers.py @@ -1963,8 +1963,8 @@ def __init__(): with pytest.raises(InitializerException) as e: compile_code(main, input_bundle=input_bundle) msg = ( - "tried to initialize `lib2`, but it depends on modules whose " - "__init__() functions were not called beforehand: lib1" + "tried to initialize `lib2`, but it depends on the following modules " + "which have not been initialized: lib1" ) assert e.value._message == msg @@ -1991,8 +1991,8 @@ def __init__(): with pytest.raises(InitializerException) as e: compile_code(main, input_bundle=input_bundle) msg = ( - "tried to initialize `lib3`, but it depends on modules whose " - "__init__() functions were not called beforehand: lib1, lib2" + "tried to initialize `lib3`, but it depends on the following modules " + "which have not been initialized: lib1, lib2" ) assert e.value._message == msg @@ -2019,8 +2019,8 @@ def __init__(): with pytest.raises(InitializerException) as e: compile_code(main, input_bundle=input_bundle) msg = ( - "tried to initialize `lib3`, but it depends on modules whose " - "__init__() functions were not called beforehand: lib2" + "tried to initialize `lib3`, but it depends on the following modules " + "which have not been initialized: lib2" ) assert e.value._message == msg @@ -2068,8 +2068,8 @@ def __init__(): with pytest.raises(InitializerException) as e: compile_code(main, input_bundle=input_bundle) msg = ( - "tried to initialize `lib2`, but it depends on modules whose " - "__init__() functions were not called beforehand: lib1" + "tried to initialize `lib2`, but it depends on the following modules " + "which have not been initialized: lib1" ) assert e.value._message == msg @@ -2456,8 +2456,8 @@ def __init__(cond: bool): with pytest.raises(InitializerException) as e: compile_code(main, input_bundle=input_bundle) msg = ( - "tried to initialize `lib2`, but it depends on modules whose " - "__init__() functions were not called beforehand: lib1" + "tried to initialize `lib2`, but it depends on the following modules " + "which have not been initialized: lib1" ) assert e.value._message == msg diff --git a/vyper/semantics/analysis/module.py b/vyper/semantics/analysis/module.py index fce7de9ae8..a513d0c448 100644 --- a/vyper/semantics/analysis/module.py +++ b/vyper/semantics/analysis/module.py @@ -202,6 +202,7 @@ def _extract_init_call(node: vy_ast.Call) -> ModuleInfo | None: # XXX: check this works as expected for nested attributes return node.func.value._expr_info.module_info # type: ignore + def _validate_init_calls( block: list[vy_ast.VyperNode], init_calls: dict[ModuleInfo, list[vy_ast.VyperNode]] ) -> dict[ModuleInfo, list[vy_ast.VyperNode]] | None: @@ -321,9 +322,11 @@ def _validate_init_calls( if len(uninitialized_dependents) != 0: msg = f"tried to initialize `{other_module_info.alias}`, " - msg += "but it depends on modules whose __init__() functions " - msg += "were not called beforehand: " + ", ".join(uninitialized_dependents) - raise InitializerException(msg, call.func, init_calls) + msg += "but it depends on the following modules " + msg += "which have not been initialized: " + ", ".join(uninitialized_dependents) + hint = f"call their `__init__()` methods before " + hint += f"`{other_module_info.alias}.__init__()`." + raise InitializerException(msg, call.func, init_calls, hint=hint) init_calls_m.append(call) local_init_calls[other_module_info].append(call) @@ -337,7 +340,7 @@ def _validate_initialized_modules(module_ast: vy_ast.Module, module_t: ModuleT) Check all `initializes:` modules each have `__init__()` executed exactly once. Also checks that the initializes calls are done in the correct order, if a uses b, init of b is called before init of a. - + This check handles branching by requiring the set of initialized modules to be the same in both branches. (If one branch raises, we act as if it initialized all necessary modules.) From dbda0a966529d6ca8557bc0bae0c88478f444f4f Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Fri, 12 Jun 2026 16:24:16 +0200 Subject: [PATCH 17/29] Make lint --- vyper/semantics/analysis/module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vyper/semantics/analysis/module.py b/vyper/semantics/analysis/module.py index a513d0c448..0cd30fe964 100644 --- a/vyper/semantics/analysis/module.py +++ b/vyper/semantics/analysis/module.py @@ -324,7 +324,7 @@ def _validate_init_calls( msg = f"tried to initialize `{other_module_info.alias}`, " msg += "but it depends on the following modules " msg += "which have not been initialized: " + ", ".join(uninitialized_dependents) - hint = f"call their `__init__()` methods before " + hint = "call their `__init__()` methods before " hint += f"`{other_module_info.alias}.__init__()`." raise InitializerException(msg, call.func, init_calls, hint=hint) From c8a56876141d9b0ac8f0db6450877564ead3206d Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Tue, 16 Jun 2026 10:03:15 +0200 Subject: [PATCH 18/29] Handle returns in constructor --- .../syntax/modules/test_initializers.py | 287 +++++++++++++++++- vyper/semantics/analysis/module.py | 106 ++++--- 2 files changed, 351 insertions(+), 42 deletions(-) diff --git a/tests/functional/syntax/modules/test_initializers.py b/tests/functional/syntax/modules/test_initializers.py index 84452a4c5a..8a2f7fe09e 100644 --- a/tests/functional/syntax/modules/test_initializers.py +++ b/tests/functional/syntax/modules/test_initializers.py @@ -2342,9 +2342,7 @@ def __init__(cond: bool): raise "still nope" """ input_bundle = make_input_bundle({"lib1.vy": _LIB1}) - with pytest.raises(InitializerException) as e: - compile_code(main, input_bundle=input_bundle) - assert e.value._message == "not initialized!" + assert compile_code(main, input_bundle=input_bundle) is not None def test_init_then_raise_in_one_branch(make_input_bundle): @@ -2396,9 +2394,7 @@ def __init__(): raise "nope" """ input_bundle = make_input_bundle({"lib1.vy": _LIB1}) - with pytest.raises(InitializerException) as e: - compile_code(main, input_bundle=input_bundle) - assert e.value._message == "not initialized!" + assert compile_code(main, input_bundle=input_bundle) is not None def test_init_body_only_raise_no_initializers(make_input_bundle): @@ -2540,3 +2536,282 @@ def __init__(cond: bool): compile_code(main, input_bundle=input_bundle) assert "not guaranteed to be reachable" in e.value._message assert "present only in a single branch of an if" in e.value._message + + +def test_return_at_end_after_init(make_input_bundle): + main = """ +import lib1 + +initializes: lib1 + +@deploy +def __init__(): + lib1.__init__() + return + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1}) + assert compile_code(main, input_bundle=input_bundle) is not None + + +def test_bare_return_no_initializers(make_input_bundle): + main = """ +@deploy +def __init__(): + return + """ + assert compile_code(main) is not None + + +def test_return_before_required_init(make_input_bundle): + main = """ +import lib1 + +initializes: lib1 + +@deploy +def __init__(): + return + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1}) + with pytest.raises(InitializerException) as e: + compile_code(main, input_bundle=input_bundle) + assert e.value._message == "not initialized!" + + +def test_init_after_return_is_dead(make_input_bundle): + main = """ +import lib1 + +initializes: lib1 + +@deploy +def __init__(): + return + lib1.__init__() + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1}) + with pytest.raises(StructureException) as e: + compile_code(main, input_bundle=input_bundle) + assert e.value._message == "Unreachable code!" + + +def test_return_in_then_after_init_else_inits(make_input_bundle): + main = """ +import lib1 + +initializes: lib1 + +@deploy +def __init__(cond: bool): + if cond: + lib1.__init__() + return + else: + lib1.__init__() + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1}) + assert compile_code(main, input_bundle=input_bundle) is not None + + +def test_return_in_then_without_init_else_inits(make_input_bundle): + main = """ +import lib1 + +initializes: lib1 + +@deploy +def __init__(cond: bool): + if cond: + return + else: + lib1.__init__() + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1}) + with pytest.raises(InitializerException) as e: + compile_code(main, input_bundle=input_bundle) + assert e.value._message == "not initialized!" + + +def test_both_branches_return_after_init(make_input_bundle): + main = """ +import lib1 + +initializes: lib1 + +@deploy +def __init__(cond: bool): + if cond: + lib1.__init__() + return + else: + lib1.__init__() + return + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1}) + assert compile_code(main, input_bundle=input_bundle) is not None + + +def test_both_branches_return_without_init(make_input_bundle): + main = """ +import lib1 + +initializes: lib1 + +@deploy +def __init__(cond: bool): + if cond: + return + else: + return + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1}) + with pytest.raises(InitializerException) as e: + compile_code(main, input_bundle=input_bundle) + assert e.value._message == "not initialized!" + + +def test_return_after_if_validates_total(make_input_bundle): + main = """ +import lib1 + +initializes: lib1 + +@deploy +def __init__(cond: bool): + if cond: + lib1.__init__() + else: + lib1.__init__() + return + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1}) + assert compile_code(main, input_bundle=input_bundle) is not None + + +def test_nested_if_return_in_inner_branch_without_init(make_input_bundle): + main = """ +import lib1 + +initializes: lib1 + +@deploy +def __init__(cond1: bool, cond2: bool): + if cond1: + if cond2: + return + else: + lib1.__init__() + else: + lib1.__init__() + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1}) + with pytest.raises(InitializerException) as e: + compile_code(main, input_bundle=input_bundle) + assert e.value._message == "not initialized!" + + +def test_return_in_for_body_no_initializers(make_input_bundle): + main = """ +@deploy +def __init__(): + for i: uint256 in range(10): + return + """ + assert compile_code(main) is not None + + +def test_return_in_for_body_required_init_missing(make_input_bundle): + main = """ +import lib1 + +initializes: lib1 + +@deploy +def __init__(): + for i: uint256 in range(10): + return + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1}) + with pytest.raises(InitializerException) as e: + compile_code(main, input_bundle=input_bundle) + assert e.value._message == "not initialized!" + + +def test_return_and_init_in_for_body(make_input_bundle): + main = """ +import lib1 + +initializes: lib1 + +@deploy +def __init__(): + for i: uint256 in range(10): + lib1.__init__() + return + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1}) + with pytest.raises(InitializerException) as e: + compile_code(main, input_bundle=input_bundle) + assert e.value._message == "not initialized!" + + +def test_init_then_for_loop_with_return(make_input_bundle): + main = """ +import lib1 + +initializes: lib1 + +@deploy +def __init__(): + lib1.__init__() + for i: uint256 in range(10): + return + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1}) + assert compile_code(main, input_bundle=input_bundle) is not None + + +def test_return_after_dependent_before_dependency(make_input_bundle): + main = """ +import lib1 +import lib2 + +initializes: lib2[lib1 := lib1] +initializes: lib1 + +@deploy +def __init__(): + lib2.__init__() + return + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1, "lib2.vy": _LIB2_USES_LIB1}) + with pytest.raises(InitializerException) as e: + compile_code(main, input_bundle=input_bundle) + msg = ( + "tried to initialize `lib2`, but it depends on the following modules " + "which have not been initialized: lib1" + ) + assert e.value._message == msg + + +def test_return_in_branch_with_correct_dep_order(make_input_bundle): + main = """ +import lib1 +import lib2 + +initializes: lib1 +initializes: lib2[lib1 := lib1] + +@deploy +def __init__(cond: bool): + if cond: + lib1.__init__() + lib2.__init__() + return + else: + lib1.__init__() + lib2.__init__() + return + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1, "lib2.vy": _LIB2_USES_LIB1}) + assert compile_code(main, input_bundle=input_bundle) is not None diff --git a/vyper/semantics/analysis/module.py b/vyper/semantics/analysis/module.py index 0cd30fe964..6c0308f9ab 100644 --- a/vyper/semantics/analysis/module.py +++ b/vyper/semantics/analysis/module.py @@ -203,8 +203,45 @@ def _extract_init_call(node: vy_ast.Call) -> ModuleInfo | None: return node.func.value._expr_info.module_info # type: ignore +def _validate_init_return( + modules_to_initialize: list[ModuleInfo], + constructor: ContractFunctionT | None, + init_calls_by_module: dict[ModuleInfo, list[vy_ast.VyperNode]], +) -> None: + """ + Checks that the correct modules have their init called when returning from the constructor + """ + # grab the init function AST node for error message + # (it could be None, it's ok since it's just for diagnostics) + init_func_node = constructor.decl_node if constructor is not None else None + + err_list = ExceptionList() + for module_info in modules_to_initialize: + init_calls = init_calls_by_module.get(module_info, []) + if len(init_calls) == 0: + msg = "not initialized!" + hint = f"add `{module_info.alias}.__init__()` to " + hint += "your `__init__()` function" + + err_list.append( + InitializerException(msg, init_func_node, module_info.ownership_decl, hint=hint) + ) + err_list.raise_if_not_empty() + + for module_info in init_calls_by_module: + if module_info not in modules_to_initialize: + msg = f"tried to initialize `{module_info.alias}`, " + msg += "but it is not in initializer list!" + hint = f"add `initializes: {module_info.alias}` " + hint += "as a top-level statement to your contract" + raise InitializerException(msg, *init_calls_by_module[module_info], hint=hint) + + def _validate_init_calls( - block: list[vy_ast.VyperNode], init_calls: dict[ModuleInfo, list[vy_ast.VyperNode]] + block: list[vy_ast.VyperNode], + modules_to_initialize: list[ModuleInfo], + constructor: ContractFunctionT | None, + init_calls: dict[ModuleInfo, list[vy_ast.VyperNode]], ) -> dict[ModuleInfo, list[vy_ast.VyperNode]] | None: """ Check a block for wether it calls all required __init__() methods, and in the right order. @@ -213,8 +250,15 @@ def _validate_init_calls( we count it as initializing whatever the other branch also initialized. Args: - block: the current block, a list of nodes to analyze - init_calls: a dict from: + block: + the current block, a list of nodes to analyze + modules_to_initialize: + list of the modules the current module `initializes:` and which have a constructor + Should only be used by _validate_init_return + constructor: + the constructor of the current module, should only be used by _validate_init_return + init_calls: + a dict from: * Modules which need to be initialized, to * Nodes in the containing scope which initialize it (There can be more than one because of branching) @@ -236,9 +280,17 @@ def _validate_init_calls( # If we raise, return the wildcard return None + elif isinstance(node, vy_ast.Return): + _validate_init_return(modules_to_initialize, constructor, init_calls) + return None + elif isinstance(node, vy_ast.If): - then_nodes = _validate_init_calls(node.body, init_calls) - else_nodes = _validate_init_calls(node.orelse, init_calls) + then_nodes = _validate_init_calls( + node.body, modules_to_initialize, constructor, init_calls + ) + else_nodes = _validate_init_calls( + node.orelse, modules_to_initialize, constructor, init_calls + ) if then_nodes is None and else_nodes is None: # Both branches revert, the block as a whole reverts @@ -277,9 +329,11 @@ def _validate_init_calls( local_init_calls[module_info] += both_branches elif isinstance(node, vy_ast.For): - loop_nodes = _validate_init_calls(node.body, init_calls) + loop_nodes = _validate_init_calls( + node.body, modules_to_initialize, constructor, init_calls + ) if loop_nodes is None: - # Raise in the body of loop is not necessarily reachable + # raise/return in the body of loop is not necessarily reachable continue for module_info in loop_nodes: if len(loop_nodes[module_info]) != 0: @@ -357,39 +411,19 @@ def _validate_initialized_modules(module_ast: vy_ast.Module, module_t: ModuleT) if constructor is not None: assert isinstance(constructor.ast_def, vy_ast.FunctionDef) # help mypy - init_calls_by_module = _validate_init_calls(constructor.ast_def.body, defaultdict(list)) - if init_calls_by_module is None: - # __init__ always reverts, maybe we should throw an error ? - init_calls_by_module = {} + body = constructor.ast_def.body else: - init_calls_by_module = {} - - err_list = ExceptionList() - for module_info in modules_to_initialize: - init_calls = init_calls_by_module.get(module_info, []) - if len(init_calls) == 0: - msg = "not initialized!" - hint = f"add `{module_info.alias}.__init__()` to " - hint += "your `__init__()` function" + body = [] - # grab the init function AST node for error message - # (it could be None, it's ok since it's just for diagnostics) - init_func_node = None - if constructor is not None: - init_func_node = constructor.decl_node - err_list.append( - InitializerException(msg, init_func_node, module_info.ownership_decl, hint=hint) - ) - err_list.raise_if_not_empty() + init_calls_by_module = _validate_init_calls( + body, modules_to_initialize, constructor, defaultdict(list) + ) - for module_info in init_calls_by_module: - if module_info not in modules_to_initialize: - msg = f"tried to initialize `{module_info.alias}`, " - msg += "but it is not in initializer list!" - hint = f"add `initializes: {module_info.alias}` " - hint += "as a top-level statement to your contract" - raise InitializerException(msg, *init_calls_by_module[module_info], hint=hint) + # is None when body ends in a revert or return (also considers branches, so if it ends with an + # if where both branches return, it will also be none) + if init_calls_by_module is not None: + _validate_init_return(modules_to_initialize, constructor, init_calls_by_module) def _validate_exports_uses(module_ast: vy_ast.Module, module_t: ModuleT) -> None: From 1858023efe566e538842573b948a63702167c6ff Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Tue, 16 Jun 2026 10:23:54 +0200 Subject: [PATCH 19/29] Add asymmetric return test --- .../syntax/modules/test_initializers.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/functional/syntax/modules/test_initializers.py b/tests/functional/syntax/modules/test_initializers.py index 8a2f7fe09e..c9b2d53215 100644 --- a/tests/functional/syntax/modules/test_initializers.py +++ b/tests/functional/syntax/modules/test_initializers.py @@ -2670,6 +2670,24 @@ def __init__(cond: bool): assert e.value._message == "not initialized!" +def test_return_in_then_without_else_init_after(make_input_bundle): + main = """ +import lib1 + +initializes: lib1 + +@deploy +def __init__(): + if True: + return + lib1.__init__() + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1}) + with pytest.raises(InitializerException) as e: + compile_code(main, input_bundle=input_bundle) + assert e.value._message == "not initialized!" + + def test_return_after_if_validates_total(make_input_bundle): main = """ import lib1 From 2f2fd4ad0e1bbd578b18c11519763bc2dc731657 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Wed, 17 Jun 2026 13:13:49 +0200 Subject: [PATCH 20/29] Remove quotes in types --- vyper/semantics/types/module.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/vyper/semantics/types/module.py b/vyper/semantics/types/module.py index 801279a498..b5626d3c01 100644 --- a/vyper/semantics/types/module.py +++ b/vyper/semantics/types/module.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from functools import cached_property from typing import TYPE_CHECKING, Optional @@ -96,7 +98,7 @@ def _try_fold(self, node): return node # when using the type itself (not an instance) in the call position - def _ctor_call_return(self, node: vy_ast.Call) -> "InterfaceT": + def _ctor_call_return(self, node: vy_ast.Call) -> InterfaceT: self._ctor_arg_types(node) return self @@ -158,7 +160,7 @@ def _from_lists( event_list: Optional[list[tuple[str, EventT]]] = None, struct_list: Optional[list[tuple[str, StructT]]] = None, flag_list: Optional[list[tuple[str, FlagT]]] = None, - ) -> "InterfaceT": + ) -> InterfaceT: functions: dict[str, ContractFunctionT] = {} events: dict[str, EventT] = {} structs: dict[str, StructT] = {} @@ -189,7 +191,7 @@ def _process(dst_dict, items): return cls(interface_name, decl_node, functions, events, structs, flags) @classmethod - def from_json_abi(cls, name: str, abi: dict) -> "InterfaceT": + def from_json_abi(cls, name: str, abi: dict) -> InterfaceT: """ Generate an `InterfaceT` object from an ABI. @@ -254,7 +256,7 @@ def _dedup_default_arg_overloads(cls, abi: dict) -> list: return [(fn_name, fn) for fn_name, fn in parsed_fns.items()] @classmethod - def from_ModuleT(cls, module_t: "ModuleT") -> "InterfaceT": + def from_ModuleT(cls, module_t: ModuleT) -> InterfaceT: """ Generate an `InterfaceT` object from a Vyper ast node. @@ -282,7 +284,7 @@ def from_ModuleT(cls, module_t: "ModuleT") -> "InterfaceT": return cls._from_lists(module_t._id, module_t.decl_node, funcs, events, structs, flags) @classmethod - def from_InterfaceDef(cls, node: vy_ast.InterfaceDef) -> "InterfaceT": + def from_InterfaceDef(cls, node: vy_ast.InterfaceDef) -> InterfaceT: functions = [] for func_ast in node.body: if not isinstance(func_ast, vy_ast.FunctionDef): @@ -407,7 +409,7 @@ def __hash__(self): def decl_node(self) -> Optional[vy_ast.VyperNode]: # type: ignore[override] return self._module - def get_type_member(self, key: str, node: vy_ast.VyperNode) -> "VyperType": + def get_type_member(self, key: str, node: vy_ast.VyperNode) -> VyperType: return self._helper.get_member(key, node) @cached_property @@ -467,7 +469,7 @@ def import_stmts(self): return self._module.get_children((vy_ast.Import, vy_ast.ImportFrom)) @cached_property - def imported_modules(self) -> dict[str, "ModuleInfo"]: + def imported_modules(self) -> dict[str, ModuleInfo]: ret = {} for s in self.import_stmts: for info in s._metadata["import_infos"]: @@ -477,7 +479,7 @@ def imported_modules(self) -> dict[str, "ModuleInfo"]: ret[info.alias] = module_info return ret - def find_module_info(self, needle: "ModuleT") -> Optional["ModuleInfo"]: + def find_module_info(self, needle: ModuleT) -> Optional[ModuleInfo]: for s in self.imported_modules.values(): if s.module_t == needle: return s @@ -500,7 +502,7 @@ def exports_decls(self): return self._module.get_children(vy_ast.ExportsDecl) @cached_property - def used_modules(self) -> list["ModuleInfo"]: + def used_modules(self) -> list[ModuleInfo]: # modules which are written to ret = [] for node in self.uses_decls: From eaa1f373672b4293f073a1dd3f723e66b16de6b7 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Wed, 17 Jun 2026 13:14:51 +0200 Subject: [PATCH 21/29] Remove docstring on variable --- vyper/semantics/analysis/module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vyper/semantics/analysis/module.py b/vyper/semantics/analysis/module.py index 6c0308f9ab..56e0ead21a 100644 --- a/vyper/semantics/analysis/module.py +++ b/vyper/semantics/analysis/module.py @@ -271,8 +271,8 @@ def _validate_init_calls( # Make a copy so that branches do not interfere init_calls = defaultdict(list, {k: v.copy() for k, v in init_calls.items()}) + # Subset of init_calls that happen in this block local_init_calls: dict[ModuleInfo, list[vy_ast.VyperNode]] = defaultdict(list) - """Subset of init_calls that happen in this block""" for node in block: From 0e82a4f572aaa5d9849b521fdbc5ada0f937a9ea Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Wed, 17 Jun 2026 14:52:19 +0200 Subject: [PATCH 22/29] Simplify handling of absent constructor --- vyper/semantics/analysis/module.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/vyper/semantics/analysis/module.py b/vyper/semantics/analysis/module.py index 56e0ead21a..2107ca50bb 100644 --- a/vyper/semantics/analysis/module.py +++ b/vyper/semantics/analysis/module.py @@ -240,7 +240,7 @@ def _validate_init_return( def _validate_init_calls( block: list[vy_ast.VyperNode], modules_to_initialize: list[ModuleInfo], - constructor: ContractFunctionT | None, + constructor: ContractFunctionT, init_calls: dict[ModuleInfo, list[vy_ast.VyperNode]], ) -> dict[ModuleInfo, list[vy_ast.VyperNode]] | None: """ @@ -409,21 +409,20 @@ def _validate_initialized_modules(module_ast: vy_ast.Module, module_t: ModuleT) constructor = module_t.init_function - if constructor is not None: + if constructor is None: + # Checks that no modules need initialization (equivalent to init with empty body) + _validate_init_return(modules_to_initialize, constructor=None, init_calls_by_module={}) + else: assert isinstance(constructor.ast_def, vy_ast.FunctionDef) # help mypy body = constructor.ast_def.body - else: - body = [] - - init_calls_by_module = _validate_init_calls( - body, modules_to_initialize, constructor, defaultdict(list) - ) + init_calls_by_module = _validate_init_calls( + body, modules_to_initialize, constructor, defaultdict(list) + ) - # is None when body ends in a revert or return (also considers branches, so if it ends with an - # if where both branches return, it will also be none) - if init_calls_by_module is not None: - _validate_init_return(modules_to_initialize, constructor, init_calls_by_module) + # return will have already checked, and revert shouldn't check, so in both cases: skip it + if init_calls_by_module is not None: + _validate_init_return(modules_to_initialize, constructor, init_calls_by_module) def _validate_exports_uses(module_ast: vy_ast.Module, module_t: ModuleT) -> None: From 201beee8e30ceb68becaf21cbbc543526cb9345b Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Thu, 18 Jun 2026 10:31:04 +0200 Subject: [PATCH 23/29] Reduce nesting by moving to a node visitor --- vyper/semantics/analysis/module.py | 248 ++++++++++++++--------------- 1 file changed, 123 insertions(+), 125 deletions(-) diff --git a/vyper/semantics/analysis/module.py b/vyper/semantics/analysis/module.py index 2107ca50bb..e094259339 100644 --- a/vyper/semantics/analysis/module.py +++ b/vyper/semantics/analysis/module.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections import defaultdict -from itertools import zip_longest +from itertools import chain, zip_longest from typing import Optional from vyper import ast as vy_ast @@ -237,156 +237,154 @@ def _validate_init_return( raise InitializerException(msg, *init_calls_by_module[module_info], hint=hint) -def _validate_init_calls( - block: list[vy_ast.VyperNode], - modules_to_initialize: list[ModuleInfo], - constructor: ContractFunctionT, - init_calls: dict[ModuleInfo, list[vy_ast.VyperNode]], -) -> dict[ModuleInfo, list[vy_ast.VyperNode]] | None: - """ - Check a block for wether it calls all required __init__() methods, and in the right order. - (Dependencies must be initialized before dependents.) - Reverting acts as a wildcard with respects to initialization: when a branch contains a revert, - we count it as initializing whatever the other branch also initialized. - - Args: - block: - the current block, a list of nodes to analyze - modules_to_initialize: - list of the modules the current module `initializes:` and which have a constructor - Should only be used by _validate_init_return - constructor: - the constructor of the current module, should only be used by _validate_init_return - init_calls: - a dict from: - * Modules which need to be initialized, to - * Nodes in the containing scope which initialize it - (There can be more than one because of branching) - - Returns: - Which modules are initialized *in this block* (for example a single branch of an if), - or None if there is a revert. - """ +class ConstructorValidator(VyperNodeVisitorBase): + modules_to_initialize: list[ModuleInfo] + constructor: ContractFunctionT - # Make a copy so that branches do not interfere - init_calls = defaultdict(list, {k: v.copy() for k, v in init_calls.items()}) + def __init__(self, modules_to_initialize: list[ModuleInfo], constructor: ContractFunctionT): + self.modules_to_initialize = modules_to_initialize + self.constructor = constructor - # Subset of init_calls that happen in this block - local_init_calls: dict[ModuleInfo, list[vy_ast.VyperNode]] = defaultdict(list) + def visit_block( + self, block: list[vy_ast.VyperNode], init_calls: dict[ModuleInfo, list[vy_ast.VyperNode]] + ) -> dict[ModuleInfo, list[vy_ast.VyperNode]] | None: + # Make a copy so that branches do not interfere + init_calls = defaultdict(list, {k: v.copy() for k, v in init_calls.items()}) - for node in block: + # Subset of init_calls that happen in this block + local_init_calls: dict[ModuleInfo, list[vy_ast.VyperNode]] = defaultdict(list) - if isinstance(node, vy_ast.Raise): - # If we raise, return the wildcard - return None + for node in block: + node_init_calls = self.visit(node, init_calls) + if node_init_calls is None: + # Return or Raise, terminates block with wildcard + return None + for module_info, calls in node_init_calls.items(): + init_calls[module_info] += calls + local_init_calls[module_info] += calls - elif isinstance(node, vy_ast.Return): - _validate_init_return(modules_to_initialize, constructor, init_calls) - return None + return local_init_calls - elif isinstance(node, vy_ast.If): - then_nodes = _validate_init_calls( - node.body, modules_to_initialize, constructor, init_calls - ) - else_nodes = _validate_init_calls( - node.orelse, modules_to_initialize, constructor, init_calls - ) + def visit_Raise(self, _: vy_ast.Raise, init_calls: dict[ModuleInfo, list[vy_ast.VyperNode]]): + # If we raise, return wildcard + return None - if then_nodes is None and else_nodes is None: - # Both branches revert, the block as a whole reverts - return None - elif then_nodes is None: - # then-branch reverts, use the initializations from the else-branch - assert else_nodes is not None # help mypy - for module_info in else_nodes: - init_calls[module_info] += else_nodes[module_info] - local_init_calls[module_info] += else_nodes[module_info] - continue - elif else_nodes is None: - assert then_nodes is not None # help mypy - # else-branch reverts, use the initializations from the then-branch - for module_info in then_nodes: - init_calls[module_info] += then_nodes[module_info] - local_init_calls[module_info] += then_nodes[module_info] - continue - assert then_nodes is not None and else_nodes is not None # help mypy - # TODO: UX: instead of raising on the first, batch them all together - for module_info in {**then_nodes, **else_nodes}: - if bool(then_nodes[module_info]) != bool(else_nodes[module_info]): - msg = f"`{module_info.alias}`.__init__() is not guaranteed to be reachable: " - msg += "present only in a single branch of an if" - raise InitializerException(msg, node) - else: - # If the context and the branches had init calls, - # then we would already have errored: "__init__() function was already called!" - assert init_calls[module_info] == [] or ( - then_nodes[module_info] == [] and else_nodes[module_info] == [] - ) + def visit_Return(self, _: vy_ast.Return, init_calls: dict[ModuleInfo, list[vy_ast.VyperNode]]): + # If we return, return wildcard + # Instead, move _validate_init_return inside ConstructorValidator + _validate_init_return(self.modules_to_initialize, self.constructor, init_calls) + return None + + def visit_If(self, node: vy_ast.If, init_calls: dict[ModuleInfo, list[vy_ast.VyperNode]]): + then_nodes = self.visit_block(node.body, init_calls) + else_nodes = self.visit_block(node.orelse, init_calls) + + if then_nodes is None or else_nodes is None: + # If either branch reverts/returns, return the other + # (if both revert, will return None) + return then_nodes or else_nodes - both_branches = then_nodes[module_info] + else_nodes[module_info] + assert then_nodes is not None and else_nodes is not None # help mypy - init_calls[module_info] += both_branches - local_init_calls[module_info] += both_branches + # TODO: UX: instead of raising on the first, batch them all together + for module_info in {**then_nodes, **else_nodes}: + if bool(then_nodes[module_info]) != bool(else_nodes[module_info]): + msg = f"`{module_info.alias}`.__init__() is not guaranteed to be reachable: " + msg += "present only in a single branch of an if" + raise InitializerException(msg, node) - elif isinstance(node, vy_ast.For): - loop_nodes = _validate_init_calls( - node.body, modules_to_initialize, constructor, init_calls + # If the context and the branches had init calls, + # then we would already have errored: "__init__() function was already called!" + assert init_calls[module_info] == [] or ( + then_nodes[module_info] == [] and else_nodes[module_info] == [] ) - if loop_nodes is None: - # raise/return in the body of loop is not necessarily reachable - continue + + merged: dict[ModuleInfo, list[vy_ast.VyperNode]] = defaultdict(list) + for module_info, calls in chain(then_nodes.items(), else_nodes.items()): + merged[module_info] += calls + return merged + + def visit_For(self, node: vy_ast.For, init_calls: dict[ModuleInfo, list[vy_ast.VyperNode]]): + loop_nodes = self.visit_block(node.body, init_calls) + if loop_nodes is not None: for module_info in loop_nodes: if len(loop_nodes[module_info]) != 0: msg = f"`{module_info.alias}`.__init__() is not guaranteed to be reachable: " msg += "present in a for loop" raise InitializerException(msg, node) + # Note: the above is more fine-grained than simply forbidding init calls in a for loop, + # it allows the following: + # def __init__(xs): + # for x in xs: + # if is_valid(x): + # lib.__init__(x) + # return + # raise "No valid x in xs" + + return {} + + def _validate_call( + self, call: vy_ast.Call, init_calls: dict[ModuleInfo, list[vy_ast.VyperNode]] + ) -> ModuleInfo | None: + # Not reached through normal traversal (`visit` dispatch), + # always called from visit_VyperNode + + other_module_info = _extract_init_call(call) + + if other_module_info is None: + # Not an init call, nothing to do + return None - else: - # Regular, non-branching node - for call in node.get_descendants(vy_ast.Call): + init_calls_m = init_calls[other_module_info] - other_module_info = _extract_init_call(call) + if len(init_calls_m) != 0: + msg = f"tried to initialize `{other_module_info.alias}`, " + msg += "but its __init__() function was already called!" + raise InitializerException(msg, call.func, init_calls_m) - if other_module_info is None: - # Not an init call, nothing to do - continue + # If A uses B, make sure B.__init__ is called before A.__init__ - init_calls_m = init_calls[other_module_info] + uninitialized_dependents: list[str] = [] + """ + Modules which the other module initializes, but whose init are not called beforehand + """ - if len(init_calls_m) != 0: - msg = f"tried to initialize `{other_module_info.alias}`, " - msg += "but its __init__() function was already called!" - raise InitializerException(msg, call.func, init_calls_m) + for dependent in other_module_info.module_t.used_modules: + if dependent.module_t.init_function is None: + # no constructor to check + continue - # If A uses B, make sure B.__init__ is called before A.__init__ + dependent_init_calls = init_calls[dependent] + if len(dependent_init_calls) == 0: + uninitialized_dependents.append(dependent.alias) - uninitialized_dependents: list[str] = [] - """ - Modules which the other module initializes, but whose init are not called beforehand - """ + if len(uninitialized_dependents) != 0: + msg = f"tried to initialize `{other_module_info.alias}`, " + msg += "but it depends on the following modules " + msg += "which have not been initialized: " + ", ".join(uninitialized_dependents) + hint = "call their `__init__()` methods before " + hint += f"`{other_module_info.alias}.__init__()`." + raise InitializerException(msg, call.func, init_calls, hint=hint) + + return other_module_info + + def visit_VyperNode( + self, node: vy_ast.VyperNode, init_calls: dict[ModuleInfo, list[vy_ast.VyperNode]] + ): + # Regular, non-branching node - for dependent in other_module_info.module_t.used_modules: - if dependent.module_t.init_function is None: - # no constructor to check - continue + ret: dict[ModuleInfo, list[vy_ast.VyperNode]] = {} - dependent_init_calls = init_calls[dependent] - if len(dependent_init_calls) == 0: - uninitialized_dependents.append(dependent.alias) + for call in node.get_descendants(vy_ast.Call): + initialized_module_info = self._validate_call(call, init_calls) + if initialized_module_info is not None: - if len(uninitialized_dependents) != 0: - msg = f"tried to initialize `{other_module_info.alias}`, " - msg += "but it depends on the following modules " - msg += "which have not been initialized: " + ", ".join(uninitialized_dependents) - hint = "call their `__init__()` methods before " - hint += f"`{other_module_info.alias}.__init__()`." - raise InitializerException(msg, call.func, init_calls, hint=hint) + # There should always be at most one init call per statement! + assert not ret - init_calls_m.append(call) - local_init_calls[other_module_info].append(call) + ret[initialized_module_info] = [call] - # Only return the local init calls, this simplifies the branching logic - return local_init_calls + return ret def _validate_initialized_modules(module_ast: vy_ast.Module, module_t: ModuleT) -> None: @@ -416,8 +414,8 @@ def _validate_initialized_modules(module_ast: vy_ast.Module, module_t: ModuleT) assert isinstance(constructor.ast_def, vy_ast.FunctionDef) # help mypy body = constructor.ast_def.body - init_calls_by_module = _validate_init_calls( - body, modules_to_initialize, constructor, defaultdict(list) + init_calls_by_module = ConstructorValidator(modules_to_initialize, constructor).visit_block( + body, defaultdict(list) ) # return will have already checked, and revert shouldn't check, so in both cases: skip it From 4b71aebc9b901f9260e817cb08568063169d224b Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Thu, 18 Jun 2026 11:23:03 +0200 Subject: [PATCH 24/29] Move all validation logic inside the node visitor --- vyper/semantics/analysis/module.py | 141 ++++++++++++++--------------- 1 file changed, 68 insertions(+), 73 deletions(-) diff --git a/vyper/semantics/analysis/module.py b/vyper/semantics/analysis/module.py index e094259339..f98b57168a 100644 --- a/vyper/semantics/analysis/module.py +++ b/vyper/semantics/analysis/module.py @@ -138,7 +138,7 @@ def _analyze_module_bodies(module_ast: vy_ast.Module) -> None: with override_global_namespace(namespace): analyze_functions(module_ast) _validate_exports_uses(module_ast, module_t) - _validate_initialized_modules(module_ast, module_t) + ConstructorValidator(module_t).validate() _validate_used_modules(module_ast, module_t) @@ -203,47 +203,78 @@ def _extract_init_call(node: vy_ast.Call) -> ModuleInfo | None: return node.func.value._expr_info.module_info # type: ignore -def _validate_init_return( - modules_to_initialize: list[ModuleInfo], - constructor: ContractFunctionT | None, - init_calls_by_module: dict[ModuleInfo, list[vy_ast.VyperNode]], -) -> None: +class ConstructorValidator(VyperNodeVisitorBase): """ - Checks that the correct modules have their init called when returning from the constructor + Check all `initializes:`-ed modules each have `__init__()` executed exactly once. + Also checks that the initializes calls are done in the correct order, + if a uses b, init of b is called before init of a. + + This check handles branching by requiring the set of initialized modules to be + the same in both branches. + (If one branch raises, we act as if it initialized all necessary modules.) """ - # grab the init function AST node for error message - # (it could be None, it's ok since it's just for diagnostics) - init_func_node = constructor.decl_node if constructor is not None else None - - err_list = ExceptionList() - for module_info in modules_to_initialize: - init_calls = init_calls_by_module.get(module_info, []) - if len(init_calls) == 0: - msg = "not initialized!" - hint = f"add `{module_info.alias}.__init__()` to " - hint += "your `__init__()` function" - - err_list.append( - InitializerException(msg, init_func_node, module_info.ownership_decl, hint=hint) - ) - err_list.raise_if_not_empty() - for module_info in init_calls_by_module: - if module_info not in modules_to_initialize: - msg = f"tried to initialize `{module_info.alias}`, " - msg += "but it is not in initializer list!" - hint = f"add `initializes: {module_info.alias}` " - hint += "as a top-level statement to your contract" - raise InitializerException(msg, *init_calls_by_module[module_info], hint=hint) + modules_to_initialize: list[ModuleInfo] + constructor: vy_ast.FunctionDef | None + def __init__(self, module_t: ModuleT): -class ConstructorValidator(VyperNodeVisitorBase): - modules_to_initialize: list[ModuleInfo] - constructor: ContractFunctionT + self.modules_to_initialize = [ + t.module_info + for t in module_t.initialized_modules + if t.module_info.module_t.init_function is not None + ] + + init_fun = module_t.init_function + if init_fun is None: + self.constructor = None + else: + assert isinstance(init_fun.ast_def, vy_ast.FunctionDef) # help mypy + + self.constructor = init_fun.ast_def + + def validate(self): + if self.constructor is not None: + body = self.constructor.body + else: + body = [] + + init_calls_by_module = self.visit_block(body, defaultdict(list)) + + # is None when body ends in a revert or return (also considers branches, + # so if it ends with an 'if' where both branches return, it will also be none) + if init_calls_by_module is not None: + self._validate_init_return(init_calls_by_module) + + def _validate_init_return( + self, init_calls_by_module: dict[ModuleInfo, list[vy_ast.VyperNode]] + ) -> None: + """ + Checks that the correct modules have their init called when returning from the constructor + """ - def __init__(self, modules_to_initialize: list[ModuleInfo], constructor: ContractFunctionT): - self.modules_to_initialize = modules_to_initialize - self.constructor = constructor + err_list = ExceptionList() + for module_info in self.modules_to_initialize: + init_calls = init_calls_by_module.get(module_info, []) + if len(init_calls) == 0: + msg = "not initialized!" + hint = f"add `{module_info.alias}.__init__()` to " + hint += "your `__init__()` function" + + err_list.append( + InitializerException( + msg, self.constructor, module_info.ownership_decl, hint=hint + ) + ) + err_list.raise_if_not_empty() + + for module_info in init_calls_by_module: + if module_info not in self.modules_to_initialize: + msg = f"tried to initialize `{module_info.alias}`, " + msg += "but it is not in initializer list!" + hint = f"add `initializes: {module_info.alias}` " + hint += "as a top-level statement to your contract" + raise InitializerException(msg, *init_calls_by_module[module_info], hint=hint) def visit_block( self, block: list[vy_ast.VyperNode], init_calls: dict[ModuleInfo, list[vy_ast.VyperNode]] @@ -272,7 +303,7 @@ def visit_Raise(self, _: vy_ast.Raise, init_calls: dict[ModuleInfo, list[vy_ast. def visit_Return(self, _: vy_ast.Return, init_calls: dict[ModuleInfo, list[vy_ast.VyperNode]]): # If we return, return wildcard # Instead, move _validate_init_return inside ConstructorValidator - _validate_init_return(self.modules_to_initialize, self.constructor, init_calls) + self._validate_init_return(init_calls) return None def visit_If(self, node: vy_ast.If, init_calls: dict[ModuleInfo, list[vy_ast.VyperNode]]): @@ -387,42 +418,6 @@ def visit_VyperNode( return ret -def _validate_initialized_modules(module_ast: vy_ast.Module, module_t: ModuleT) -> None: - """ - Check all `initializes:` modules each have `__init__()` executed exactly once. - Also checks that the initializes calls are done in the correct order, - if a uses b, init of b is called before init of a. - - This check handles branching by requiring the set of initialized modules to be - the same in both branches. - (If one branch raises, we act as if it initialized all necessary modules.) - """ - # only call `__init__()` for modules which have an - # `__init__()` function - modules_to_initialize: list[ModuleInfo] = [ - t.module_info - for t in module_t.initialized_modules - if t.module_info.module_t.init_function is not None - ] - - constructor = module_t.init_function - - if constructor is None: - # Checks that no modules need initialization (equivalent to init with empty body) - _validate_init_return(modules_to_initialize, constructor=None, init_calls_by_module={}) - else: - assert isinstance(constructor.ast_def, vy_ast.FunctionDef) # help mypy - - body = constructor.ast_def.body - init_calls_by_module = ConstructorValidator(modules_to_initialize, constructor).visit_block( - body, defaultdict(list) - ) - - # return will have already checked, and revert shouldn't check, so in both cases: skip it - if init_calls_by_module is not None: - _validate_init_return(modules_to_initialize, constructor, init_calls_by_module) - - def _validate_exports_uses(module_ast: vy_ast.Module, module_t: ModuleT) -> None: """ Check that exported functions that use state have proper `uses:` declarations. From cef8cdff2750d3c9dc10fcd85272ddeff2e416d5 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Thu, 18 Jun 2026 11:33:17 +0200 Subject: [PATCH 25/29] Inline _extract_init_call --- vyper/semantics/analysis/module.py | 43 ++++++++++++------------------ 1 file changed, 17 insertions(+), 26 deletions(-) diff --git a/vyper/semantics/analysis/module.py b/vyper/semantics/analysis/module.py index f98b57168a..2ab94ddae9 100644 --- a/vyper/semantics/analysis/module.py +++ b/vyper/semantics/analysis/module.py @@ -182,27 +182,6 @@ def _validate_used_modules(module_ast: vy_ast.Module, module_t: ModuleT) -> None err_list.raise_if_not_empty() -def _extract_init_call(node: vy_ast.Call) -> ModuleInfo | None: - - expr_info = node.func._expr_info - - if expr_info is None: - # this can happen for range() calls; CMC 2024-02-05 try to - # refactor so that range() is properly tagged. - return None - - call_t = expr_info.typ - - if not isinstance(call_t, ContractFunctionT): - return None - - if not call_t.is_constructor: - return None - - # XXX: check this works as expected for nested attributes - return node.func.value._expr_info.module_info # type: ignore - - class ConstructorValidator(VyperNodeVisitorBase): """ Check all `initializes:`-ed modules each have `__init__()` executed exactly once. @@ -357,15 +336,27 @@ def visit_For(self, node: vy_ast.For, init_calls: dict[ModuleInfo, list[vy_ast.V def _validate_call( self, call: vy_ast.Call, init_calls: dict[ModuleInfo, list[vy_ast.VyperNode]] ) -> ModuleInfo | None: - # Not reached through normal traversal (`visit` dispatch), - # always called from visit_VyperNode - other_module_info = _extract_init_call(call) + expr_info = call.func._expr_info + + if expr_info is None: + # this can happen for range() calls; CMC 2024-02-05 try to + # refactor so that range() is properly tagged. + return None + + call_t = expr_info.typ + + if not isinstance(call_t, ContractFunctionT): + return None - if other_module_info is None: - # Not an init call, nothing to do + if not call_t.is_constructor: return None + # XXX: check this works as expected for nested attributes + other_module_info = call.func.value._expr_info.module_info # type: ignore + + # If A.__init__ is called, make sure it was not called before + init_calls_m = init_calls[other_module_info] if len(init_calls_m) != 0: From e765e895271eda2ffc36c9b61e2d3be142b56eb4 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Thu, 18 Jun 2026 11:38:02 +0200 Subject: [PATCH 26/29] Make lint --- vyper/semantics/analysis/module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vyper/semantics/analysis/module.py b/vyper/semantics/analysis/module.py index 2ab94ddae9..32e872865a 100644 --- a/vyper/semantics/analysis/module.py +++ b/vyper/semantics/analysis/module.py @@ -356,7 +356,7 @@ def _validate_call( other_module_info = call.func.value._expr_info.module_info # type: ignore # If A.__init__ is called, make sure it was not called before - + init_calls_m = init_calls[other_module_info] if len(init_calls_m) != 0: From adeb3e46bf855a0c81a72b2c6bc2843f7cf6f816 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Thu, 18 Jun 2026 11:55:32 +0200 Subject: [PATCH 27/29] Add loop test --- .../syntax/modules/test_initializers.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/functional/syntax/modules/test_initializers.py b/tests/functional/syntax/modules/test_initializers.py index c9b2d53215..bbacb47462 100644 --- a/tests/functional/syntax/modules/test_initializers.py +++ b/tests/functional/syntax/modules/test_initializers.py @@ -2833,3 +2833,28 @@ def __init__(cond: bool): """ input_bundle = make_input_bundle({"lib1.vy": _LIB1, "lib2.vy": _LIB2_USES_LIB1}) assert compile_code(main, input_bundle=input_bundle) is not None + + +def test_for_loop_with_guarded_init_and_return_then_raise(make_input_bundle): + lib = """ +counter: uint256 + +@deploy +def __init__(x: uint256): + self.counter = x + """ + main = """ +import lib + +initializes: lib + +@deploy +def __init__(xs: DynArray[uint256, 10]): + for x: uint256 in xs: + if x > 0: + lib.__init__(x) + return + raise "no valid x in xs" + """ + input_bundle = make_input_bundle({"lib.vy": lib}) + assert compile_code(main, input_bundle=input_bundle) is not None From fb3eecce125c964e37df1a34487582a6e3390019 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Thu, 9 Jul 2026 12:08:38 +0200 Subject: [PATCH 28/29] Fix faulty logic --- .../syntax/modules/test_initializers.py | 19 +++++++++++++++++++ vyper/semantics/analysis/module.py | 10 ++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/tests/functional/syntax/modules/test_initializers.py b/tests/functional/syntax/modules/test_initializers.py index 54d5b47fac..1ddf6c99cb 100644 --- a/tests/functional/syntax/modules/test_initializers.py +++ b/tests/functional/syntax/modules/test_initializers.py @@ -2328,6 +2328,25 @@ def __init__(cond: bool): assert compile_code(main, input_bundle=input_bundle) is not None +def test_no_init_in_then_raise_in_else(make_input_bundle): + main = """ +import lib1 + +initializes: lib1 + +@deploy +def __init__(cond: bool): + if cond: + pass + else: + raise "nope" + """ + input_bundle = make_input_bundle({"lib1.vy": _LIB1}) + with pytest.raises(InitializerException) as e: + compile_code(main, input_bundle=input_bundle) + assert e.value._message == "not initialized!" + + def test_both_branches_raise_with_initializer(make_input_bundle): main = """ import lib1 diff --git a/vyper/semantics/analysis/module.py b/vyper/semantics/analysis/module.py index b945f11a80..bb39a1999d 100644 --- a/vyper/semantics/analysis/module.py +++ b/vyper/semantics/analysis/module.py @@ -300,10 +300,12 @@ def visit_If(self, node: vy_ast.If, init_calls: dict[ModuleInfo, list[vy_ast.Vyp then_nodes = self.visit_block(node.body, init_calls) else_nodes = self.visit_block(node.orelse, init_calls) - if then_nodes is None or else_nodes is None: - # If either branch reverts/returns, return the other - # (if both revert, will return None) - return then_nodes or else_nodes + # If either branch reverts/returns, return the other + # (if both revert, will return None) + if then_nodes is None: + return else_nodes + elif else_nodes is None: + return then_nodes assert then_nodes is not None and else_nodes is not None # help mypy From c7619cd6c6d1db330899048569b7eab6cab2cb9c Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Thu, 9 Jul 2026 15:01:34 +0200 Subject: [PATCH 29/29] Remove now-redundant "help mypy" assert --- vyper/semantics/analysis/module.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/vyper/semantics/analysis/module.py b/vyper/semantics/analysis/module.py index bb39a1999d..5b134d160b 100644 --- a/vyper/semantics/analysis/module.py +++ b/vyper/semantics/analysis/module.py @@ -307,8 +307,6 @@ def visit_If(self, node: vy_ast.If, init_calls: dict[ModuleInfo, list[vy_ast.Vyp elif else_nodes is None: return then_nodes - assert then_nodes is not None and else_nodes is not None # help mypy - # TODO: UX: instead of raising on the first, batch them all together for module_info in {**then_nodes, **else_nodes}: if bool(then_nodes[module_info]) != bool(else_nodes[module_info]):