3636 "NotifyCancelMethod" ,
3737 "ScriptRunnerBase" ,
3838 "apply_let_bindings" ,
39+ "resolve_action_arg_values" ,
3940 "resolve_effective_cancelation" ,
4041 "resolve_optional_int_field" ,
4142)
@@ -96,6 +97,48 @@ class NotifyCancelMethod(CancelMethod):
9697 """Amount of time after a SIGTERM to wait to do the SIGKILL"""
9798
9899
100+ def resolve_action_arg_values (args : Optional [Sequence ], symtab : SymbolTable ) -> list [str ]:
101+ """Resolve an action's ``args`` field into its flat list of argument
102+ strings (excluding the command).
103+
104+ RFC 0005 §1.3.2 argument semantics, mirroring openjd-rs's
105+ resolve_action_args: a whole-field expression argument resolves typed —
106+ a null result skips the argument, a list result flattens inline (one
107+ argument per element, rendered with the engine's display coercion), and
108+ a scalar becomes a single argument. Multi-segment format strings and
109+ legacy (non-EXPR) expressions resolve to their string form.
110+
111+ Shared by the enforcement path (:meth:`ScriptRunnerBase._run_action`)
112+ and the RFC 0008 ``WrappedAction.Args`` injection, so a wrap hook sees
113+ exactly the arguments the wrapped action would have run with unwrapped.
114+
115+ Raises:
116+ FormatStringError: If an argument's expression cannot be resolved.
117+ """
118+ resolved : list [str ] = []
119+ if args is not None :
120+ for arg in args :
121+ try :
122+ value = arg .resolve_value (symtab = symtab )
123+ except FormatStringError :
124+ # Mirror openjd-rs's resolve_action_args: when typed
125+ # resolution fails (e.g. a legacy-parsed expression meeting
126+ # a typed symbol value), fall back to plain string
127+ # resolution — which raises FormatStringError itself if the
128+ # argument is genuinely unresolvable.
129+ resolved .append (arg .resolve (symtab = symtab ))
130+ continue
131+ if isinstance (value , str ):
132+ resolved .append (value )
133+ elif getattr (value , "is_null" , False ):
134+ continue
135+ elif str (getattr (value , "type" , "" )).startswith ("list[" ):
136+ resolved .extend (str (element ) for element in value )
137+ else :
138+ resolved .append (str (value ))
139+ return resolved
140+
141+
99142def resolve_optional_int_field (
100143 value : Any ,
101144 symtab : SymbolTable ,
@@ -111,13 +154,16 @@ def resolve_optional_int_field(
111154 - ``None`` (field omitted) stays ``None``.
112155 - A literal ``int`` passes through unchecked: literal values were
113156 bounds-checked by the static validator at parse time.
114- - A FormatString (FEATURE_BUNDLE_1) is resolved against ``symtab``. A
115- whole-field expression that resolves to null renders as the empty
116- string and is treated as if the field were not provided (``None`` —
117- the caller applies any positional schema default). Otherwise the
118- resolved value must be an integer within the given bounds; the bounds
119- apply here because format-string values could not be checked at parse
120- time.
157+ - A FormatString (FEATURE_BUNDLE_1) is resolved against ``symtab``
158+ using typed resolution. A whole-field expression that resolves to a
159+ typed null is treated as if the field were not provided (``None`` —
160+ the caller applies any positional schema default). Any other result
161+ — including a genuine empty string — must be an integer within the
162+ given bounds; the bounds apply here because format-string values
163+ could not be checked at parse time. This matches the openjd-rs
164+ runtime (resolve_action_timeout / resolve_notify_period_seconds),
165+ which only treats an ExprValue::Null result as "field omitted" and
166+ errors on an empty string.
121167
122168 Raises:
123169 ValueError: If the resolved value is not an integer, or violates
@@ -128,9 +174,15 @@ def resolve_optional_int_field(
128174 return None
129175 if isinstance (value , int ):
130176 return value
131- resolved = value .resolve (symtab = symtab )
132- if resolved == "" :
177+ # Typed resolution: a whole-field EXPR expression yields the engine's
178+ # typed value, so a null result is distinguishable from a genuine empty
179+ # string. Multi-segment and legacy (non-EXPR) format strings fall back
180+ # to plain string resolution — correct, since typed nulls only exist
181+ # under EXPR whole-field semantics (Template Schemas 5.3).
182+ resolved_value = value .resolve_value (symtab = symtab )
183+ if getattr (resolved_value , "is_null" , False ):
133184 return None
185+ resolved = str (resolved_value )
134186 if ge is not None and le is not None :
135187 constraint = f"between { ge } and { le } "
136188 elif ge == 1 and le is None :
@@ -195,19 +247,23 @@ def resolve_period(period: Any) -> Optional[int]:
195247 if cancelation is None :
196248 return (None , None )
197249 if isinstance (cancelation , CancelationMethodDeferred_2023_09 ):
198- # Null semantics apply only to a whole-field expression
199- # ("{{ ... }}" with no surrounding text, target type string? —
200- # Template Schemas 5.3). A normal format string that happens to
201- # resolve to the empty string is NOT null; it falls through to the
202- # "must resolve to..." error below.
203- is_whole_field = cancelation .mode .whole_field_expression () is not None
204- mode = cancelation .mode .resolve (symtab = symtab )
205- if mode == "" and is_whole_field :
250+ # Typed resolution. Null semantics apply only to a whole-field
251+ # expression ("{{ ... }}" with no surrounding text, target type
252+ # string? — Template Schemas 5.3), and resolve_value only yields a
253+ # typed null for a whole-field EXPR expression; every other format
254+ # string resolves to its plain string form. A format string that
255+ # happens to resolve to the empty string is NOT null; it falls
256+ # through to the "must resolve to..." error below (matching the
257+ # openjd-rs runtime, which errors on any non-null, non-mode-name
258+ # result).
259+ mode_value = cancelation .mode .resolve_value (symtab = symtab )
260+ if getattr (mode_value , "is_null" , False ):
206261 # Null mode drops the ENTIRE cancelation object: mode is the
207262 # object's required discriminator, so an "omitted" mode cannot
208263 # leave a partial object behind. The action behaves exactly as
209264 # if no <Cancelation> were declared.
210265 return (None , None )
266+ mode = str (mode_value )
211267 if mode == CancelationMode_2023_09 .TERMINATE .value :
212268 # Post-resolution the object must validate against the resolved
213269 # variant's shape: TERMINATE admits no notify period.
@@ -644,24 +700,10 @@ def _run_action(
644700 assert isinstance (action , Action_2023_09 )
645701 try :
646702 command = [action .command .resolve (symtab = symtab )]
647- if action .args is not None :
648- # RFC 0005 §1.3.2 argument semantics, mirroring openjd-rs's
649- # resolve_action_args: a whole-field expression argument
650- # resolves typed — a null result skips the argument, a list
651- # result flattens inline (one argument per element, rendered
652- # with the engine's display coercion), and a scalar becomes a
653- # single argument. Multi-segment format strings and legacy
654- # (non-EXPR) expressions resolve to their string form.
655- for arg in action .args :
656- value = arg .resolve_value (symtab = symtab )
657- if isinstance (value , str ):
658- command .append (value )
659- elif getattr (value , "is_null" , False ):
660- continue
661- elif str (getattr (value , "type" , "" )).startswith ("list[" ):
662- command .extend (str (element ) for element in value )
663- else :
664- command .append (str (value ))
703+ # RFC 0005 §1.3.2 typed argument semantics (null skip, list
704+ # flattening) — see resolve_action_arg_values, shared with the
705+ # RFC 0008 WrappedAction.Args injection.
706+ command .extend (resolve_action_arg_values (action .args , symtab ))
665707 except FormatStringError as exc :
666708 # Extremely unlikely since a JobTemplate needs to have passed
667709 # validation before we could be running it, but just to be safe.
@@ -670,11 +712,10 @@ def _run_action(
670712 time_limit : Optional [timedelta ] = default_timeout
671713 # A FormatString timeout (FEATURE_BUNDLE_1) is resolved right
672714 # before the action runs. A whole-field expression that
673- # resolves to null renders as the empty string — e.g.
674- # forwarding `timeout: "{{WrappedAction.Timeout}}"`
675- # (RFC 0008) when the wrapped action specified no
676- # timeout — and is treated as if the field were not
677- # provided, so the positional default applies.
715+ # resolves to a typed null — e.g. forwarding
716+ # `timeout: "{{WrappedAction.Timeout}}"` (RFC 0008) when the
717+ # wrapped action specified no timeout — is treated as if the
718+ # field were not provided, so the positional default applies.
678719 try :
679720 seconds = resolve_optional_int_field (
680721 action .timeout , symtab , ge = 1 , description = "timeout"
0 commit comments