315 lines
12 KiB
Python
315 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Iterator, Mapping
|
|
from typing import Any
|
|
|
|
from wf_core.analysis.context_scopes import (
|
|
ContextSchema,
|
|
root_context_schema,
|
|
)
|
|
from wf_core.analysis.control_regions import ControlRegionAnalysis
|
|
from wf_core.context_contracts import RESERVED_CONTEXT_KEYS
|
|
from wf_core.models.conditions import (
|
|
BinaryCondition,
|
|
Condition,
|
|
ExistsCondition,
|
|
LiteralOperand,
|
|
NotCondition,
|
|
PathOperand,
|
|
VariadicCondition,
|
|
)
|
|
from wf_core.models.input_bindings import (
|
|
ArrayExpression,
|
|
InputExpression,
|
|
InputExpressionBinding,
|
|
InputPathBinding,
|
|
LiteralExpression,
|
|
ObjectExpression,
|
|
PathExpression,
|
|
)
|
|
from wf_core.models.steps import (
|
|
ConditionNode,
|
|
ForeachNode,
|
|
InterruptNode,
|
|
NodeUse,
|
|
SubgraphNode,
|
|
)
|
|
from wf_core.models.workflow import Workflow
|
|
from wf_core.paths import GraphSourcePath
|
|
from wf_core.schema_navigation import SchemaNavigator
|
|
from wf_core.validation.issues import ValidationIssueCode, ValidationReport
|
|
|
|
|
|
def validate_context_paths(
|
|
workflow: Workflow,
|
|
*,
|
|
context_schemas: Mapping[str, ContextSchema],
|
|
report: ValidationReport,
|
|
control_regions: ControlRegionAnalysis | None = None,
|
|
) -> None:
|
|
"""Validate every ``context.*`` path against its consuming location schema.
|
|
|
|
Ordinary input/state validation stays where it is; this pass owns the
|
|
stronger program-location-aware meaning of ``context.*``. A path is valid
|
|
only if every literal segment is a declared object property in the
|
|
consuming node's generated schema. The whole ``context`` object and the
|
|
``context.foreach`` map remain readable; unknown dynamic keys do not.
|
|
The shared control-region analysis is threaded through so validation runs
|
|
it once; alias ownership never triggers a second traversal.
|
|
"""
|
|
nodes_by_index = list(workflow.nodes)
|
|
node_index_by_id = {node.id: idx for idx, node in enumerate(nodes_by_index)}
|
|
_validate_alias_ownership(
|
|
workflow, node_index_by_id, report, control_regions=control_regions
|
|
)
|
|
for idx, node in enumerate(nodes_by_index):
|
|
schema = context_schemas.get(node.id)
|
|
if isinstance(node, NodeUse):
|
|
_validate_step_input_bindings(
|
|
node.input, f"nodes[{idx}].input", node.id, schema, report
|
|
)
|
|
elif isinstance(node, SubgraphNode):
|
|
_validate_step_input_bindings(
|
|
node.input, f"nodes[{idx}].input", node.id, schema, report
|
|
)
|
|
elif isinstance(node, ConditionNode):
|
|
for location, path in _condition_paths(node.check, f"nodes[{idx}].check"):
|
|
_validate_one_context_path(path, location, node.id, schema, report)
|
|
elif isinstance(node, ForeachNode):
|
|
# Context-rooted `over` paths reach this pass; the old
|
|
# input/state-only check stays permissive for them.
|
|
if node.over.root == "context":
|
|
_validate_context_foreach_source(
|
|
node.over, f"nodes[{idx}].over", node.id, schema, report
|
|
)
|
|
elif isinstance(node, InterruptNode):
|
|
_validate_step_input_bindings(
|
|
node.request, f"nodes[{idx}].request", node.id, schema, report
|
|
)
|
|
_validate_workflow_output(workflow, report)
|
|
|
|
|
|
def _validate_step_input_bindings(
|
|
bindings: list[Any],
|
|
base: str,
|
|
node_id: str,
|
|
schema: ContextSchema | None,
|
|
report: ValidationReport,
|
|
) -> None:
|
|
"""Validate context paths in one input/request binding list.
|
|
|
|
`base` is the list location such as `nodes[3].input` or
|
|
`nodes[1].request`; each binding contributes `base[i]` and each path
|
|
field contributes a further suffix like `.path` or
|
|
`.expression.items[0].path`.
|
|
"""
|
|
for binding_index, binding in enumerate(bindings):
|
|
binding_location = f"{base}[{binding_index}]"
|
|
if isinstance(binding, InputPathBinding):
|
|
if binding.path.root == "context":
|
|
_validate_one_context_path(
|
|
binding.path, f"{binding_location}.path", node_id, schema, report
|
|
)
|
|
elif isinstance(binding, InputExpressionBinding):
|
|
for location, path in _expression_paths(
|
|
binding.expression, f"{binding_location}.expression"
|
|
):
|
|
if path.root == "context":
|
|
_validate_one_context_path(path, location, node_id, schema, report)
|
|
|
|
|
|
def _expression_paths(
|
|
expression: InputExpression,
|
|
location: str,
|
|
) -> Iterator[tuple[str, GraphSourcePath]]:
|
|
"""Yield ``(model path, graph path)`` for every path leaf in an expression.
|
|
|
|
Finite recursion mirrors the input-expression model: paths, arrays, and
|
|
objects. Literals contribute no paths.
|
|
"""
|
|
match expression:
|
|
case PathExpression(path=path):
|
|
yield location + ".path", path
|
|
case ArrayExpression(items=items):
|
|
for index, item in enumerate(items):
|
|
yield from _expression_paths(item, f"{location}.items[{index}]")
|
|
case ObjectExpression(fields=fields):
|
|
for name, item in fields.items():
|
|
yield from _expression_paths(item, f"{location}.fields.{name}")
|
|
case LiteralExpression():
|
|
return
|
|
|
|
|
|
def _condition_paths(
|
|
condition: Condition,
|
|
location: str,
|
|
) -> Iterator[tuple[str, GraphSourcePath]]:
|
|
"""Yield context-candidate paths from a condition tree with model locations."""
|
|
if isinstance(condition, ExistsCondition):
|
|
yield location + ".path", condition.path
|
|
return
|
|
if isinstance(condition, NotCondition):
|
|
yield from _condition_paths(condition.arg, f"{location}.arg")
|
|
return
|
|
if isinstance(condition, VariadicCondition):
|
|
for index, arg in enumerate(condition.args):
|
|
yield from _condition_paths(arg, f"{location}.args[{index}]")
|
|
return
|
|
if isinstance(condition, BinaryCondition):
|
|
yield from _operand_paths(condition.left, f"{location}.left")
|
|
yield from _operand_paths(condition.right, f"{location}.right")
|
|
return
|
|
|
|
|
|
def _operand_paths(
|
|
operand: PathOperand | LiteralOperand, location: str
|
|
) -> Iterator[tuple[str, GraphSourcePath]]:
|
|
if isinstance(operand, LiteralOperand):
|
|
return
|
|
yield location + ".path", operand.path
|
|
|
|
|
|
def _validate_one_context_path(
|
|
path: GraphSourcePath,
|
|
location: str,
|
|
node_id: str | None,
|
|
schema: ContextSchema | None,
|
|
report: ValidationReport,
|
|
) -> None:
|
|
if path.root != "context":
|
|
return
|
|
if schema is None:
|
|
report.add(
|
|
ValidationIssueCode.INVALID_CONTEXT_PATH,
|
|
location,
|
|
f"invalid context path {str(path)!r} at {node_id or location!r}: "
|
|
"no context schema for this program location",
|
|
)
|
|
return
|
|
failing, available = _failing_segment(schema, path.parts)
|
|
if failing is not None:
|
|
listed = f" (available: {available})" if available else ""
|
|
report.add(
|
|
ValidationIssueCode.INVALID_CONTEXT_PATH,
|
|
location,
|
|
f"invalid context path {str(path)!r} at {node_id or location!r}: "
|
|
f"unknown segment {failing!r}{listed}",
|
|
)
|
|
|
|
|
|
def _validate_context_foreach_source(
|
|
path: GraphSourcePath,
|
|
location: str,
|
|
node_id: str,
|
|
schema: ContextSchema | None,
|
|
report: ValidationReport,
|
|
) -> None:
|
|
"""Require an existing context path to declare an array source.
|
|
|
|
Missing paths keep the more precise ``INVALID_CONTEXT_PATH`` diagnostic;
|
|
this adds the foreach-specific error only after the path resolves.
|
|
"""
|
|
_validate_one_context_path(path, location, node_id, schema, report)
|
|
if schema is None:
|
|
return
|
|
source = SchemaNavigator(schema).at_path(path.parts)
|
|
if source is not None and not source.is_array():
|
|
report.add(
|
|
ValidationIssueCode.INVALID_FOREACH_SOURCE,
|
|
location,
|
|
f"foreach source {str(path)!r} must resolve to an array",
|
|
)
|
|
|
|
|
|
def _failing_segment(
|
|
schema: Mapping[str, Any], parts: tuple[str, ...]
|
|
) -> tuple[str | None, str]:
|
|
"""Return the first unknown segment plus the keys available there.
|
|
|
|
Returns ``(None, "")`` when the path walks declared properties (or
|
|
permissive unconstrained schemas). Reference resolution is delegated to
|
|
the shared Draft 2020-12 navigator; unresolvable refs fail closed.
|
|
Composition is a union for path availability: a path is readable when
|
|
some structural branch declares it.
|
|
"""
|
|
return SchemaNavigator(schema).first_unknown(parts)
|
|
|
|
|
|
def _validate_workflow_output(workflow: Workflow, report: ValidationReport) -> None:
|
|
schema = root_context_schema()
|
|
for output_index, binding in enumerate(workflow.output):
|
|
if isinstance(binding, InputPathBinding):
|
|
if binding.path.root == "context":
|
|
_validate_one_context_path(
|
|
binding.path,
|
|
f"output[{output_index}].path",
|
|
"workflow_output",
|
|
schema,
|
|
report,
|
|
)
|
|
elif isinstance(binding, InputExpressionBinding):
|
|
for location, path in _expression_paths(
|
|
binding.expression, f"output[{output_index}].expression"
|
|
):
|
|
if path.root == "context":
|
|
_validate_one_context_path(
|
|
path, location, "workflow_output", schema, report
|
|
)
|
|
|
|
|
|
def _validate_alias_ownership(
|
|
workflow: Workflow,
|
|
node_index_by_id: dict[str, int],
|
|
report: ValidationReport,
|
|
*,
|
|
control_regions: ControlRegionAnalysis | None = None,
|
|
) -> None:
|
|
"""Reject reserved or colliding active foreach aliases.
|
|
|
|
Reserved names are every standard context field plus ``foreach``,
|
|
``loop_item``, and ``loop_index`` (that is, ``RESERVED_CONTEXT_KEYS``).
|
|
Siblings in separate control regions may reuse an alias because they are
|
|
never active together; only aliases active in the same owner stack
|
|
collide. Failures point at the inner foreach's ``as`` field. The shared
|
|
control-region analysis is reused; this helper never traverses alone.
|
|
"""
|
|
if control_regions is None:
|
|
from wf_core.analysis.control_regions import analyze_control_regions
|
|
|
|
control_regions = analyze_control_regions(workflow)
|
|
analysis = control_regions
|
|
foreach_by_id = {
|
|
node.id: node for node in workflow.nodes if isinstance(node, ForeachNode)
|
|
}
|
|
reported: set[str] = set()
|
|
for stack in analysis.owner_stack_by_node.values():
|
|
seen_aliases: dict[str, str] = {}
|
|
for owner_id in stack:
|
|
foreach = foreach_by_id.get(owner_id)
|
|
if foreach is None:
|
|
continue
|
|
alias = foreach.as_
|
|
idx = node_index_by_id.get(owner_id)
|
|
location = f"nodes[{idx}].as" if idx is not None else f"nodes[{owner_id}]"
|
|
if not alias or alias in RESERVED_CONTEXT_KEYS:
|
|
if owner_id not in reported:
|
|
reported.add(owner_id)
|
|
report.add(
|
|
ValidationIssueCode.FOREACH_CONTEXT_ALIAS_CONFLICT,
|
|
location,
|
|
f"foreach alias {alias!r} for node {owner_id!r} "
|
|
"collides with reserved context keys",
|
|
)
|
|
continue
|
|
if alias in seen_aliases:
|
|
if owner_id not in reported:
|
|
reported.add(owner_id)
|
|
report.add(
|
|
ValidationIssueCode.FOREACH_CONTEXT_ALIAS_CONFLICT,
|
|
location,
|
|
f"foreach alias {alias!r} for node {owner_id!r} "
|
|
f"collides with active alias from {seen_aliases[alias]!r}",
|
|
)
|
|
continue
|
|
seen_aliases[alias] = owner_id
|