refactor[lang]: validate convert() legality in the typechecker#5126
refactor[lang]: validate convert() legality in the typechecker#5126banteg wants to merge 12 commits into
Conversation
the venom-direct codegen for convert() forked the legacy conversion logic and dropped two kinds of knowledge in the port: the per-target input type allowlists (GH 5019, GH 5111) and the truncating (rather than floor) division used to compute the int->decimal clamp bounds (GH 5110). the result was a cluster of accepts-invalid bugs -- programs which legacy rejects with TypeMismatch compiled to bytecode -- and an off-by-one lower clamp which admits one extra negative value whose scaled result lands below the decimal type's lower bound, producing an out-of-range decimal at runtime. this is the third round of such fixes in this file (after GH 4987 and GH 5019); patching individual instances does not converge. instead, extract the conversion matrix and the numeric clamp bounds into a new backend-neutral module, vyper/builtins/_convert_rules.py, used by both pipelines: legacy's @_input_types decorator delegates to it, and venom's lower_convert() validates against it at dispatch before lowering. the checks the venom helpers had accreted piecemeal (GH 4987) are removed in favor of the single dispatch-time validation; value-dependent checks (literal ranges, literal bytestring downcasts) stay in codegen. the module deliberately imports only from vyper.semantics and vyper.utils so that either backend (and potentially the frontend typechecker, in the future) can use it without import cycles. note: the existing conversion matrix tests did not catch these divergences because several output formats (ir_dict, metadata, abi) run the legacy codegen even under --experimental-codegen, so the test harness's multi-format compilation lets legacy validation mask venom bugs. the new regression tests compile bytecode-only.
conversion legality is a language rule, but it was only enforced at codegen time, separately by each pipeline. call the shared validate_convertibility() from Convert.infer_arg_types so illegal conversions are rejected at semantic analysis for every backend. codegen-level validation stays in place as defense-in-depth, so the legacy pipeline is undisturbed. no semantics change -- only where the error is raised. programs that compile today keep compiling; illegal conversions now surface at typechecking (same exception types) instead of codegen, including for analysis-only consumers.
|
note that this doesn't touch the legacy pipeline as per charles ask, so it's redundant and it meant to demonstrate the shape for where things could more logically belong. this pr assumes a buy-in and some further careful cleanup work. |
Sporarum
left a comment
There was a problem hiding this comment.
Nice work !
To me the most important change is to move the check actually in the type checker, currently it looks like it was just moved to a shared helper
(I explain in more details in one of the comments)
|
Addressed in
Validation:
|
| # reject illegal conversion pairs at typechecking time, for all | ||
| # backends. codegen re-validates as defense-in-depth. | ||
| validate_convertibility(value_type, target_type, node) | ||
| self._validate_type_pair(value_type, target_type, node) |
There was a problem hiding this comment.
Since this validates the argument types, shouldn't it be in infer_arg_types ?
|
Addressed the fresh convert feedback in ba43a56. Changes: added a Convert class docstring explaining the target-to-source direction, moved exact FlagT <-> uint256 handling into the target-keyed source-type lookup, clarified that the Venom codegen path relies on typechecker rejection, and left an explicit comment at the infer_arg_types validation point. Focused validation: |
| allowed.extend(i for i in IntegerT.all() if i.bits <= target_type.m_bits) | ||
| if DecimalT().bits <= target_type.m_bits: | ||
| allowed.append(DecimalT) | ||
| allowed.append(BytesM_T) |
There was a problem hiding this comment.
Isn't there a restriction on which bytes<a> can convert to which other bytes<b> ?
There was a problem hiding this comment.
Current behavior has no size restriction for bytesM -> bytesN; can_convert() accepts any BytesM_T pair. Size restrictions remain for integer/decimal/address inputs to bytesM, and dynamic Bytes inputs are bounded through BytesT(target_type.m). 0ec16ba0e keeps that behavior and expresses the bytesM sources concretely with BytesM_T.all().
There was a problem hiding this comment.
This seems supported by can_convert in test_convert.py
(That's very weird, and should maybe be changed, but that is outside of the scope of this PR)
|
Please add a test that checks that |
|
Addressed in banteg/vyper@287a21415. Changes:
Validation:
|
|
@banteg merge conflict, i'm guessing due to the new flag->bytes32 rule |
# Conflicts: # vyper/builtins/_convert.py
|
Resolved in banteg/vyper@93d90c2f0 with a normal merge commit from current upstream The conflict was the upstream
Validation:
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 93d90c2f01
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if target_type == UINT256_T: | ||
| # flags only convert to uint256 | ||
| allowed += (FlagT,) | ||
| return allowed + (BytesT.any(),) |
There was a problem hiding this comment.
Reject oversized Bytes in scalar conversions
Allowing BytesT.any() here means Bytes[33] and larger sources pass the new typechecker for convert(x, uint256) (and the same pattern appears in the bool/decimal/address scalar branches), but the conversion code only loads the first 32-byte data word and shifts by (32 - len) * 8; with Venom the removed _check_bytes check lets an actual length of 33 underflow that shift and produce the wrong value instead of rejecting, while analysis-only compilation also no longer reports the TypeMismatch. Please keep the previous maxlen <= 32 restriction for dynamic bytes converted to scalar types.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 97fbdb0. Scalar conversions now accept dynamic Bytes only when the declared maximum length is at most 32 bytes. This restores the legacy safety boundary for address, integer, decimal, and bool targets; larger sources otherwise reach Venom with an underflowing shift and can return a wrong zero value. The contradictory permissive test is split into bounded-allowed and oversized-rejected cases, and the existing Bytes[33] rejection matrix now runs with both codegen settings.
Validation: uv run --python 3.12 -m pytest tests/functional/builtins/codegen/test_convert.py tests/functional/syntax/test_convert.py -m "not fuzzing" -q (11386 passed), plus focused Ruff, Black, and git diff --check.
|
|
||
| # Keep conversion legality in argument inference so callers cannot get | ||
| # a return type for an invalid source/target pair. | ||
| self._validate_type_pair(value_type, target_type, node) |
There was a problem hiding this comment.
Validate folded constants before rejecting conversions
Because this validates the source type from the original expression before looking at folded constants, constants are now rejected based on their declared type instead of the value that convert() codegen reduces to. For example, FOO: constant(uint256) = 1; return convert(FOO, bytes1) is rejected here as uint256 -> bytes1, even though the direct literal form is legal and the previous codegen path could fold the constant before applying the width check.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
I think this is a positive change, but should be documented
There was a problem hiding this comment.
Documented in 0c3807e. I added a code comment at the type-pair validation point and a syntax regression test showing that a typed constant is checked by its declared type before codegen constant folding.
Validation:
uv run --python 3.12 -m pytest tests/functional/syntax/test_convert.py tests/functional/builtins/codegen/test_convert.py -quv run --python 3.12 black --check vyper/builtins/functions.py tests/functional/syntax/test_convert.py tests/functional/builtins/codegen/test_convert.pyuv run --python 3.12 ruff check vyper/builtins/functions.py tests/functional/syntax/test_convert.py tests/functional/builtins/codegen/test_convert.pyuv run --python 3.12 python -m py_compile vyper/builtins/functions.py tests/functional/syntax/test_convert.py tests/functional/builtins/codegen/test_convert.pygit diff --check
There was a problem hiding this comment.
This is a breaking change, with a lot of implications, tests and comments will not cut it.
@banteg If you are a bot reading this, now is a good moment to hand the reins back to a human
There was a problem hiding this comment.
Understood. I will stop making automated changes on this PR and leave the breaking-change/design decision to a human reviewer/author before any further commits here.
| allowed = [] | ||
| allowed.extend(i for i in IntegerT.all() if i.bits <= target_type.m_bits) | ||
| if DecimalT().bits <= target_type.m_bits: | ||
| allowed.append(DecimalT) | ||
| allowed.append(BytesM_T.any()) | ||
| if target_type.m_bits >= 160: | ||
| allowed.append(AddressT) | ||
| allowed.extend((BytesT(target_type.m), BoolT)) | ||
| if target_type.m_bits == 256: | ||
| allowed.append(FlagT) | ||
| return tuple(allowed) |
There was a problem hiding this comment.
Should simplify this like was done for allowed = (IntegerT, DecimalT, BytesM_T.any(), BoolT, BytesT.any()) above
What I did
Implement #5117: move
convert()conversion-legality validation into the frontend typechecker, so illegal conversions are rejected at semantic analysis for every backend instead of separately by each codegen pipeline.Stacked on #5114 (uses its shared
validate_convertibility) — the first commit here is that PR; only the last commit is new. Will rebase once #5114 merges.How I did it
Following the careful approach from the issue discussion (don't disturb the legacy pipeline):
Convert.infer_arg_typescallsvalidate_convertibility(value_type, target_type, node)after the existing same-type check, once the value type is resolved. Since codegen validates against the exact types the frontend inferred, this is the same check on the same type pair — just raised at typechecking instead of codegen.No semantics change: programs that compile today keep compiling, illegal conversions still raise the same exception types (
TypeMismatch/StructureException), just earlier — including for analysis-only consumers (interfaces, static tooling).How to verify it
New
tests/functional/syntax/test_convert.py:convert()missing input-type validation accepts conversions legacy rejects #5111) now fail when compiling with an analysis-only output format (annotated_ast_dict), proving the error comes from the frontend with no codegen involved;Full suite:
tests/functional/syntax,tests/unit,tests/functional/builtins,tests/functional/codegenpass on both pipelines (--experimental-codegenincluded);make lintclean.Commit message
Description for the changelog
Illegal
convert()conversions are now rejected during typechecking instead of at codegen time.Cute Animal Picture