Skip to content

fix(model): Accept Task.File property access in format strings - #292

Open
leongdl wants to merge 1 commit into
OpenJobDescription:mainfrom
leongdl:fix/task-file-property-access
Open

fix(model): Accept Task.File property access in format strings#292
leongdl wants to merge 1 commit into
OpenJobDescription:mainfrom
leongdl:fix/task-file-property-access

Conversation

@leongdl

@leongdl leongdl commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Problem

RFC 0005 declares Task.File.<name> as path typed and shows property access on one directly inside a format string:

echo "Script: {{Task.File.Run.name}}"

The step-script validator rejected that form. FormatString::expression_names() returns the whole bare dotted lookup (Task.File.Run.name), and the validator stripped only the Task.File. prefix and then looked the entire remaining tail up as an embedded-file key. The property access got folded into the filename, so a template using the form the RFC documents failed validation:

ERROR: Model validation error: 2 validation errors for JobTemplate
steps[0] -> script:
	references undefined embedded file 'Run.name'.
steps[0] -> script:
	references undefined embedded file 'Run.suffix'.

This was inconsistent in two directions: our own runtime resolves the expression correctly, and our environment-template path accepts the equivalent Env.File.<name>.name.

Reproducer

Save as repro.yaml and run openjd check repro.yaml:

specificationVersion: jobtemplate-2023-09
extensions:
- EXPR
name: TestJob
steps:
- name: Step1
  script:
    embeddedFiles:
    - name: Run
      type: TEXT
      filename: run.txt
      data: "contents\n"
    actions:
      onRun:
        command: python
        args:
        - -c
        # BEFORE THIS FIX: both lines below failed validation with
        #   "references undefined embedded file 'Run.name'."
        #   "references undefined embedded file 'Run.suffix'."
        # because `.name` / `.suffix` were treated as part of the embedded-file
        # key -- the validator looked up a file literally named "Run.name".
        # `{{Task.File.Run}}` (no property access) worked fine, which is why
        # this survived: the existing tests only reached a property through a
        # `let` binding or a function call, both of which parse differently and
        # never hit this code path.
        - |
          print(r'NAME:{{Task.File.Run.name}}')
          print(r'SUFFIX:{{Task.File.Run.suffix}}')

before: ERROR ... references undefined embedded file 'Run.name'. (exit 1)
after: Template at 'repro.yaml' passes validation checks. (exit 0)

Running it produces NAME:run.txt and SUFFIX:.txt.

Fix

Only the first dotted segment after Task.File. is the embedded-file key; anything further is property access on the resulting path value:

if let Some(tail) = name.strip_prefix("Task.File.") {
    let file_name = tail.split_once('.').map_or(tail, |(key, _)| key);

Embedded file names are identifiers and cannot contain a dot (enforced a few lines below in the same function), so the split is unambiguous. split_once is used rather than split('.').next() because the latter needs a fallback arm that is unreachable — dead code that no test can ever pin.

Undefined embedded files are still rejected, and the error now names the file key ('Missing') rather than the property tail ('Missing.name').

Conformance

This closes divergence 1 of the two tracked in the draft spec PR:

Both fixtures from that draft now pass against this branch:

✓ 7.3--task-file-direct-property-access
✓ 3--int-literal-above-int64-max.invalid.yaml

Against pre-fix main the first one fails at validation, never reaching the runtime:

✗ 7.3--task-file-direct-property-access
  Expected success, got failure
  ERROR: Model validation error: 2 validation errors for JobTemplate
  steps[0] -> script:
  	references undefined embedded file 'Run.name'.

Full conformance suites on this branch, with #156's fixtures applied:

Suite Result
2023-09/base/* 655 passed, 0 failed
2023-09/EXPR/* 356 passed, 0 failed

Fixture 2 (integer literal above 2^63-1) already passed here — that one is owned by openjd-model-for-python. #156 can be un-drafted once that side lands.

Tests

10 tests in crates/openjd-model/tests/integration/test_embedded.rs, written before the fix and confirmed failing against unfixed code.

Accepted: bare {{Task.File.Run}}, .name, .suffix, .parent, chained .parent.name, and property access in onRun.command (not just args).

Rejected (negative controls): unknown file with and without a property tail, unknown file referenced from command, and a dotted embedded-file name — the last pins the invariant the first-segment split depends on.

Every test was mutation-checked; all 7 mutants are caught:

Mutation Caught by
revert the fix (whole tail as key) 7 tests
last segment instead of first 7 tests
never reject anything 3 reject tests
drop the file name from the message 3 reject tests
plausible wrong fix: skip any reference containing a dot 2 reject tests
collapse only a single property segment task_file_nested_property_chain_accepted
stop scanning onRun.command task_file_unknown_name_in_command_rejected

The last two mutants initially survived, which is why the chained-property and command-position tests exist. Nothing in the previous 1510-test suite pinned that onRun.command was scanned at all.

Three of the ten tests pass against unfixed code by design — they are controls that pin invariants rather than this regression.

Verified: full workspace suite green, clippy --all-targets -- -D warnings clean, cargo fmt --check clean.

Follow-ups, not in this PR

Base-mode property gating. Base (no EXPR) should reject path property access, and openjd-rs accepts it — for every path-typed symbol, not just Task.File. Verified against the Python implementation:

Expression, no EXPR extension openjd-rs Python
{{Session.WorkingDirectory.name}} accept reject
{{Param.SomePathParam.name}} accept reject
{{Task.File.Run.name}} accept reject

The first two never touch this code path, so this gap is pre-existing and general; it belongs in the expression/type-check layer, not in the embedded-file existence check. Gating it here would fix one symbol and leave the others divergent, while preserving the misleading "undefined embedded file" wording. No base/ conformance fixture currently exercises path property access, so nothing in the suite regresses either way.

A third divergence, found while verifying this one. Under EXPR, Python accepts {{Task.File.Run.bogusproperty}} at validation time; openjd-rs rejects it with "Cannot access attribute 'bogusproperty' on path". openjd-rs is correct here — the spec defines a fixed property set — and this is currently uncovered by the conformance suite. Worth a fixture in #156 or a follow-up.

RFC 0005 declares `Task.File.<name>` as `path` typed and shows property
access on one directly inside a format string:

    echo "Script: {{Task.File.Run.name}}"

The step-script validator rejected that form. `expression_names()` returns
the whole bare dotted lookup (`Task.File.Run.name`), and the validator
stripped only the `Task.File.` prefix, then looked the entire remaining
tail up as an embedded-file key. `.name` was folded into the filename, so
a template using the form the RFC documents failed validation with
"references undefined embedded file 'Run.name'" -- even though the
runtime resolves the expression correctly and the environment-template
path accepts the equivalent `Env.File.<name>.name`.

Only the first dotted segment is the embedded-file key; the rest is
property access on the resulting path value. Embedded file names are
identifiers and cannot contain a dot, so the split is unambiguous.

Undefined embedded files are still rejected, and the error now names the
file key ('Missing') rather than the property tail ('Missing.name').

Found while reviewing RFC 0008 wrap-action support across the two
implementations; covered by a new conformance fixture in
openjd-specifications#156.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
@leongdl
leongdl requested a review from a team as a code owner July 31, 2026 22:42
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