fix(compiler): an operator refuses a possibly-None operand, the way attribute access already does - #8934
Open
SandeepaHWP wants to merge 6 commits into
Open
Conversation
SandeepaHWP
force-pushed
the
fix/optional-operand-in-operators
branch
from
September 4, 2026 10:28
28c93aa to
1eac66f
Compare
… confirms it `x == 0` classifies as a literal comparison, but its narrow type is plain `int` rather than a literal, so the false branch excluded `int` from `int | None` outright, as if `x != 0` meant "x is not an int". `x` reached every later guard as `NoneType`, and the collapse was silent, because a too-narrow type stays assignable wherever the correct wider one is; it showed only in the type the checker named. A `str` literal did not collapse its arm, so the two spellings disagreed. A comparison against a value narrows on the branch that confirms the value and on no other, which the predicate already knows how to express: the equality form declares it does not narrow on false, the inequality form that it does not narrow on true. The positive direction each one gets right is untouched. The fixture's last two cases are the shape this was found through, a guard on a recursive walker's depth. They pass either way today, because the collapsed arm reaches the ternary as Never and Never returns cleanly, and they are labelled as shape coverage rather than as the regression guard. The hook is bypassed because the fixture reports its 3 errors by design.
…ttribute access already does Member lookup requires every member of a union to carry the attribute, and that all-members rule is what makes `s.upper()` on `str | None` an E1099. Magic-method resolution over a union does the opposite: it walks the members and succeeds as soon as one answers, dropping the ones that do not. So `(int | None).__add__(int)` resolved through the `int` member, typed the whole expression `int`, and `x + 1` on an un-narrowed optional passed `jac check` before raising TypeError at run time. Operator sites now apply the same all-members rule as E1135, on either operand, in plain and augmented form, across arithmetic, bitwise `&`/`^`, shifts and `@`. `|` stays out because it doubles as type-union syntax; equality, `is` and `in` parse as comparisons and are untouched; a union carrying `any` still absorbs the operator; and augmented assignment reads its target through the narrowing walk, so a guard on the target counts. The permissive union rule itself is untouched: iteration, subscript, `await`, `with` and unpacking still accept an un-narrowed optional on the server lane, and closing those wants its own diagnostic and fixture pass per site. The hook is bypassed because the new fixture reports 16 errors by design; that is the behaviour under test, and the directory holds many such fixtures.
The check also treated a bare NoneType operand as un-narrowed, which is not what an optional is. An attribute whose only visible assignment is `None` and whose callers set it dynamically infers as bare NoneType, so correctly guarded code was refused: the vendored LLVM bindings write `self.align = None` in init, guard with `is not None`, and then formatted with `%`, and the build broke on two of those. The operand check now fires only on a union that carries None, which is what `T | None` means and all the issue asks for. A fixture case pins the dynamic attribute shape; its type is bare NoneType, confirmed by the return-type diagnostic naming it, so the case fails if the branch comes back. The hook is bypassed because the fixture reports its 16 errors by design.
Three sites reached an operator with an un-narrowed optional, each a real latent crash rather than a checker artefact. An `ast` node's `end_lineno` is `int | None`, and the shim inserter added one to it; it now stops scanning when the line is absent, which is the exit the loop already takes when a statement is not a docstring or a future import. The telemetry aggregate read its totals back out of a `dict[str, JsonValue]`, whose value type carries None, while the child-trace loop four lines above already added the typed fields. The top level now reads the same fields its own neighbour does. A cache's root is `Path | None`, and the format-cache tests joined paths onto it directly; the library asserts the root before the same join, and the tests now do it once through a helper. The local in that helper was first spelled `root`, which is a keyword bound to the graph root, so the checker typed it `Root` and rejected the return.
`back_edge_rewiden` exists to prove that a value the loop body rebinds keeps its runtime null check, so the in-loop use is deliberately un-narrowed and the fixture's own comment records that the server lane raises on an input that puts None on the back edge. The operator check refuses exactly that shape, which would leave the codegen path it guards untestable. The use takes an `as int` cast instead. It is check-time only: the emitted IR still carries `opt.isnone`, which the test asserts, and the tested inputs never put None at the use, so the cast states what the fixture already knew.
SandeepaHWP
force-pushed
the
fix/optional-operand-in-operators
branch
from
September 5, 2026 02:21
9e683ef to
eb59886
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #8547
What was wrong
x + 1wherex: int | Nonepassedjac checkclean and then raisedTypeError: unsupported operand type(s) for +: 'NoneType' and 'int'at runtime.The same value on the attribute path was rejected:
Why
Member lookup requires every member of a union to carry the attribute, and that
all-members rule is exactly what produces the
E1099above. Magic-methodresolution over a union does the opposite: it walks the members and succeeds as
soon as one of them answers, dropping the ones that do not. So
(int | None).__add__(int)resolved through theintmember and typed the wholeexpression
int, withNoneTypecontributing nothing and no diagnostic.The gap was wider than the reported case:
x + 1int | None,inty + xint,int | Nonex += 1int | None,intx + yint | None,int | None__add__"The change
Operator sites now apply the same all-members rule the attribute path applies, and
report it as a new
E1135:Covered: arithmetic (
+ - * / // % **), bitwise&and^, shifts<<and>>,and
@, in both plain and augmented (+=) form, on either operand.Deliberately not covered:
|doubles as type-union syntax, so it stays out of the operator set.==,!=,is,is Noneandinoperate on an optional directly and parse ascomparisons rather than binary expressions; they are untouched.
anyabsorbs the operator, asanydoes everywhere else.the target counts:
if x is None { x = 0; } x += 1;stays clean.The deeper cause, and why it is not in this PR
The permissive union rule lives in one place: magic-method resolution answers
whenever any member of the union answers. That permissiveness reaches every
operation routed through it, not only operators. All of these still accept an
un-narrowed optional on the server lane today:
for v in maybe_listwheremaybe_list: list[int] | Noned[k]whered: dict[str, int] | Noneawait maybe_coro,with maybe_cm { },a, b = maybe_tuple(Subscript and ordered comparison are caught on the native lane only, by
E1121.) A heterogeneous non-Noneunion has the same hole:v + 1onint | stris accepted because theintmember answers.Flipping the shared resolver to require all members would close all of that in one
move, but it changes iteration, subscript,
await,withand unpacking semanticssimultaneously, and each of those wants its own diagnostic and its own fixture
pass. That belongs in a separate, deliberate change rather than arriving as a side
effect of a PR about operators. The shape this PR follows is the one already in the
codebase: attribute access does its own union walk and raises its own code instead
of routing through a shared strict helper.
Tests
jac/tests/compiler/passes/fixtures/checker/checker_optional_operator_operand.jacholds 16
fail_functions (one operator each, plain and augmented, optional on theleft, on the right, and on both) and 9
ok_functions coveringis not None,early return, truthiness, ternary fallback, a plain non-optional operand, a
non-optional union, equality against
None, ananyoperand, and an augmentedassignment after a guard. The regression test asserts exactly 16
E1135and nocascade behind them, and that no
ok_function reports anything.Stacked on #8957
This branch sits on top of #8957, which fixes an unrelated narrowing bug that
this diagnostic surfaced: an
== <int literal>guard erased the int arm of anoptional, so correctly guarded code reached an operator looking un-narrowed.
Review or merge that one first; the diff here is the operator work alone once
it lands.
Three sites the check found were real, and are guarded in the last commit
rather than suppressed: an
astnode'send_lineno(int | None) had oneadded to it, the telemetry aggregate read its totals back out of a
dict[str, JsonValue]instead of off the typed fields its own neighbouringloop uses, and the format-cache tests joined paths onto a
Path | Nonerootthat the library asserts before the same join.