mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
fix(workflows): reject non-list input 'enum' instead of crashing (#3601)
A workflow input whose `enum` is a scalar or string (e.g. `enum: 5`,
`enum: "abc"`) previously slipped past `validate_workflow` and crashed
at run time. The `value not in enum_values` membership test in
`_coerce_input` raises a raw `TypeError` ("argument of type 'int' is
not iterable") for a scalar, and a bare string turns enum membership
into a silent substring test. The `TypeError` also escapes
`validate_workflow`'s `except ValueError`, breaking its documented
"return a list of errors, never raise" contract.
This is the same unvalidated-`execute()` crash class as the fan-in
`wait_for` (#3482) and fan-out step-template (#3537) fixes: `validate()`
should reject the value, but the value can still reach the engine via
`execute()`, which accepts unvalidated definitions.
Fix:
- `_coerce_input` requires a list `enum` (or `None`), raising a clean
ValueError for any other shape — so both `validate_workflow` and
runtime `_resolve_inputs` fail fast with a clear message.
- `validate_workflow` checks `enum` shape directly (not only via the
default-coercion path, which is reached only when a `default` exists),
and strips a malformed `enum` before coercing the default so the
wrong-typed-default error is not duplicated as an enum-shape error.
- The `integration: auto` sentinel only strips a *list* `enum`; a
non-list `enum` stays in the definition so it is rejected rather than
silently exempted by the `auto` membership skip.
Tests cover all three layers: `_coerce_input` directly, authoring-time
`validate_workflow` (with no default present), and runtime
`_resolve_inputs`, plus the `integration: auto` interaction.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4349,6 +4349,108 @@ steps:
|
||||
assert WorkflowEngine._coerce_input("count", 5.0, {"type": "number"}) == 5
|
||||
assert WorkflowEngine._coerce_input("count", 3.5, {"type": "number"}) == 3.5
|
||||
|
||||
def test_coerce_input_rejects_non_list_enum_cleanly(self):
|
||||
"""A non-list ``enum`` (scalar or string) must raise a clean ValueError,
|
||||
not the raw ``TypeError`` from the ``value not in enum`` membership test.
|
||||
|
||||
A scalar (``enum: 5``) makes ``value not in 5`` raise
|
||||
``TypeError: argument of type 'int' is not iterable``. A bare string
|
||||
(``enum: "abc"``) is silently wrong instead — ``value in "abc"`` is a
|
||||
substring test, not enum membership — so it must be rejected too.
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowEngine
|
||||
|
||||
for bad_enum in (5, True, "abc", {"a": 1}):
|
||||
with pytest.raises(ValueError, match="invalid 'enum': must be a list"):
|
||||
WorkflowEngine._coerce_input(
|
||||
"scope", "x", {"type": "string", "enum": bad_enum}
|
||||
)
|
||||
# A valid list ``enum`` still works, and ``None`` means "no enum".
|
||||
assert (
|
||||
WorkflowEngine._coerce_input(
|
||||
"scope", "a", {"type": "string", "enum": ["a", "b"]}
|
||||
)
|
||||
== "a"
|
||||
)
|
||||
assert (
|
||||
WorkflowEngine._coerce_input("scope", "x", {"type": "string"}) == "x"
|
||||
)
|
||||
|
||||
def test_validate_workflow_rejects_non_list_enum(self):
|
||||
"""A non-list ``enum`` must be reported as an error, not crash
|
||||
``validate_workflow``. The membership test would raise ``TypeError``,
|
||||
which escapes its ``except ValueError`` and breaks the "return a list of
|
||||
errors, never raise" contract. This must surface even with no ``default``
|
||||
present (the coercion path that would otherwise catch it is only reached
|
||||
when a default exists).
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "bad-enum"
|
||||
name: "Bad Enum"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
scope:
|
||||
type: string
|
||||
enum: 5
|
||||
steps:
|
||||
- id: noop
|
||||
type: gate
|
||||
message: "noop"
|
||||
options: [approve]
|
||||
""")
|
||||
errors = validate_workflow(definition)
|
||||
assert any("invalid 'enum': must be a list" in e for e in errors), errors
|
||||
|
||||
def test_resolve_inputs_rejects_non_list_enum_at_runtime(self, project_dir):
|
||||
"""``execute()`` accepts unvalidated definitions, so a non-list ``enum``
|
||||
can reach ``_resolve_inputs`` at run time. It must fail with a clean
|
||||
ValueError rather than the raw ``TypeError`` from the membership test.
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "runtime-bad-enum"
|
||||
name: "Runtime Bad Enum"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
scope:
|
||||
type: string
|
||||
enum: 5
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
with pytest.raises(ValueError, match="invalid 'enum': must be a list"):
|
||||
engine._resolve_inputs(definition, {"scope": "x"})
|
||||
|
||||
def test_non_list_enum_on_integration_auto_still_rejected(self, project_dir):
|
||||
"""The ``integration: auto`` sentinel strips a *list* ``enum`` before
|
||||
coercion (enum-membership is a runtime concern for ``auto``). A non-list
|
||||
``enum`` must NOT be silently stripped by that path — it is still an
|
||||
authoring error and must fail with the clean shape ValueError.
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "auto-bad-enum"
|
||||
name: "Auto Bad Enum"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
integration:
|
||||
type: string
|
||||
default: "auto"
|
||||
enum: 5
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
with pytest.raises(ValueError, match="invalid 'enum': must be a list"):
|
||||
engine._resolve_inputs(definition, {})
|
||||
|
||||
def test_validate_workflow_rejects_infinite_default_for_number_type(self):
|
||||
"""``type: number`` with an infinite default (YAML ``.inf``) must be
|
||||
reported as an error, not raise. ``int(inf)`` raises OverflowError during
|
||||
|
||||
Reference in New Issue
Block a user