From 417f48b55126f160dc40a9203e647b36e432aa92 Mon Sep 17 00:00:00 2001 From: danceratopz Date: Tue, 9 Jun 2026 10:35:47 +0200 Subject: [PATCH] feat(spec-tool): enforce `@final` on leaf dataclasses Add a `FinalDecoratorHygiene` lint rule that flags any leaf dataclass (a `@dataclass` or `@slotted_freezable` class never used as a base) that is missing `@final`. Marking leaf dataclasses `@final` lets `mypyc` bypass the vtable for method calls and property accessors. The rule scans the whole specification once at the first fork position: every fork's modules plus the shared modules such as `ethereum.state` and `ethereum.trace`, including each package's `__init__.py`. Register the rule in `vulture_whitelist.py` since lints are discovered dynamically. --- .../lint/lints/final_decorator.py | 147 ++++++++++++++++++ vulture_whitelist.py | 6 + 2 files changed, 153 insertions(+) create mode 100644 src/ethereum_spec_tools/lint/lints/final_decorator.py diff --git a/src/ethereum_spec_tools/lint/lints/final_decorator.py b/src/ethereum_spec_tools/lint/lints/final_decorator.py new file mode 100644 index 00000000000..182500835aa --- /dev/null +++ b/src/ethereum_spec_tools/lint/lints/final_decorator.py @@ -0,0 +1,147 @@ +""" +Final Decorator Lint. + +Ensures that leaf dataclasses are decorated with `@final`. +""" + +import ast +import importlib +import inspect +import pkgutil +from typing import Generator, List, Optional, Sequence, Set, Tuple + +from ethereum_spec_tools.forks import Hardfork +from ethereum_spec_tools.lint import Diagnostic, Lint + +DATACLASS_DECORATORS = {"dataclass", "slotted_freezable"} +FINAL_DECORATOR = "final" + + +def _spec_sources( + forks: List[Hardfork], +) -> Generator[Tuple[str, str], None, None]: + """ + Yield the name and source of every module in the specification. + + This spans each fork's modules as well as the shared, fork-independent + modules such as `ethereum.state` and `ethereum.trace`. The fork packages + are walked individually because `ethereum.forks` is a namespace package + that `walk_packages` does not descend into from the `ethereum` root. The + package roots are listed explicitly because `walk_packages` never yields + the package it is walking, so their `__init__.py` files would otherwise + be skipped. + """ + names: List[str] = ["ethereum"] + for fork in forks: + names.append(fork.name) + names += [mod_info.name for mod_info in fork.walk_packages()] + + root = importlib.import_module("ethereum") + names += [ + mod_info.name + for mod_info in pkgutil.walk_packages(root.__path__, "ethereum.") + ] + + for name in names: + mod = importlib.import_module(name) + yield mod.__name__, inspect.getsource(mod) + + +class FinalDecoratorHygiene(Lint): + """ + Ensure that every leaf dataclass is decorated with `@final`. + + A *leaf* class is one that is never used as a base class anywhere in the + specification. Marking such classes `@final` lets `mypyc` bypass the + vtable for method calls and property accessors. Classes that are + subclassed are skipped (they are not leaves), as are non-dataclass types + such as enums, protocols, exceptions, and constant namespaces. + """ + + def lint( + self, forks: List[Hardfork], position: int + ) -> Sequence[Diagnostic]: + """ + Flag leaf dataclasses that are missing `@final`. + + The check spans every fork and the shared modules at once, so it only + does work at the first position. + """ + if position != 0: + return [] + + bases: Set[str] = set() + candidates: List[Tuple[str, int, str]] = [] + for name, source in _spec_sources(forks): + visitor = self._parse(source, _Visitor()) + bases |= visitor.bases + for lineno, class_name in visitor.undecorated: + candidates.append((name, lineno, class_name)) + + diagnostics: List[Diagnostic] = [] + for name, lineno, class_name in candidates: + if class_name in bases: + # The class is subclassed somewhere, so it is not a leaf. + continue + diagnostics.append( + Diagnostic( + message=( + f"`{class_name}` at line {lineno} in `{name}` is a " + "leaf dataclass and should be decorated with `@final`" + ) + ) + ) + + return diagnostics + + +def _name_of(node: ast.expr) -> Optional[str]: + """ + Return the bare name of a decorator or base class expression. + + Handles plain names (`final`), dotted names (`typing.final`), and calls + (`dataclass(frozen=True)`). Returns `None` for anything else, such as a + subscripted generic base. + """ + target = node.func if isinstance(node, ast.Call) else node + if isinstance(target, ast.Name): + return target.id + if isinstance(target, ast.Attribute): + return target.attr + return None + + +class _Visitor(ast.NodeVisitor): + """ + Collect base class names and dataclasses that lack `@final`. + """ + + bases: Set[str] + undecorated: List[Tuple[int, str]] + + def __init__(self) -> None: + self.bases = set() + self.undecorated = [] + + def visit_ClassDef(self, klass: ast.ClassDef) -> None: + """ + Visit a class definition. + """ + for base in klass.bases: + base_name = _name_of(base) + if base_name is not None: + self.bases.add(base_name) + + decorators: Set[str] = set() + for decorator in klass.decorator_list: + decorator_name = _name_of(decorator) + if decorator_name is not None: + decorators.add(decorator_name) + + if ( + decorators & DATACLASS_DECORATORS + and FINAL_DECORATOR not in decorators + ): + self.undecorated.append((klass.lineno, klass.name)) + + self.generic_visit(klass) diff --git a/vulture_whitelist.py b/vulture_whitelist.py index 6b045fb9e8b..1a663e745e5 100644 --- a/vulture_whitelist.py +++ b/vulture_whitelist.py @@ -24,6 +24,9 @@ FinalTrace, Trace, ) +from ethereum_spec_tools.lint.lints.final_decorator import ( + FinalDecoratorHygiene, +) from ethereum_spec_tools.lint.lints.glacier_forks_hygiene import ( GlacierForksHygiene, ) @@ -127,6 +130,9 @@ Trace.opName FinalTrace.gasUsed +# src/ethereum_spec_tools/lint/lints/final_decorator.py +FinalDecoratorHygiene + # src/ethereum_spec_tools/lint/lints/uint_len.py UintLenHygiene