From 9e0d13c1b3e5b2f1c01e4102ba80c4de74256bac Mon Sep 17 00:00:00 2001 From: David Leong <116610336+leongdl@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:35:32 -0700 Subject: [PATCH] test: Add function-library conformance fixtures for coverage gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 20 fixtures from the RFC 0006 coverage audit: repr_sh/repr_py/repr_json adversarial round-trips executed through the real target interpreter, repr_cmd/repr_pwsh round-trips (windows-gated), unicode codepoint semantics for len/case/predicates/padding, int64 overflow inside abs/sum/floor/ceil, predicate and conversion edges, and three regex-dialect pins (lookahead, backreference, group-reference rejection per spec 2.2.5). Suite: 350 -> 368 passing. Spec-question fixtures that fail today are split to conformance-func-lib-expected-failures. Review: quorum-review fixes — dropped the caret payload from the repr_cmd round-trip (the spec's own example output "a ^^ b" cannot round-trip through cmd /c under the documented quoting model; header explains); abs overflow operand rebuilt as (0 - max - 1) so a unary-minus lexing quirk cannot mask a missing abs check; floor/ceil and the two re_sub group-reference dialects split into separate fixtures so abort-on-first-error cannot leave the second case unpinned; documented the isalpha-parallel-to-isdigit ambiguity and the padding width-unit inference. Also: fix runner file encoding for Windows CI — the runner opened fixture files and decoded subprocess output with the platform default encoding, so Windows (cp1252) crashed with UnicodeDecodeError on this PR's non-ASCII unicode fixtures. All reads/writes and subprocess decoding now pin UTF-8 (errors='replace' for child output). Also set PYTHONUTF8=1 for child processes: on Windows a child python writing to a pipe uses cp1252 by default, mangling non-ASCII sentinel output before it reaches the session log (the remaining unicode-fixture failures on the Rust windows job). Parked expr2.2.6--repr-cmd-roundtrip in jobs/proposed/ after its first real Windows CI run: the cmd.exe child emits no stdout at all (siblings repr_pwsh/repr_py/repr_json pass on the same runner), so the cmd /c action path needs hands-on Windows investigation before this fixture can be live. Header records the observed behavior and promotion criteria. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com> --- ....1--bool-string-case-insensitive.test.yaml | 36 ++++++ ....1--int-conversion-9e18-boundary.test.yaml | 27 +++++ .../jobs/expr2.2.1--len-unicode.test.yaml | 36 ++++++ ...nge-expr-whitespace-only.invalid.test.yaml | 20 ++++ ...--abs-int64-min-overflow.invalid.test.yaml | 26 ++++ ....2.2--ceil-exceeds-int64.invalid.test.yaml | 23 ++++ ...2.2--floor-exceeds-int64.invalid.test.yaml | 23 ++++ ....2.2--sum-int64-overflow.invalid.test.yaml | 18 +++ ...pr2.2.4--case-transforms-unicode.test.yaml | 39 ++++++ .../jobs/expr2.2.4--padding-unicode.test.yaml | 38 ++++++ ....4--predicates-uncased-and-empty.test.yaml | 41 +++++++ ...4--string-classification-unicode.test.yaml | 46 +++++++ ...e-backreference-rejected.invalid.test.yaml | 27 +++++ ...5--re-lookahead-rejected.invalid.test.yaml | 26 ++++ ...group-reference-rejected.invalid.test.yaml | 27 +++++ ...group-reference-rejected.invalid.test.yaml | 28 +++++ ...-repr-json-roundtrip-adversarial.test.yaml | 103 ++++++++++++++++ .../expr2.2.6--repr-pwsh-roundtrip.test.yaml | 79 ++++++++++++ ...6--repr-py-roundtrip-adversarial.test.yaml | 112 ++++++++++++++++++ ...6--repr-sh-roundtrip-adversarial.test.yaml | 81 +++++++++++++ .../expr2.2.6--repr-sh-roundtrip.test.yaml | 50 ++++++++ .../expr2.2.6--repr-cmd-roundtrip.test.yaml | 79 ++++++++++++ conformance-tests/run_openjd_cli_tests.py | 23 ++-- 23 files changed, 1001 insertions(+), 7 deletions(-) create mode 100644 conformance-tests/2023-09/EXPR/jobs/expr2.2.1--bool-string-case-insensitive.test.yaml create mode 100644 conformance-tests/2023-09/EXPR/jobs/expr2.2.1--int-conversion-9e18-boundary.test.yaml create mode 100644 conformance-tests/2023-09/EXPR/jobs/expr2.2.1--len-unicode.test.yaml create mode 100644 conformance-tests/2023-09/EXPR/jobs/expr2.2.1--range-expr-whitespace-only.invalid.test.yaml create mode 100644 conformance-tests/2023-09/EXPR/jobs/expr2.2.2--abs-int64-min-overflow.invalid.test.yaml create mode 100644 conformance-tests/2023-09/EXPR/jobs/expr2.2.2--ceil-exceeds-int64.invalid.test.yaml create mode 100644 conformance-tests/2023-09/EXPR/jobs/expr2.2.2--floor-exceeds-int64.invalid.test.yaml create mode 100644 conformance-tests/2023-09/EXPR/jobs/expr2.2.2--sum-int64-overflow.invalid.test.yaml create mode 100644 conformance-tests/2023-09/EXPR/jobs/expr2.2.4--case-transforms-unicode.test.yaml create mode 100644 conformance-tests/2023-09/EXPR/jobs/expr2.2.4--padding-unicode.test.yaml create mode 100644 conformance-tests/2023-09/EXPR/jobs/expr2.2.4--predicates-uncased-and-empty.test.yaml create mode 100644 conformance-tests/2023-09/EXPR/jobs/expr2.2.4--string-classification-unicode.test.yaml create mode 100644 conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-backreference-rejected.invalid.test.yaml create mode 100644 conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-lookahead-rejected.invalid.test.yaml create mode 100644 conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-sub-dollar-group-reference-rejected.invalid.test.yaml create mode 100644 conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-sub-group-reference-rejected.invalid.test.yaml create mode 100644 conformance-tests/2023-09/EXPR/jobs/expr2.2.6--repr-json-roundtrip-adversarial.test.yaml create mode 100644 conformance-tests/2023-09/EXPR/jobs/expr2.2.6--repr-pwsh-roundtrip.test.yaml create mode 100644 conformance-tests/2023-09/EXPR/jobs/expr2.2.6--repr-py-roundtrip-adversarial.test.yaml create mode 100644 conformance-tests/2023-09/EXPR/jobs/expr2.2.6--repr-sh-roundtrip-adversarial.test.yaml create mode 100644 conformance-tests/2023-09/EXPR/jobs/expr2.2.6--repr-sh-roundtrip.test.yaml create mode 100644 conformance-tests/2023-09/EXPR/jobs/proposed/expr2.2.6--repr-cmd-roundtrip.test.yaml diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--bool-string-case-insensitive.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--bool-string-case-insensitive.test.yaml new file mode 100644 index 00000000..129d1558 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--bool-string-case-insensitive.test.yaml @@ -0,0 +1,36 @@ +# Expression Language §2.2.1: bool() string conversion is case-insensitive. +# The spec lists "1"/"true"/"on"/"yes" -> true and "0"/"false"/"off"/"no" -> false, +# matched case-insensitively. The existing bool-conversion fixture covers only +# lowercase word forms; this pins the numeric forms and mixed/upper case. +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'ONE:{{ bool("1") }}') + print(r'ZERO:{{ bool("0") }}') + print(r'TRUE_UC:{{ bool("TRUE") }}') + print(r'FALSE_UC:{{ bool("FALSE") }}') + print(r'YES_MIXED:{{ bool("YeS") }}') + print(r'NO_MIXED:{{ bool("No") }}') + print(r'ON_MIXED:{{ bool("On") }}') + print(r'OFF_UC:{{ bool("OFF") }}') +expected: + output: + - ONE:true + - ZERO:false + - TRUE_UC:true + - FALSE_UC:false + - YES_MIXED:true + - NO_MIXED:false + - ON_MIXED:true + - OFF_UC:false diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--int-conversion-9e18-boundary.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--int-conversion-9e18-boundary.test.yaml new file mode 100644 index 00000000..eecbc18a --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--int-conversion-9e18-boundary.test.yaml @@ -0,0 +1,27 @@ +# Expression Language §2.2.1: int(value: float) converts to integer, error if not exact. +# 9e18 is exactly representable as an IEEE-754 double (9e18 = 8789062500000000 * 2^10, +# and 8789062500000000 < 2^53), its value is an exact integer, and it lies inside the +# 64-bit signed range (int64 max is ~9.22e18). So int(9e18) must succeed and produce +# exactly 9000000000000000000. Same for the negative counterpart. +# This pins the near-boundary success case; overflow rejection above int64 max is +# covered by the §2.2.2 int64 overflow fixtures. +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'POS:{{ int(9e18) }}') + print(r'NEG:{{ int(-9e18) }}') +expected: + output: + - POS:9000000000000000000 + - NEG:-9000000000000000000 diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--len-unicode.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--len-unicode.test.yaml new file mode 100644 index 00000000..bb7f68f7 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--len-unicode.test.yaml @@ -0,0 +1,36 @@ +# Expression Language §2.2.1: len(s: string) returns the number of unicode CODEPOINTS. +# This is the first fixture with non-ASCII data in the EXPR suite. It distinguishes +# codepoint counting from byte counting (UTF-8) and from UTF-16 code-unit counting: +# - "日本語" is 3 codepoints (9 UTF-8 bytes) +# - "👍" (U+1F44D) is 1 codepoint (4 UTF-8 bytes, 2 UTF-16 code units / a surrogate +# pair — a UTF-16-based implementation would report 2) +# - "e" + U+0301 combining acute is 2 codepoints (1 grapheme cluster — a +# grapheme-based implementation would report 1) +# - U+1D54F (𝕏) is 1 codepoint (astral plane) +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'CJK:{{ len("日本語") }}') + print(r'EMOJI:{{ len("👍") }}') + print(r'COMBINING:{{ len("e\u0301") }}') + print(r'ASTRAL:{{ len("\U0001D54F") }}') + print(r'MIXED:{{ len("a👍日e\u0301") }}') +expected: + output: + - CJK:3 + - EMOJI:1 + - COMBINING:2 + - ASTRAL:1 + # a(1) + emoji(1) + CJK(1) + e(1) + combining mark(1) = 5 codepoints + - MIXED:5 diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--range-expr-whitespace-only.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--range-expr-whitespace-only.invalid.test.yaml new file mode 100644 index 00000000..33429c83 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--range-expr-whitespace-only.invalid.test.yaml @@ -0,0 +1,20 @@ +# Expression Language §2.2.1: range_expr(" ") (whitespace-only string) is an error. +# The spec note says: range_expr("") is an error, range_expr(" ") is an error, and +# range_expr([]) is an error — range expressions must contain at least one value. +# Empty-string and empty-list rejection are covered elsewhere; this pins the +# whitespace-only case. +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'R:{{ range_expr(" ") }}') diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--abs-int64-min-overflow.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--abs-int64-min-overflow.invalid.test.yaml new file mode 100644 index 00000000..ed48c5fc --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--abs-int64-min-overflow.invalid.test.yaml @@ -0,0 +1,26 @@ +# Expression Language §2.2.2 (abs) + §2.1.1 (64-bit signed integer arithmetic): +# the absolute value of int64 min (−2^63) is 2^63, which is outside the +# 64-bit signed range, so the expression must be rejected. The operand is +# built as (0 - 9223372036854775807 - 1) rather than written as the literal +# -9223372036854775808: a parser that lexes the latter as unary-minus +# applied to a 2^63 literal would reject it BEFORE abs ever runs, letting +# the fixture pass with no abs overflow check at all. The arithmetic form +# is a valid int64 computation (== int64 min), so the only possible error +# is abs's own overflow. +# The operator-level overflow family (expr2.1.1--int64-bounds) covers + - * ; +# this is the function-level counterpart. +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'ABS:{{ abs(0 - 9223372036854775807 - 1) }}') diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--ceil-exceeds-int64.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--ceil-exceeds-int64.invalid.test.yaml new file mode 100644 index 00000000..7e2a056f --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--ceil-exceeds-int64.invalid.test.yaml @@ -0,0 +1,23 @@ +# Expression Language §2.2.2 (ceil) + §2.1.1 (64-bit signed integer range): +# ceil(x: float) -> int must produce an int64 value. 1e300 is a finite +# float far outside the 64-bit signed range (~9.22e18), so the conversion +# must be rejected rather than saturating or wrapping. floor has its own +# fixture (expr2.2.2--floor-exceeds-int64); split so each function's +# overflow check is independently pinned. +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + let: + - 'c = ceil(1e300)' + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'C:{{ c }}') diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--floor-exceeds-int64.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--floor-exceeds-int64.invalid.test.yaml new file mode 100644 index 00000000..d4c37c9f --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--floor-exceeds-int64.invalid.test.yaml @@ -0,0 +1,23 @@ +# Expression Language §2.2.2 (floor) + §2.1.1 (64-bit signed integer range): +# floor(x: float) -> int must produce an int64 value. 1e300 is a finite +# float far outside the 64-bit signed range (~9.22e18), so the conversion +# must be rejected rather than saturating or wrapping. ceil has its own +# fixture (expr2.2.2--ceil-exceeds-int64): bundling both into one +# abort-on-first-error template would leave the second function unpinned. +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + let: + - 'f = floor(1e300)' + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'F:{{ f }}') diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--sum-int64-overflow.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--sum-int64-overflow.invalid.test.yaml new file mode 100644 index 00000000..eb17a720 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--sum-int64-overflow.invalid.test.yaml @@ -0,0 +1,18 @@ +# Expression Language §2.2.2 (sum) + §2.1.1 (64-bit signed integer arithmetic): +# sum(values: list[int]) accumulating past int64 max (2^63 - 1) must be rejected. +# 9223372036854775807 + 1 = 2^63 overflows the 64-bit signed range. +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'SUM:{{ sum([9223372036854775807, 1]) }}') diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--case-transforms-unicode.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--case-transforms-unicode.test.yaml new file mode 100644 index 00000000..2b43e8df --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--case-transforms-unicode.test.yaml @@ -0,0 +1,39 @@ +# Expression Language §2.2.4: upper()/lower() on non-ASCII input. +# Only unambiguous 1:1 Unicode simple case mappings are asserted here — these are +# locale-independent and identical under both simple and full case mapping. +# +# Deliberately NOT asserted (spec does not currently specify): +# - upper("ß"): "SS" under Unicode full case mapping, "ß" under simple mapping. +# The spec table says only "Convert to uppercase" and does not pick a mapping. +# - Turkish dotted/dotless i (upper("i") -> "İ" in tr locale): locale-dependent +# and the spec does not fix a locale. +# Both belong in the spec before they can be conformance-tested. +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'UP_EACUTE:{{ upper("é") }}') + print(r'LOW_EACUTE:{{ lower("É") }}') + print(r'UP_AUML:{{ upper("ä") }}') + print(r'LOW_AUML:{{ lower("Ä") }}') + print(r'UP_NTILDE:{{ upper("señor") }}') + print(r'UP_CJK_PASSTHROUGH:{{ upper("日本") }}') +expected: + output: + - UP_EACUTE:É + - LOW_EACUTE:é + - UP_AUML:Ä + - LOW_AUML:ä + - UP_NTILDE:SEÑOR + # CJK has no case; must pass through unchanged + - UP_CJK_PASSTHROUGH:日本 diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--padding-unicode.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--padding-unicode.test.yaml new file mode 100644 index 00000000..dc9fa75a --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--padding-unicode.test.yaml @@ -0,0 +1,38 @@ +# Expression Language §2.2.4: padding functions with non-ASCII (CJK wide) input. +# len() is specified in unicode codepoints, so padding width must also count +# codepoints — NOT display columns (CJK characters render two columns wide) and +# NOT bytes ("日本" is 6 UTF-8 bytes, which would already exceed width 5). +# (Codepoint counting for PADDING is an inference from len's definition — +# §2.2.4 never states the width unit for ljust/rjust/center/zfill; a spec +# sentence would make this normative rather than inferred.) +# ljust("日本", 5) -> "日本" + 3 spaces (2 codepoints + 3 pad = 5) +# rjust("日本", 5) -> 3 spaces + "日本" +# center("日本", 4) -> 1 space each side (even split; the odd-split side is +# spec-unspecified, see proposed/expr2.2.4--center-odd-padding) +# zfill("日", 3) -> "00日" +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'LJUST:[{{ ljust("日本", 5) }}]') + print(r'RJUST:[{{ rjust("日本", 5) }}]') + print(r'CENTER:[{{ center("日本", 4) }}]') + print(r'ZFILL:[{{ zfill("日", 3) }}]') + print(r'LEN_LJUST:{{ len(ljust("日本", 5)) }}') +expected: + output: + - "LJUST:[日本 ]" + - "RJUST:[ 日本]" + - "CENTER:[ 日本 ]" + - "ZFILL:[00日]" + - LEN_LJUST:5 diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--predicates-uncased-and-empty.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--predicates-uncased-and-empty.test.yaml new file mode 100644 index 00000000..c2addc71 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--predicates-uncased-and-empty.test.yaml @@ -0,0 +1,41 @@ +# Expression Language §2.2.4: is* predicate clauses that are easy to get wrong. +# - isupper/islower require "at least one cased character", so a digits-only +# string is false for BOTH (not true for either). +# - isdigit/isalpha/isalnum/isspace require "string is non-empty", so "" is +# false for all four; isupper/islower on "" have no cased character -> false. +# - isascii is the exception: specified as true when "all characters are ASCII +# (U+0000–U+007F), or string is empty". +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'UPPER_DIGITS:{{ isupper("123") }}') + print(r'LOWER_DIGITS:{{ islower("123") }}') + print(r'E_ISDIGIT:{{ isdigit("") }}') + print(r'E_ISALPHA:{{ isalpha("") }}') + print(r'E_ISALNUM:{{ isalnum("") }}') + print(r'E_ISSPACE:{{ isspace("") }}') + print(r'E_ISUPPER:{{ isupper("") }}') + print(r'E_ISLOWER:{{ islower("") }}') + print(r'E_ISASCII:{{ isascii("") }}') +expected: + output: + - UPPER_DIGITS:false + - LOWER_DIGITS:false + - E_ISDIGIT:false + - E_ISALPHA:false + - E_ISALNUM:false + - E_ISSPACE:false + - E_ISUPPER:false + - E_ISLOWER:false + - E_ISASCII:true diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--string-classification-unicode.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--string-classification-unicode.test.yaml new file mode 100644 index 00000000..b7aa05ea --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--string-classification-unicode.test.yaml @@ -0,0 +1,46 @@ +# Expression Language §2.2.4: classification predicates on non-ASCII data. +# - isascii is explicitly specified as U+0000–U+007F, so any non-ASCII +# character must make it false. +# - isalpha says "all characters are alphabetic"; CJK ideographs and accented +# Latin letters are Unicode-alphabetic. This is consistent with §2.2.5's +# explicit Unicode posture for \w and \d. CAVEAT: the spec wording is +# exactly parallel to isdigit's, which is treated as ambiguous (see +# below) — the ALPHA assertions are kept only because both reference +# implementations agree (Python str.isalpha and Rust char::is_alphabetic +# are both Unicode); a spec sentence defining "alphabetic" should ratify +# this. If the spec instead rules ASCII-only, these lines flip. +# +# NOT asserted here: isdigit on non-ASCII digits (e.g. Arabic-Indic "٣"). +# Python's str.isdigit("٣") is True while openjd-rs returns false — a live +# implementation divergence on spec-ambiguous text ("all characters are digits"). +# See proposed/expr2.2.4--isdigit-unicode.test.yaml. +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'ASCII_PLAIN:{{ isascii("abc123") }}') + print(r'ASCII_ARABIC_DIGIT:{{ isascii("٣") }}') + print(r'ASCII_CAFE:{{ isascii("café") }}') + print(r'ASCII_CJK:{{ isascii("日本") }}') + print(r'ASCII_MIXED:{{ isascii("abc👍") }}') + print(r'ALPHA_CJK:{{ isalpha("日本") }}') + print(r'ALPHA_ACCENT:{{ isalpha("café") }}') +expected: + output: + - ASCII_PLAIN:true + - ASCII_ARABIC_DIGIT:false + - ASCII_CAFE:false + - ASCII_CJK:false + - ASCII_MIXED:false + - ALPHA_CJK:true + - ALPHA_ACCENT:true diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-backreference-rejected.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-backreference-rejected.invalid.test.yaml new file mode 100644 index 00000000..5a4de11b --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-backreference-rejected.invalid.test.yaml @@ -0,0 +1,27 @@ +# Expression Language §2.2.5: backreferences (\1, \2, ...) in patterns are +# explicitly listed as "Not supported" (Python `re` feature absent from Rust's +# `regex` crate). A template using a backreference pattern must be rejected. +# +# DIALECT-DIVERGENCE PROBE: Python's `re` natively ACCEPTS backreferences +# (r"(abc)\1" matches "abcabc"), so a Python-backed implementation must actively +# screen patterns to reject this. Rust's `regex` crate fails to compile it. +# openjd-rs rejects with: "Unsupported regex feature: backreferences". +# Note the raw string r"(abc)\1" — in a non-raw string "\1" is not a defined +# §1.1.5 escape and would not reach the regex engine as backslash-one. +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + let: + - 'm = re_search("abcabc", r"(abc)\1")' + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'M:{{ m }}') diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-lookahead-rejected.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-lookahead-rejected.invalid.test.yaml new file mode 100644 index 00000000..fe6bfd98 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-lookahead-rejected.invalid.test.yaml @@ -0,0 +1,26 @@ +# Expression Language §2.2.5: the regex syntax is the intersection of Python's +# `re` and Rust's `regex` crate, and lookahead ((?=...), (?!...)) is explicitly +# listed as "Not supported". A template using a lookahead pattern is therefore +# non-conforming and must be rejected. +# +# DIALECT-DIVERGENCE PROBE: Python's `re` natively ACCEPTS lookahead, so a +# Python-backed implementation must actively screen patterns to reject this — +# passing it straight to `re` would silently accept it and diverge. +# openjd-rs rejects with: "Unsupported regex feature: lookahead". +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + let: + - 'm = re_search("foo123", r"foo(?=\d)")' + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'M:{{ m }}') diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-sub-dollar-group-reference-rejected.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-sub-dollar-group-reference-rejected.invalid.test.yaml new file mode 100644 index 00000000..c2d68293 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-sub-dollar-group-reference-rejected.invalid.test.yaml @@ -0,0 +1,27 @@ +# Expression Language §2.2.5: re_sub's repl string "is literal text — group +# references (\1, \g<1>, $1, ${1}) are not supported and are errors." +# +# DIALECT-DIVERGENCE PROBE, Rust-style form ($1): Rust's +# regex::replace_all expands $1, so an implementation that forwards repl +# unchecked to a Rust engine would EXPAND it instead of erroring (a Python +# engine would pass it through literally — also a violation, of the +# must-error rule). The Python-style form (\1) has its own fixture +# (expr2.2.5--re-sub-group-reference-rejected); split so each dialect's +# rejection is independently pinned. +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + let: + - 'dollar_ref = re_sub("frame_001", r"(\d+)", "$1$1")' + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'DOLLAR:{{ dollar_ref }}') diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-sub-group-reference-rejected.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-sub-group-reference-rejected.invalid.test.yaml new file mode 100644 index 00000000..b9c75f1f --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-sub-group-reference-rejected.invalid.test.yaml @@ -0,0 +1,28 @@ +# Expression Language §2.2.5: re_sub's repl string "is literal text — group +# references (\1, \g<1>, $1, ${1}) are not supported and are errors." +# This is spec-explicit rejection, not merely undefined behaviour. +# +# DIALECT-DIVERGENCE PROBE, Python-style form (\1): Python's re.sub expands +# \1, so an implementation that forwards repl unchecked to a Python engine +# would EXPAND it instead of erroring. The Rust-style form ($1) has its own +# fixture (expr2.2.5--re-sub-dollar-group-reference-rejected): bundling +# both into one abort-on-first-error template would leave the second form +# unpinned. openjd-rs rejects with: +# "Group references in replacement strings are not supported". +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + let: + - 'backslash_ref = re_sub("frame_001", r"(\d+)", r"\1\1")' + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'BS:{{ backslash_ref }}') diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.6--repr-json-roundtrip-adversarial.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.6--repr-json-roundtrip-adversarial.test.yaml new file mode 100644 index 00000000..5d6c665e --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.6--repr-json-roundtrip-adversarial.test.yaml @@ -0,0 +1,103 @@ +# Expression Language §2.2.6: repr_json round-trip through a real JSON parser. +# The only sound test of JSON serialization is to parse the output back and +# assert the ORIGINAL value returns. Each quoted value is passed as its own +# argv element (no shell involved) and parsed with Python's json.loads. +# Comparison is by value AND type, so true emitted as 1 fails. +# Battery: the JSON escape branches (double quote, backslash, newline, tab), +# shell metacharacters (must stay literal text), unicode (either raw UTF-8 or +# \uXXXX escaping is conforming — both parse back identically), empty string, +# and the non-string overloads (null/bool/int/float/list). +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + let: + - 'j01 = repr_json("he said \"hi\"")' + - 'j02 = repr_json(r"back\slash")' + - 'j03 = repr_json("line1\nline2")' + - 'j04 = repr_json("tab\there")' + - 'j05 = repr_json("héllo 日本 👍")' + - 'j06 = repr_json("")' + - 'j07 = repr_json("; echo INJ $(pwn) `tick`")' + - 'j08 = repr_json("../../etc/passwd --rm")' + - 'j09 = repr_json(null)' + - 'j10 = repr_json(true)' + - 'j11 = repr_json(false)' + - 'j12 = repr_json(42)' + - 'j13 = repr_json(3.14)' + - 'j14 = repr_json([1, 2, 3])' + - 'j15 = repr_json(["a b", "c\"d"])' + script: + actions: + onRun: + command: python + args: + - -c + - | + import json + import sys + want = [ + 'he said "hi"', + "back\\slash", + "line1\nline2", + "tab\there", + "héllo 日本 👍", + "", + "; echo INJ $(pwn) `tick`", + "../../etc/passwd --rm", + None, + True, + False, + 42, + 3.14, + [1, 2, 3], + ["a b", 'c"d'], + ] + args = sys.argv[1:] + print("COUNT:" + str(len(args))) + for i, (a, w) in enumerate(zip(args, want), 1): + got = json.loads(a) + ok = got == w and type(got) is type(w) + label = "P" + str(i).zfill(2) + if ok: + print(label + ":PASS") + else: + print(label + ":FAIL got=" + repr(got) + " want=" + repr(w)) + - "{{ j01 }}" + - "{{ j02 }}" + - "{{ j03 }}" + - "{{ j04 }}" + - "{{ j05 }}" + - "{{ j06 }}" + - "{{ j07 }}" + - "{{ j08 }}" + - "{{ j09 }}" + - "{{ j10 }}" + - "{{ j11 }}" + - "{{ j12 }}" + - "{{ j13 }}" + - "{{ j14 }}" + - "{{ j15 }}" +expected: + output: + - COUNT:15 + - P01:PASS + - P02:PASS + - P03:PASS + - P04:PASS + - P05:PASS + - P06:PASS + - P07:PASS + - P08:PASS + - P09:PASS + - P10:PASS + - P11:PASS + - P12:PASS + - P13:PASS + - P14:PASS + - P15:PASS + forbidden: + - FAIL diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.6--repr-pwsh-roundtrip.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.6--repr-pwsh-roundtrip.test.yaml new file mode 100644 index 00000000..c8e9e12f --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.6--repr-pwsh-roundtrip.test.yaml @@ -0,0 +1,79 @@ +# Expression Language §2.2.6: repr_pwsh round-trip through a real PowerShell. +# Each repr_pwsh() result is a PowerShell literal; it is assigned to a variable +# by the actual PowerShell parser and compared (case-sensitively) against the +# original value inside PowerShell. Non-string overloads also assert type: +# $true must arrive as [bool], 42 as an integer, lists as arrays. +# Adversarial strings: embedded single quote (the escape branch: doubled), +# double quote, backtick (PowerShell's escape char), $env: expansion attempt +# (single-quoted literals must NOT expand), metacharacters, unicode, empty. +# +# WINDOWS-UNVERIFIED: authored on a POSIX host where this test skips. +runOn: [windows] +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + let: + - 'q01 = repr_pwsh("it''s")' + - 'q02 = repr_pwsh("say \"hi\"")' + - 'q03 = repr_pwsh("a`b")' + - 'q04 = repr_pwsh("$env:PATH")' + - 'q05 = repr_pwsh("a & b; c | d")' + - 'q06 = repr_pwsh("héllo 日本")' + - 'q07 = repr_pwsh("")' + - 'q08 = repr_pwsh(42)' + - 'q09 = repr_pwsh(3.14)' + - 'q10 = repr_pwsh(true)' + - 'q11 = repr_pwsh(false)' + - 'q12 = repr_pwsh(["a b", "c''d"])' + script: + actions: + onRun: + command: powershell + args: + - -NoProfile + - -Command + - | + $v01 = {{ q01 }} + if ($v01 -ceq 'it''s') { 'P01:PASS' } else { 'P01:FAIL [' + $v01 + ']' } + $v02 = {{ q02 }} + if ($v02 -ceq 'say "hi"') { 'P02:PASS' } else { 'P02:FAIL [' + $v02 + ']' } + $v03 = {{ q03 }} + if ($v03 -ceq 'a`b') { 'P03:PASS' } else { 'P03:FAIL [' + $v03 + ']' } + $v04 = {{ q04 }} + if ($v04 -ceq '$env:PATH') { 'P04:PASS' } else { 'P04:FAIL [' + $v04 + ']' } + $v05 = {{ q05 }} + if ($v05 -ceq 'a & b; c | d') { 'P05:PASS' } else { 'P05:FAIL [' + $v05 + ']' } + $v06 = {{ q06 }} + if ($v06 -ceq 'héllo 日本') { 'P06:PASS' } else { 'P06:FAIL [' + $v06 + ']' } + $v07 = {{ q07 }} + if ($v07 -ceq '') { 'P07:PASS' } else { 'P07:FAIL [' + $v07 + ']' } + $v08 = {{ q08 }} + if ($v08 -eq 42 -and $v08 -isnot [string]) { 'P08:PASS' } else { 'P08:FAIL [' + $v08 + ']' } + $v09 = {{ q09 }} + if ($v09 -eq 3.14 -and $v09 -isnot [string]) { 'P09:PASS' } else { 'P09:FAIL [' + $v09 + ']' } + $v10 = {{ q10 }} + if ($v10 -is [bool] -and $v10) { 'P10:PASS' } else { 'P10:FAIL [' + $v10 + ']' } + $v11 = {{ q11 }} + if ($v11 -is [bool] -and -not $v11) { 'P11:PASS' } else { 'P11:FAIL [' + $v11 + ']' } + $v12 = {{ q12 }} + if ($v12.Count -eq 2 -and $v12[0] -ceq 'a b' -and $v12[1] -ceq "c'd") { 'P12:PASS' } else { 'P12:FAIL' } +expected: + output: + - P01:PASS + - P02:PASS + - P03:PASS + - P04:PASS + - P05:PASS + - P06:PASS + - P07:PASS + - P08:PASS + - P09:PASS + - P10:PASS + - P11:PASS + - P12:PASS + forbidden: + - FAIL diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.6--repr-py-roundtrip-adversarial.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.6--repr-py-roundtrip-adversarial.test.yaml new file mode 100644 index 00000000..6bb0d022 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.6--repr-py-roundtrip-adversarial.test.yaml @@ -0,0 +1,112 @@ +# Expression Language §2.2.6: repr_py round-trip through the real Python parser. +# repr_py output is specified to follow Python's repr(); the only sound test is +# to hand the quoted text back to Python and assert the ORIGINAL value returns. +# Each quoted value is passed as its own argv element (no shell involved) and +# parsed with ast.literal_eval — the genuine Python literal parser. Comparison +# is by value AND type, so e.g. True emitted as 1 fails. +# Battery: shell metacharacters (must stay literal), nested quotes, backslash, +# glob, traversal, leading dash, unicode, empty, whitespace, tab, +# plus the non-string overloads (null/bool/int/float/list). +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + let: + - 'q01 = repr_py("; echo INJ")' + - 'q02 = repr_py("$(echo INJ) `tick`")' + - 'q03 = repr_py("it''s \"quoted\"")' + - 'q04 = repr_py(r"back\slash")' + - 'q05 = repr_py("*.txt ../../etc/passwd")' + - 'q06 = repr_py("--rm -rf")' + - 'q07 = repr_py("héllo 日本 👍")' + - 'q08 = repr_py("")' + - 'q09 = repr_py(" ")' + - 'q10 = repr_py("a\tb")' + # Embedded newline ("a\nb") is NOT tested here: openjd-rs emits a raw + # newline inside the quotes, which is not a valid Python string literal. + # See proposed/expr2.2.6--repr-py-newline-roundtrip.test.yaml. + - 'q11 = repr_py("per%cent $HOME")' + - 'q12 = repr_py(null)' + - 'q13 = repr_py(true)' + - 'q14 = repr_py(42)' + - 'q15 = repr_py(3.14)' + # List literals are homogeneous per §1.2.6, so a string list exercises the + # list overload (element quoting + nesting). + - 'q16 = repr_py(["a b", "c''d"])' + script: + actions: + onRun: + command: python + args: + - -c + - | + import ast + import sys + want = [ + "; echo INJ", + "$(echo INJ) `tick`", + 'it\'s "quoted"', + "back\\slash", + "*.txt ../../etc/passwd", + "--rm -rf", + "héllo 日本 👍", + "", + " ", + "a\tb", + "per%cent $HOME", + None, + True, + 42, + 3.14, + ["a b", "c'd"], + ] + args = sys.argv[1:] + print("COUNT:" + str(len(args))) + for i, (a, w) in enumerate(zip(args, want), 1): + got = ast.literal_eval(a) + ok = got == w and type(got) is type(w) + label = "P" + str(i).zfill(2) + if ok: + print(label + ":PASS") + else: + print(label + ":FAIL got=" + repr(got) + " want=" + repr(w)) + - "{{ q01 }}" + - "{{ q02 }}" + - "{{ q03 }}" + - "{{ q04 }}" + - "{{ q05 }}" + - "{{ q06 }}" + - "{{ q07 }}" + - "{{ q08 }}" + - "{{ q09 }}" + - "{{ q10 }}" + - "{{ q11 }}" + - "{{ q12 }}" + - "{{ q13 }}" + - "{{ q14 }}" + - "{{ q15 }}" + - "{{ q16 }}" +expected: + output: + - COUNT:16 + - P01:PASS + - P02:PASS + - P03:PASS + - P04:PASS + - P05:PASS + - P06:PASS + - P07:PASS + - P08:PASS + - P09:PASS + - P10:PASS + - P11:PASS + - P12:PASS + - P13:PASS + - P14:PASS + - P15:PASS + - P16:PASS + forbidden: + - FAIL diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.6--repr-sh-roundtrip-adversarial.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.6--repr-sh-roundtrip-adversarial.test.yaml new file mode 100644 index 00000000..490a56b3 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.6--repr-sh-roundtrip-adversarial.test.yaml @@ -0,0 +1,81 @@ +# Expression Language §2.2.6: repr_sh adversarial round-trip through bash. +# repr_sh is the quoting surface RFC 0008 relies on to safely reconstruct +# command lines, so every escape branch is executed through the real shell: +# metacharacters, command/variable substitution, nested quotes, backslash, +# glob, path traversal, leading dash, unicode, empty/whitespace-only, tab, +# newline. Each payload must come back byte-identical inside the brackets. +# If quoting leaks, printf either word-splits (bracket line changes) or the +# substitution executes (forbidden lines below appear). +runOn: [posix] +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + let: + - 'q01 = repr_sh("; echo INJ")' + - 'q02 = repr_sh("a | b & c")' + - 'q03 = repr_sh("$(echo INJ)")' + - 'q04 = repr_sh("`echo INJ`")' + - 'q05 = repr_sh("$HOME")' + - 'q06 = repr_sh("it''s \"quoted\"")' + - 'q07 = repr_sh(r"back\slash")' + - 'q08 = repr_sh("*.txt")' + - 'q09 = repr_sh("../../etc/passwd")' + - 'q10 = repr_sh("--rm -rf")' + - 'q11 = repr_sh("héllo 日本 👍")' + - 'q12 = repr_sh("")' + - 'q13 = repr_sh(" ")' + - 'q14 = repr_sh("a\tb")' + - 'q15 = repr_sh("a\nb")' + script: + actions: + onRun: + command: bash + args: + - -c + - | + printf 'P01:[%s]\n' {{ q01 }} + printf 'P02:[%s]\n' {{ q02 }} + printf 'P03:[%s]\n' {{ q03 }} + printf 'P04:[%s]\n' {{ q04 }} + printf 'P05:[%s]\n' {{ q05 }} + printf 'P06:[%s]\n' {{ q06 }} + printf 'P07:[%s]\n' {{ q07 }} + printf 'P08:[%s]\n' {{ q08 }} + printf 'P09:[%s]\n' {{ q09 }} + printf 'P10:[%s]\n' {{ q10 }} + printf 'P11:[%s]\n' {{ q11 }} + printf 'P12:[%s]\n' {{ q12 }} + printf 'P13:[%s]\n' {{ q13 }} + printf 'P14:[%s]\n' {{ q14 }} + # The embedded-newline payload spans two output lines, and the CLI + # prefixes each log line, so it cannot be asserted as one substring. + # Compare byte-for-byte inside the shell instead. + v15={{ q15 }} + if [ "$v15" = $'a\nb' ]; then echo 'P15:PASS'; else echo 'P15:FAIL'; fi +expected: + output: + - "P01:[; echo INJ]" + - "P02:[a | b & c]" + - "P03:[$(echo INJ)]" + - "P04:[`echo INJ`]" + - "P05:[$HOME]" + - "P06:[it's \"quoted\"]" + - "P07:[back\\slash]" + - "P08:[*.txt]" + - "P09:[../../etc/passwd]" + - "P10:[--rm -rf]" + - "P11:[héllo 日本 👍]" + - "P12:[]" + - "P13:[ ]" + - "P14:[a\tb]" + - "P15:PASS" + forbidden: + # These appear only if command/variable substitution executed instead of + # round-tripping as literal text. + - "P03:[INJ]" + - "P04:[INJ]" + - "P15:FAIL" diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.6--repr-sh-roundtrip.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.6--repr-sh-roundtrip.test.yaml new file mode 100644 index 00000000..24d6d24f --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.6--repr-sh-roundtrip.test.yaml @@ -0,0 +1,50 @@ +# Expression Language §2.2.6: repr_sh round-trip through a real POSIX shell. +# Quoting is only correct if the target shell agrees: the existing repr-sh +# fixture substring-matches the quoted text; this one executes it. Each quoted +# value is parsed by bash and printed back between brackets — the assertion is +# that the ORIGINAL bytes return. The list overload is round-tripped with +# `set --` to prove it parses back into exactly the original words. +# Any valid POSIX quoting passes (both Python shlex.quote and Rust +# shlex::try_quote forms round-trip identically). +runOn: [posix] +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + let: + - 'q_simple = repr_sh("hello")' + - 'q_spaces = repr_sh("hello world")' + - 'q_squote = repr_sh("it''s")' + - 'q_empty = repr_sh("")' + - 'q_path = repr_sh(path("/tmp/dir with space/out.exr"))' + - 'q_list = repr_sh(["render", "-o", "out file.exr"])' + script: + actions: + onRun: + command: bash + args: + - -c + - | + printf 'SIMPLE:[%s]\n' {{ q_simple }} + printf 'SPACES:[%s]\n' {{ q_spaces }} + printf 'SQUOTE:[%s]\n' {{ q_squote }} + printf 'EMPTY:[%s]\n' {{ q_empty }} + printf 'PATH:[%s]\n' {{ q_path }} + set -- {{ q_list }} + printf 'LIST_ARGC:%s\n' "$#" + printf 'LIST_ARG:[%s]\n' "$@" +expected: + output: + - "SIMPLE:[hello]" + - "SPACES:[hello world]" + - "SQUOTE:[it's]" + - "EMPTY:[]" + - "PATH:[/tmp/dir with space/out.exr]" + # The quoted list must parse back into exactly the 3 original words + - "LIST_ARGC:3" + - "LIST_ARG:[render]" + - "LIST_ARG:[-o]" + - "LIST_ARG:[out file.exr]" diff --git a/conformance-tests/2023-09/EXPR/jobs/proposed/expr2.2.6--repr-cmd-roundtrip.test.yaml b/conformance-tests/2023-09/EXPR/jobs/proposed/expr2.2.6--repr-cmd-roundtrip.test.yaml new file mode 100644 index 00000000..c7398447 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/proposed/expr2.2.6--repr-cmd-roundtrip.test.yaml @@ -0,0 +1,79 @@ +# Expression Language §2.2.6: repr_cmd round-trip through a real cmd.exe. +# The quoted values are embedded in a cmd /c command line (the documented use +# case) and handed to python, which reports whether each argv round-tripped +# byte-identical to the original. +# +# Payload scope is deliberately conservative: the spec scopes % doubling and +# ^^! escaping to .bat / EnableDelayedExpansion contexts and states that +# repr_cmd "does not preserve every input string in all cmd.exe modes", so +# %VAR% and ! payloads are NOT asserted here — they are not round-trip +# guarantees in a plain cmd /c context. Embedded double quotes are also +# excluded pending verification of ^" handling on a Windows runner. +# The caret payload ("a ^ b") is likewise excluded: the spec's own example +# output is "a ^^ b", and cmd.exe does not process ^ as an escape inside +# double-quoted spans, so a spec-conforming repr_cmd delivers a doubled +# caret to the child argv — the payload cannot round-trip under the +# documented quoting model. If real cmd behavior differs, that is a spec +# bug to resolve before asserting it here. +# +# PARKED after a real Windows CI run (2026-08-15, openjd-rs windows-latest, +# PYTHONUTF8=1 in effect): the session log ends immediately after the +# "Running Task" banner — the cmd.exe child emits NO stdout at all (not a +# quoting mismatch; nothing). The sibling repr_pwsh/repr_py/repr_json +# round-trips pass on the same runner, so the failure is specific to the +# cmd /c action path. Needs hands-on Windows investigation (candidate +# causes: console codepage for cmd children, cmd /c argument-joining of +# the multi-payload command line, or the >- folded scalar's newline +# handling) before this can join the live suite. Promote once it passes a +# real Windows runner. +runOn: [windows] +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + let: + - 'q01 = repr_cmd("a & b")' + - 'q02 = repr_cmd("a | b")' + - 'q03 = repr_cmd("(paren)")' + - 'q04 = repr_cmd("less")' + - 'q05 = repr_cmd("plain")' + - 'q06 = repr_cmd("has space")' + - 'q07 = repr_cmd(r"..\..\etc")' + - 'q08 = repr_cmd("--rm -rf")' + - 'q09 = repr_cmd(["render", "-o", "out file.exr"])' + script: + actions: + onRun: + command: cmd + args: + - /c + - >- + python -c "import sys; want=['a & b', 'a | b', '(paren)', + 'less', 'plain', 'has space', '..\\..\\etc', '--rm -rf', + 'render', '-o', 'out file.exr']; + args=sys.argv[1:]; + print('COUNT:' + str(len(args))); + [print('P' + str(i).zfill(2) + (':PASS' if a == w else ':FAIL got=' + repr(a) + ' want=' + repr(w))) + for i, (a, w) in enumerate(zip(args, want), 1)]" + {{ q01 }} {{ q02 }} {{ q03 }} {{ q04 }} {{ q05 }} {{ q06 }} {{ q07 }} {{ q08 }} {{ q09 }} +expected: + output: + # The list overload joins into space-separated quoted words, so the 8 scalar + # payloads plus 3 list words arrive as 11 argv entries. + - COUNT:11 + - P01:PASS + - P02:PASS + - P03:PASS + - P04:PASS + - P05:PASS + - P06:PASS + - P07:PASS + - P08:PASS + - P09:PASS + - P10:PASS + - P11:PASS + forbidden: + - FAIL diff --git a/conformance-tests/run_openjd_cli_tests.py b/conformance-tests/run_openjd_cli_tests.py index 1118d420..24989dd9 100644 --- a/conformance-tests/run_openjd_cli_tests.py +++ b/conformance-tests/run_openjd_cli_tests.py @@ -21,6 +21,7 @@ import fnmatch import io import json +import os import re import subprocess import sys @@ -33,13 +34,21 @@ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8') +# Fixture task commands are python programs that print UTF-8 sentinels. On +# Windows, a child python writing to a pipe uses the locale encoding +# (cp1252) unless told otherwise, which mangles non-ASCII output before it +# reaches the CLI's session log. Propagate UTF-8 mode to every child. +os.environ.setdefault("PYTHONUTF8", "1") + OPERATING_SYSTEM = "windows" if sys.platform == "win32" else "posix" CONFORMANCE_DIR = Path(__file__).parent def load_yaml_or_json(path: Path): - with open(path) as f: + # Explicit UTF-8: fixture files contain non-ASCII (unicode string-function + # tests) and Windows' default locale encoding (cp1252) cannot decode them. + with open(path, encoding="utf-8") as f: content = f.read() if path.suffix == ".json": return json.loads(content) @@ -49,7 +58,7 @@ def load_yaml_or_json(path: Path): def run_check(template_path: Path) -> tuple[bool, str]: result = subprocess.run( ["openjd", "check", str(template_path)], - capture_output=True, text=True, + capture_output=True, text=True, encoding="utf-8", errors="replace", ) return result.returncode == 0, result.stderr or result.stdout @@ -97,7 +106,7 @@ def run_job(test_path: Path) -> tuple[bool, str] | None: # Write template template_path = tmpdir / "template.yaml" - with open(template_path, "w") as f: + with open(template_path, "w", encoding="utf-8") as f: yaml.dump(test["template"], f) cmd = ["openjd", "run", str(template_path)] @@ -105,25 +114,25 @@ def run_job(test_path: Path) -> tuple[bool, str] | None: # Parameters if "parameters" in test: params_path = tmpdir / "parameters.yaml" - with open(params_path, "w") as f: + with open(params_path, "w", encoding="utf-8") as f: yaml.dump(test["parameters"], f) cmd.extend(["-p", f"file://{params_path}"]) # Environment templates for i, env in enumerate(test.get("environments", [])): env_path = tmpdir / f"env{i}.yaml" - with open(env_path, "w") as f: + with open(env_path, "w", encoding="utf-8") as f: yaml.dump(env, f) cmd.extend(["--env", str(env_path)]) # Path mapping if "pathMapping" in test: pm_path = tmpdir / "pathmapping.json" - with open(pm_path, "w") as f: + with open(pm_path, "w", encoding="utf-8") as f: json.dump({"version": "pathmapping-1.0", "path_mapping_rules": test["pathMapping"]}, f) cmd.extend(["--path-mapping-rules", f"file://{pm_path}"]) - result = subprocess.run(cmd, capture_output=True, text=True) + result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace") output = result.stdout + result.stderr # For invalid tests, any failure (non-zero exit code) is acceptable