Skip to content

Bug(expr): target_type is applied to operands that must not inherit it, and rejected as 'any' #291

Description

@uberware

Summary

Supplying target_type to evaluate_expression changes the outcome of expressions that
evaluate correctly without it. Three cases, two distinct root causes:

  • A. The target is applied to a subscript's operands — the receiver and the index —
    rather than to the subscript's result.
  • B. The target is applied to the operand and/or discards, rather than to the one
    they return.
  • C. An explicit any target is rejected outright, for any value.

Each fails with a coercion error on an expression that is valid and that the same
implementation evaluates correctly when target_type is omitted.

Versions

Reproduced on:

  • openjd-model 0.11.1 from PyPI (bundles openjd-expr 0.2.1, the current release)
  • openjd-rs main at d021564 — built from source, all three still reproduce

Case B's node coverage appears related to 2e6e1a4 ("fix(expr): evaluate operator operands
with unconstrained target type", 2026-05-18); see Root cause below.

Reproduction

from openjd.expr import ExprType, evaluate_expression as ev

# A — target leaks into a subscript's operands
ev("[10, 20, 30][0]")                                  # -> 10 :: int      (correct)
ev("[10, 20, 30][0]", target_type=ExprType("int"))     # -> ExpressionError
ev("'hello'[0]",      target_type=ExprType("string"))  # -> ExpressionError

# B — target is applied to the operand and/or discards
ev("true and 0")                                       # -> 0              (correct)
ev("true and 0",      target_type=ExprType("int"))     # -> ExpressionError
ev("null or 7")                                        # -> 7              (correct)
ev("null or 7",       target_type=ExprType("int"))     # -> ExpressionError

# C — an explicit `any` target is rejected for any value
ev("1",               target_type=ExprType("any"))     # -> ExpressionError
ev("'x'",             target_type=ExprType("any"))     # -> ExpressionError

Expected vs actual

Expression target_type Expected Actual
[10, 20, 30][0] int 10 :: int Cannot coerce list[int] to int
'hello'[0] string h :: string No matching signature for __getitem__(string, string)
true and 0 int 0 :: int Cannot coerce bool to int
null or 7 int 7 :: int Cannot coerce nulltype to int
1 any 1 :: int Cannot coerce int to any

The Expected column is what this same library returns for each expression with target_type
omitted, so no second implementation is needed to establish it.

The error spans point straight at the mis-targeted operand:

>>> ev("[10, 20, 30][0]", target_type=ExprType("int"))
Cannot coerce list[int] to int
  [10, 20, 30][0]
  ^~~~~~~~~~~~            <- the receiver, not the result

>>> ev("'hello'[0]", target_type=ExprType("string"))
No matching signature for __getitem__(string, string)
  'hello'[0]
  ~~~~~~~^~~              <- the index has become a string

>>> ev("true and 0", target_type=ExprType("int"))
Cannot coerce bool to int
  true and 0
  ^~~~                    <- the operand `and` discards

Why these are bugs

Case A. §2.1.7 gives the subscript signatures as
__getitem__(list: list[T], index: int) -> T and __getitem__(s: string, index: int) -> string.
The index is always int and the receiver is always the collection, irrespective of what the
calling context wants back. A target of int describes the subscript's result; applying it
to a list[int] receiver is a category error, and applying it to the index turns a valid int
into a string so no signature matches.

Case B. §2.1.6 is explicit that and/or are value-returning: "If a is null or
false, return a; otherwise evaluate and return b"
, and "they return one of their
operands, not necessarily a bool"
. For true and 0 the returned operand is 0, which
coerces to int without difficulty. The implementation instead coerces true — the operand
the rule discards. Same for null or 7, which returns 7 but reports a failure to coerce
nulltype.

Case C. §1.2.1 lists any as "Unconstrained type (matches anything)", and the
ExprValue notes state that ANY, UNION and UNRESOLVED "are type-level constructs used
during type checking. They do not appear as the type of a concrete ExprValue at runtime"
.
An any target is therefore a no-op constraint that every value satisfies; it should never be
the reason an evaluation fails.

Note also §1.3.1: the target "guides implicit type coercion" and "the expression must
produce a value of type T or a type that can be implicitly coerced to T"
— a statement
about the expression's result, not about its sub-expressions.

Root cause

Cases A and B look like one defect: the set of node kinds that scope the target away from
their operands is enumerated rather than derived. 2e6e1a4 added
Evaluator::evaluate_with_target and applied the unconstrained-operand rule to BinOp,
UnaryOp, Compare and the test slot of IfExp. Subscript and BoolOp are not in that
list, and they are exactly the two shapes that still fail. Any node kind added later inherits
the same hazard.

Case C is separate — it is ExprValue::coerce having no arm for an any target, so the value
falls through to the catch-all error. It resembles 429aed0 ("accept union target_type via
match-or-coerce"), which fixed the analogous gap for union targets; any appears to have been
missed by the same reasoning. Happy to split it into its own issue if you would rather track it
separately.

Impact

target_type is the mechanism an embedding context uses to say what it expects, so these fire
in ordinary use rather than at the edges. §1.3.2 defines the target for a template's args
items as T? | list[T], which means a host evaluating template fields with the documented
targets hits case A for any expression containing a subscript, and case B for any use of or
as a null-coalescing operator — the pattern §2.1.6's own examples recommend
(Param.X or "fallback").

How this was found

Differential testing against an independent implementation of the same specification: a corpus
of expressions is evaluated by both and the results compared. These are the cases where the two
disagree and the specification supports the other implementation's answer. Happy to supply the
full corpus if it would be useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions