Skip to content

History / 2026 02 Expression Language

Revisions

  • "fix: Specify list string escaping and nested repr_pwsh arrays Chasing openjd-rs#312 showed the spec's list-to-string conversion ("the JSON string representation") was being violated by implementations that quoted list elements without escaping them, and that five conformance tests had recorded that unescaped output as the expected result on Windows, where every path separator is a backslash: ["C:\out\a.exr"] -> ["C:\\out\\a.exr"] Correct the output_windows expectations of the five tests (expr2.2.3--flatten, expr1.2.6--list-type-inference, expr2.3.1--uri-path-scheme-detection, and both 2.12--list-path-param-*-windows), and add expr2.2.1--string-conversion-list-escaping covering a double quote, a backslash, control characters, a nested list, and a list of paths -- each rendering is also handed to a JSON parser, pinning the property rather than one spelling of it. The Expression Language page (2.2.1) now states the rule directly: strings and paths are double-quoted with `"`, `\`, and characters below U+0020 escaped; format string interpolation uses this same conversion. Escaping non-ASCII is left to the implementation, since JSON accepts those characters directly. Auditing the fix surfaced a second gap: the spec did not say what repr_pwsh produces for a nested list, and the Rust implementation was rendering inner lists in JSON form -- @(["a", "b"], ["c"]) -- which is a PowerShell syntax error. Section 2.2.6 now states that nested lists render recursively as @(...) and that a one-element outer list uses the unary comma form @(,@(1, 2)), because PowerShell flattens @(@(1, 2)). The expr2.2.6--repr-pwsh test gains assertions for the nested forms, and a new Windows-only test, expr2.2.6--repr-pwsh-nested-roundtrip-windows, feeds the rendered literals to a real PowerShell interpreter and checks that it reads back the same structure with no flattening. Companion to the openjd-rs fix for openjd-rs#312; the full conformance suite (1116 tests) passes on Windows against that implementation. Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>"

    @client-software-ci client-software-ci committed Aug 21, 2026
  • "fix: Restate RFC 0005 coercion as satisfaction then conversion This change started on the implementation side: we identified that the coercion code had become too complicated to understand and review properly, and evaluated how we might improve it. The improvement was to restructure coercion as two explicitly ordered steps — first check whether the result already satisfies the target, then convert it if not. Working through that split surfaced adjustments we wanted to make in the specification itself, restated here. Because EXPR isn't yet widely deployed in production, we believe this is still a good time to make a change like this. The coercion rules applied the scalar rule only "when the target types have a single scalar type (without counting `nulltype` or `list[T]`)" and the list rule only when there was a single list type, prescribing no coercion at all for a target with two or more candidates of the same shape. That leaves reachable targets undefined: a target built from several candidate signatures can carry two scalar candidates (`zfill`, `int`, `float`, and `bool` each have a `float | int | string` parameter position), and implementations coerce there rather than reporting an ambiguity. The RFC also listed `range_expr` → `string` and `range_expr` → `list[int]` as rules whose conditions both hold for a `list[int] | string` target, with no stated winner. Restate the section in the two steps an implementation actually performs: - Satisfaction. If the result's type already satisfies the target it is used unchanged. Spell out the relation, including that a union target needs one member satisfied and that `list[T]` is covariant in `T`, so `list[int]` satisfies `list[any]` and `list[int | string]`. Note it is directional and therefore not the symmetric matching used to bind type variables — using one for the other accepts a `list[T1]` target by binding `T1` and discarding the binding — and that a result's type is never itself a union, since union constraints on unresolved values are decomposed first. - Conversion. Otherwise convert toward one of the target's destinations, a union contributing each member, first success winning. This replaces the single-candidate conditions and makes a union accept at least what each member accepts on its own. Destinations are ordered non-list before list, and within each group by a per-result-type preference table set by two principles: a value prefers to stay within its own kind, so a number remains a number before it becomes text, and a conversion that can fail is attempted before one that always succeeds, since a universal fallback attempted first would make every destination after it unreachable. So `int` prefers `float` over `string`; `float` prefers `int` (exact wholes) over `string`; `string` prefers `int`, then `float`, then the selective `bool` and `range_expr` parses, then `path`, which every string trivially satisfies; and a list source orders list destinations by its element type's preference, recursively. This makes the choice fully deterministic — `5` against `float | string` is `5.0`, `"5"` against `int | float` is `5` — where a first-draft of this rewrite had left same-shape order unspecified, letting the same template produce different jobs on different conforming implementations. The non-list-first level resolves the `range_expr` overlap: against `list[int] | string` the result is the canonical string `"1-5"`, whose cost does not depend on the range size. Add `string` → `bool` (the same case-insensitive spellings as RFC 0006's explicit `bool()` conversion) and `string` → `range_expr` to the conversion list. Both are non-destructive parses that succeed only for strings that unambiguously denote a value of the target type, in the same spirit as `string` → `int` and `string` → `float`, and they slot directly into the ordering principles — after the numeric parses, before the universal `path` fallback — so `"true"` against a `bool | path` target is `Bool(true)`. Since satisfaction runs first, the conversions no longer need their "when the target types do not include ..." conditions; those were restating the first step. State that `nulltype` is never a destination, so a `string` whose text is `"null"` does not become `null`, and that the type-variable rule holds at any nesting depth: an implementation must reject a `list` destination whose element type mentions an unbound type variable rather than binding the variable and discarding the binding. Also sharpen the unresolved-value narrowing: against a union target the constraint narrows to the union of every destination with a type-level rule, rather than betting on any one of them, because the type level cannot see the payload that decides which destination wins. The narrowed constraint thus always satisfies the target and always describes the concrete result — an `unresolved[float]` narrows to `unresolved[int | string]` against `int | string`, covering both the 3.0 payload that lands on `int` and the 3.5 payload that falls through to `string`. For a non-union target exactly one destination exists, so the constraint is exactly the type evaluation will produce. Matching user-facing language in the wiki's Expression Language page. The openjd-rs implementation matches this text, with every stated example pinned by a test. Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>"

    @client-software-ci client-software-ci committed Aug 19, 2026
  • "fix: Sync RFC 0005 coercion rules with openjd-rs coercion fixes Ports three points from openjd-rs specs/expr/values.md that landed after the Call-dispatch clarification: - Sharpen range_expr -> list[int]: it is the only list type a range_expr implicitly coerces to. Any other element type is an error; implicit rules do not chain, so the materialized list[int] is never widened element-wise toward the target. Templates that want the widened list chain the explicit conversion list(value: range_expr) -> list[int] from RFC 0006. - State explicitly that a type-variable target (T, T1, T2, T3) has no coercion rule for concrete or unresolved values: type variables are resolved by signature matching before coercion, so reaching coercion with one unbound is always an error. - Document coercion of unresolved values: the same conversion table applies at the type level, union constraints coerce existentially (at least one member must succeed; failing possibilities are discarded), payload-dependent checks defer to resolution, and the two paths are asymmetric in exactly one direction - type-level coercion may accept what the concrete value later rejects, but must never reject what the concrete value would accept. Matching user-facing language added to the wiki's Expression Language page (Implicit Type Coercion and Static Type Checking sections). Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>"

    @client-software-ci client-software-ci committed Aug 12, 2026
  • "test: Add JSON format test for unknown field rejection Ensures implementations reject unknown fields in JSON templates, mirroring the existing YAML test (1.1--unknown-extra-field.invalid.yaml). Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>"

    @client-software-ci client-software-ci committed May 12, 2026
  • "fix: range_expr slicing returns range_expr for positive step Update the expression language spec and conformance test for §2.1.8: - Signature: range_expr slice returns range_expr | list[int] - Positive step (including default): returns range_expr - Negative step (reverse): returns list[int] - Add RREV test case for reverse range slice - Document the rationale (range_expr cannot represent descending order) fix(conformance): mark case-insensitive duplicate capability tests as invalid Per spec §3.3 constraints 3-4, no two amounts/attributes may have the same name. Per §3.3.1.1 and §3.3.2.1, capability names are not case-sensitive. Therefore amount.worker.vcpu and AMOUNT.WORKER.VCPU are duplicates and must be rejected. The test file comments already noted this ('capability names are case-insensitive, so these are duplicates') but the files were not named .invalid.yaml, so the conformance runner expected them to pass. Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>"

    @client-software-ci client-software-ci committed Apr 13, 2026
  • "fix(expr): Rename re_replace to re_sub to match Python's re.sub Our intent was to match Python's re functions generally, but we got re_replace instead of re_sub. This change renames that. Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>"

    @client-software-ci client-software-ci committed Mar 30, 2026
  • ""

    @client-software-ci client-software-ci committed Mar 24, 2026