Skip to content

fix: Make task parameter type names case-insensitive under EXPR - #350

Open
leongdl wants to merge 1 commit into
OpenJobDescription:mainlinefrom
leongdl:fix/task-param-type-case-insensitive
Open

fix: Make task parameter type names case-insensitive under EXPR#350
leongdl wants to merge 1 commit into
OpenJobDescription:mainlinefrom
leongdl:fix/task-param-type-case-insensitive

Conversation

@leongdl

@leongdl leongdl commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

What changed

Template Schemas §2 says "job parameter and task parameter type names become case-insensitive" when the EXPR extension is enabled, and so are case-sensitive without it. _normalize_parameter_type_case already implemented that, gated on the extension, but it was registered only on parameterDefinitions in JobTemplate and EnvironmentTemplate. StepParameterSpaceDefinition had no such registration, so a task parameter spelled type: int was rejected even with EXPR declared:

steps[0] -> parameterSpace -> taskParameterDefinitions[0]:
	Input tag 'int' found using 'type' does not match any of the expected tags: ...

The fix is the same registration the other two models carry, on taskParameterDefinitions, before discriminated-union resolution. Nothing else was needed: TaskParameterList is a list of dicts, the shape the shared function was already written for, and context.extensions holds the effective set by then because JobTemplate declares extensions before steps. That covers all five task parameter types, CHUNK[INT] included — §3.4.1.5 makes it a task parameter type name, so EXPR makes chunk[int] a spelling of it.

The fold now uses an ASCII-only translation table instead of str.upper(). str.upper() is Unicode-aware and folds ten codepoints wholly into the type-name alphabet, five of which spell a real type: ıNT (U+0131), ſTRING (U+017F), flOAT (U+FB02) and ſtRING/stRING (U+FB05/U+FB06) were all accepted under EXPR. Spec type names are ASCII, so none of those is a spelling variant. This is a second defect rather than part of the fixture's, and no fixture pins it, but it shares the line being edited — and registering the fold on task parameters would otherwise have extended it to a surface that was accidentally safe by not folding at all.

Conformance

The fixture is EXPR/job_templates/proposed/3.4.1--task-param-type-case-insensitive.yaml, added by OpenJobDescription/openjd-specifications#166, which failed here and in openjd-rs alike. Both are job_templates/ fixtures, so what they assert is template validity — what openjd check gets from decode_job_template. Loading the real fixture files and decoding them, before and after this commit:

Fixture Expected Before After
EXPR/…/proposed/3.4.1--task-param-type-case-insensitive.yaml valid invalid, Input tag 'pAtH' valid
base/…/proposed/2--type-lowercase.invalid.yaml invalid invalid invalid
EXPR/job_templates/2--type-case-insensitive.yaml (already gating) valid valid valid

The fixture this package was failing now passes, the base fixture it already satisfied is unchanged, and the already-green gating fixture stays green. The conformance runner itself was not executed, because that needs openjd-cli and openjd-sessions built against the patched model.

The fixture stays in proposed/ until openjd-rs ships the matching change, since a fixture is promotable only when it passes on both implementations.

Tests

81 tests across three classes, one per registration site, each covering the four cases of EXPR on/off by canonical/mis-cased spelling.

Class File Surface
TestTaskParameterTypeNameCase test_parameter_space.py StepParameterSpaceDefinition.taskParameterDefinitions
TestJobParameterTypeNameCase test_list_parameters.py JobTemplate.parameterDefinitions
TestEnvironmentTemplateParameterTypeNameCase test_environment_template.py EnvironmentTemplate.parameterDefinitions

Nine mutations, each caught by the tests that should catch it:

Mutation Caught by
un-register on taskParameterDefinitions test_with_expr_miscased_accepted ×5 + 3
drop mode="before" the same 8
un-register on JobTemplate job test_with_expr_miscased_accepted ×4
un-register on EnvironmentTemplate env test_with_expr_miscased_accepted ×3
drop the EXPR gate test_no_expr_miscased_rejected ×12 + 2
revert to str.upper() test_non_ascii_lookalike_rejected_with_expr ×11
no-op translation table 15
remove both isinstance guards test_malformed_input_is_a_validation_error_not_a_crash ×6
fold only the first list element the two interaction tests

Two groups survive every mutation and are negative controls rather than false pins. test_no_expr_canonical_case_accepted proves a mis-cased rejection is about the spelling and not about the type being unavailable. test_non_ascii_lookalike_rejected_without_expr is inert by construction, since without EXPR no fold runs; it exists so the pair covers both extension states, and its comment says so.

Full suite: 5626 passed, against a 5545 baseline. The 12 failures are pre-existing and unrelated — all in test/openjd/model_v1/test_step_param_space_iter.py::TestChunksTaskCountOverride, one cause, a stale Rust crate pin in the v1 binding layer. Byte-identical failure set before and after. ruff, black and mypy clean.

What test auditing found that the design missed

Three findings came from auditing the tests rather than the code, and each added tests that were missing.

The EnvironmentTemplate registration was covered by nothing. Neutering it left the entire model_v0 suite green at 2743 passed, where neutering the JobTemplate one fails 6 tests. The ASCII change had therefore silently altered that shipped path in both directions with no test either way.

The shared function's two isinstance guards became load-bearing on a new surface. Registering the fold routed taskParameterDefinitions through them for the first time. Measured with the guards removed: taskParameterDefinitions: left empty raises TypeError: 'NoneType' object is not iterable, type: 3 raises AttributeError: 'int' object has no attribute 'translate', and a missing type raises KeyError, each escaping decode_job_template instead of arriving as a DecodeValidationError.

A mis-cased name now reaches validators it never used to. Before the fold ran on this field, type: int failed at discriminator resolution and nothing downstream saw it. Now it resolves, so two mis-cased chunk[int] parameters report the one-CHUNK[INT]-per-step rule and int/INT under one name reports the duplicate-name rule. Both are correct and both are new, so both are asserted.

One test was renamed because mutation testing showed it did not pin what its name claimed. A template declaring EXPR to a caller that does not allowlist it is rejected on the extension name, before the type name is reached, so it survives removal of the EXPR gate. It now says so and asserts the extension message.

Out of scope

Pydantic's wording for a type name that is wrong for reasons other than case (Input tag 'NOPE' found using 'type' does not match any of the expected tags) is unhelpful but is not this defect, and is not worth diverging from the framework to improve.

openjd.model._v1 is entirely re-exports from the openjd._openjd_rs extension module, so that surface is fixed by the openjd-rs change rather than here.

Related

Template Schemas §2 says "job parameter and task parameter type names become
case-insensitive" when the EXPR extension is enabled, and so are case-sensitive
without it. `_normalize_parameter_type_case` already implemented that, gated on
the extension, but it was registered only on `parameterDefinitions` in
`JobTemplate` and `EnvironmentTemplate`. `StepParameterSpaceDefinition` had no
such registration, so a task parameter spelled `type: int` was rejected with
`Input tag 'int' found using 'type' does not match any of the expected tags`
even with EXPR declared. The conformance fixture pinning this is
`EXPR/job_templates/proposed/3.4.1--task-param-type-case-insensitive.yaml`,
added by openjd-specifications#166, which failed here and in openjd-rs alike.

The fix is the same three-line registration the other two models carry, on
`taskParameterDefinitions`, before discriminated-union resolution. Nothing else
was needed: `TaskParameterList` is a list of dicts, the shape the shared
function was already written for, and `context.extensions` holds the effective
set by then because `JobTemplate` declares `extensions` before `steps`. That
covers all five task parameter types, `CHUNK[INT]` included -- §3.4.1.5 makes it
a task parameter type name, so EXPR makes `chunk[int]` a spelling of it.

The fold now uses an ASCII-only translation table instead of `str.upper()`.
`str.upper()` is Unicode-aware and folds ten codepoints wholly into the
type-name alphabet, five of which spell a real type: `ıNT` (U+0131), `ſTRING`
(U+017F), `flOAT` (U+FB02) and `ſtRING`/`stRING` (U+FB05/U+FB06) were all accepted
under EXPR. Spec type names are ASCII, so none of those is a spelling variant.
This is a second defect rather than part of the fixture's, and no fixture pins
it, but it shares the line being edited -- and registering the fold on task
parameters would otherwise have extended it to a surface that was accidentally
safe by not folding at all.

81 tests across three files, one class per registration site, each covering the
four cases of EXPR on/off by canonical/mis-cased spelling. Nine mutations, all
caught: un-registering each of the three sites, dropping `mode="before"`,
dropping the EXPR gate, reverting to `str.upper()`, neutering the translation
table, removing the two isinstance guards, and folding only the first list
element.

Three findings came from auditing the tests rather than the code, and each added
tests that were missing. The `EnvironmentTemplate` registration was covered by
nothing: neutering it left the entire model_v0 suite green at 2743 passed, and
the ASCII change had silently altered that shipped path in both directions with
no test either way. The two isinstance guards in the shared function became
load-bearing on a new surface, since registering the fold routed
`taskParameterDefinitions` through them for the first time; without them
`taskParameterDefinitions:` left empty raises `TypeError` and `type: 3` raises
`AttributeError` out of `decode_job_template` instead of
`DecodeValidationError`. And because a mis-cased name now resolves to a variant,
it reaches validators it never used to: two mis-cased `chunk[int]` parameters
now report the one-CHUNK[INT] rule, and `int`/`INT` under one name now reports
the duplicate-name rule.

One test was renamed after mutation testing showed it did not pin what its name
claimed. A template declaring EXPR to a caller that does not allowlist it is
rejected on the extension name, before the type name is reached, so it survives
removal of the EXPR gate; it now says so and asserts the extension message.

The matching openjd-rs change is not yet raised. Both fixtures stay in
`proposed/` until both implementations release, since a fixture is promotable
only when it passes on both.

Design note: SuperDaveDocs docs/conformance-0901/4.3-parameter-insensitive/fix.md

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
@field_validator("taskParameterDefinitions", mode="before")
@classmethod
def _normalize_parameter_type_case(cls, v: Any, info: ValidationInfo) -> Any:
return _normalize_parameter_type_case(v, info)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just applies the validator below to support mixed case.

leongdl added a commit to OpenJobDescription/openjd-rs that referenced this pull request Sep 5, 2026
Template Schemas §2 says "job parameter and task parameter type names become
case-insensitive" when the EXPR extension is enabled, and so are case-sensitive
without it. openjd-rs got both halves wrong, in opposite directions and on
different parameter kinds.

`JobParameterDefinition`'s hand-written `Deserialize` upper-cased the `type` tag
unconditionally, so `type: string` was accepted on a base template with no
extensions. `TaskParameterDefinition` used the derived internally-tagged impl
with exact variant names and never folded, so `type: int` was rejected even with
EXPR, reporting `unknown variant 'int', expected one of 'INT', 'FLOAT',
'STRING', 'PATH', 'CHUNK[INT]'`. The one combination that worked -- job
parameters under EXPR -- worked by accident, because an ungated fold happens to
agree with the spec there.

Two conformance fixtures pin one half each:
`base/job_templates/proposed/2--type-lowercase.invalid.yaml` from
openjd-specifications#163 and
`EXPR/job_templates/proposed/3.4.1--task-param-type-case-insensitive.yaml` from
#166. Both failed here. Measured by direct path, before and after: #166's goes
from invalid to valid, #163's from valid to invalid, and the already-gating
`EXPR/job_templates/2--type-case-insensitive.yaml` stays valid. Suite totals do
not move, because a `proposed/` fixture is never picked up by directory
discovery: 1161 passed and the same 1 pre-existing failure
(`3.5--env-script-onexit-only.invalid.yaml`, unrelated and failing on
origin/main too) before and after.

Two changes, and neither is useful alone. First, `TaskParameterDefinition` gets
a hand-written `Deserialize` mirroring `JobParameterDefinition`'s, so both kinds
match the tag case-blind. That alone makes the EXPR half pass and the base half
worse. Second, a check in `parse.rs` rejects a spelling that names a real type
but is not the spec's spelling, when EXPR is not in effect.

The check cannot live in `Deserialize`, and that shapes the whole change. The
effective extension set is the template's declared list intersected with the
caller allowlist, and it is not computed until after `serde_json::from_value`
has consumed the document -- while the author's spelling is available only
during deserialization, since folding destroys it. So a walk over the raw
`serde_json::Value` collects `(error path, type name as written)` for the two
places a parameter type name can appear, before the document is moved, and the
check consumes that list once `validate_extensions_list` has produced the real
extension set. Errors join the same batch, so a template with both an extension
problem and a casing problem reports both. The alternatives were a thread-local
carrying the EXPR bit into the deserializers, which makes the public
`JobTemplate: Deserialize` depend on ambient state, and `DeserializeSeed`, which
would mean hand-writing seeds for every type from `JobTemplate` down to
`TaskParameterDefinition` and giving up `rename_all` and `deny_unknown_fields`.
The cost of the walk is that it knows where type names live independently of the
structs; a future revision adding a third location must update it.

`decode_environment_template` gets the same wiring. An environment template's
`parameterDefinitions` is the same `JobParameterDefinition` union, so a fix
applied only to `decode_job_template` would leave it accepting `type: string` in
base. Auditing the equivalent Python change found that surface covered by
nothing, which is why it has explicit tests here.

Both folds are now `to_ascii_uppercase` rather than `to_uppercase`. Unicode
folding maps four codepoints wholly into the type-name alphabet -- U+0131 dotless
i to I, U+017F long s to S, U+FB02 fl to FL, U+FB06 st to ST -- so `ıNT` was
accepted as INT with and without EXPR. Spec type names are ASCII, so none of
those is a spelling variant. No fixture pins this, but it shares the line being
edited, and making task parameters fold at all would otherwise have extended the
defect to a surface that was accidentally safe by not folding.

`type: list[int]` without EXPR changes message. It reported `parameter type
'LIST[INT]' is not allowed.` -- naming a canonical spelling for a template that
never used it, because `structure.rs` formats `type_name()` -- and now reports
the casing, naming what the author wrote. A canonically-spelled EXPR-only type
without EXPR still reports `is not allowed.`, which is what eight tests in
`test_expr_parameters.rs` assert. An unrecognized task type now reports
`unknown task parameter type: 'NOPE'` instead of serde's variant list.

27 tests in `tests/integration/test_param_type_name_case.rs`, covering all four
combinations of extension state by spelling on all three surfaces, plus the
ASCII fold, the collect-all behaviour, per-step paths, and malformed input.
Eleven mutations, all caught: matching the task tag exactly again, reverting
either fold to Unicode, disabling the check, dropping its EXPR gate, gating it on
any extension instead, dropping either half of the walk, skipping environment
templates, walking only the first step, and collecting only the first definition.

Design note: SuperDaveDocs docs/conformance-0901/4.3-parameter-insensitive/fix.md
The matching openjd-model-for-python change is
OpenJobDescription/openjd-model-for-python#350.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
leongdl added a commit to OpenJobDescription/openjd-rs that referenced this pull request Sep 5, 2026
* fix(model): Gate parameter type-name case on the EXPR extension

Template Schemas §2 says "job parameter and task parameter type names become
case-insensitive" when the EXPR extension is enabled, and so are case-sensitive
without it. openjd-rs got both halves wrong, in opposite directions and on
different parameter kinds.

`JobParameterDefinition`'s hand-written `Deserialize` upper-cased the `type` tag
unconditionally, so `type: string` was accepted on a base template with no
extensions. `TaskParameterDefinition` used the derived internally-tagged impl
with exact variant names and never folded, so `type: int` was rejected even with
EXPR, reporting `unknown variant 'int', expected one of 'INT', 'FLOAT',
'STRING', 'PATH', 'CHUNK[INT]'`. The one combination that worked -- job
parameters under EXPR -- worked by accident, because an ungated fold happens to
agree with the spec there.

Two conformance fixtures pin one half each:
`base/job_templates/proposed/2--type-lowercase.invalid.yaml` from
openjd-specifications#163 and
`EXPR/job_templates/proposed/3.4.1--task-param-type-case-insensitive.yaml` from
#166. Both failed here. Measured by direct path, before and after: #166's goes
from invalid to valid, #163's from valid to invalid, and the already-gating
`EXPR/job_templates/2--type-case-insensitive.yaml` stays valid. Suite totals do
not move, because a `proposed/` fixture is never picked up by directory
discovery: 1161 passed and the same 1 pre-existing failure
(`3.5--env-script-onexit-only.invalid.yaml`, unrelated and failing on
origin/main too) before and after.

Two changes, and neither is useful alone. First, `TaskParameterDefinition` gets
a hand-written `Deserialize` mirroring `JobParameterDefinition`'s, so both kinds
match the tag case-blind. That alone makes the EXPR half pass and the base half
worse. Second, a check in `parse.rs` rejects a spelling that names a real type
but is not the spec's spelling, when EXPR is not in effect.

The check cannot live in `Deserialize`, and that shapes the whole change. The
effective extension set is the template's declared list intersected with the
caller allowlist, and it is not computed until after `serde_json::from_value`
has consumed the document -- while the author's spelling is available only
during deserialization, since folding destroys it. So a walk over the raw
`serde_json::Value` collects `(error path, type name as written)` for the two
places a parameter type name can appear, before the document is moved, and the
check consumes that list once `validate_extensions_list` has produced the real
extension set. Errors join the same batch, so a template with both an extension
problem and a casing problem reports both. The alternatives were a thread-local
carrying the EXPR bit into the deserializers, which makes the public
`JobTemplate: Deserialize` depend on ambient state, and `DeserializeSeed`, which
would mean hand-writing seeds for every type from `JobTemplate` down to
`TaskParameterDefinition` and giving up `rename_all` and `deny_unknown_fields`.
The cost of the walk is that it knows where type names live independently of the
structs; a future revision adding a third location must update it.

`decode_environment_template` gets the same wiring. An environment template's
`parameterDefinitions` is the same `JobParameterDefinition` union, so a fix
applied only to `decode_job_template` would leave it accepting `type: string` in
base. Auditing the equivalent Python change found that surface covered by
nothing, which is why it has explicit tests here.

Both folds are now `to_ascii_uppercase` rather than `to_uppercase`. Unicode
folding maps four codepoints wholly into the type-name alphabet -- U+0131 dotless
i to I, U+017F long s to S, U+FB02 fl to FL, U+FB06 st to ST -- so `ıNT` was
accepted as INT with and without EXPR. Spec type names are ASCII, so none of
those is a spelling variant. No fixture pins this, but it shares the line being
edited, and making task parameters fold at all would otherwise have extended the
defect to a surface that was accidentally safe by not folding.

`type: list[int]` without EXPR changes message. It reported `parameter type
'LIST[INT]' is not allowed.` -- naming a canonical spelling for a template that
never used it, because `structure.rs` formats `type_name()` -- and now reports
the casing, naming what the author wrote. A canonically-spelled EXPR-only type
without EXPR still reports `is not allowed.`, which is what eight tests in
`test_expr_parameters.rs` assert. An unrecognized task type now reports
`unknown task parameter type: 'NOPE'` instead of serde's variant list.

27 tests in `tests/integration/test_param_type_name_case.rs`, covering all four
combinations of extension state by spelling on all three surfaces, plus the
ASCII fold, the collect-all behaviour, per-step paths, and malformed input.
Eleven mutations, all caught: matching the task tag exactly again, reverting
either fold to Unicode, disabling the check, dropping its EXPR gate, gating it on
any extension instead, dropping either half of the walk, skipping environment
templates, walking only the first step, and collecting only the first definition.

Design note: SuperDaveDocs docs/conformance-0901/4.3-parameter-insensitive/fix.md
The matching openjd-model-for-python change is
OpenJobDescription/openjd-model-for-python#350.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>

* refactor(model): Give the type-tag fold one home

The two parameter deserializers each read the `type` tag, folded it, and stripped
it before matching -- twelve duplicated lines carrying the rule this change set
exists to fix. Two copies of a folding rule is how the job and task halves came to
disagree about what a spelling variant is in the first place, so the shared
prologue moves into `split_type_tag`, which returns the tag as written, the tag
folded for matching, and the body.

Three consequences, all wanted. The ASCII fold now exists once, so it cannot drift
again. `strip_type_field` goes back to module-private, since the new helper is the
intended shared entry point and is the only thing that needed widening. And the
mutation that reverts the fold to `to_uppercase` now fails all four non-ASCII
lookalike groups rather than two each, because one site covers both kinds.

Also spells `PathElement` through the file's own import rather than
`crate::error::PathElement` inline, and builds the root path with `vec![]` to match
`limits.rs:14` and `structure.rs:25`.

No behaviour change. `openjd-model` stays at 1984 passed, all eleven mutations stay
caught, and clippy, rustfmt and `cargo doc` stay clean.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>

---------

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant