fix: complete ref handling with composition, siblings, recursion
This commit is contained in:
@@ -8,6 +8,9 @@ from wf_core.analysis.context_scopes import (
|
|||||||
ContextFieldAvailability,
|
ContextFieldAvailability,
|
||||||
context_analysis_warnings,
|
context_analysis_warnings,
|
||||||
context_fields_by_node,
|
context_fields_by_node,
|
||||||
|
normalize_definition_reference,
|
||||||
|
resolve_schema_reference,
|
||||||
|
schema_union_branches,
|
||||||
)
|
)
|
||||||
from wf_core.models.workflow import Workflow
|
from wf_core.models.workflow import Workflow
|
||||||
from wf_core.paths import GraphSourcePath
|
from wf_core.paths import GraphSourcePath
|
||||||
@@ -289,13 +292,77 @@ def _nested_item_subpaths(
|
|||||||
*,
|
*,
|
||||||
depth: int,
|
depth: int,
|
||||||
prefix_parts: tuple[str, ...] = (),
|
prefix_parts: tuple[str, ...] = (),
|
||||||
|
definitions: Mapping[str, Any] | None = None,
|
||||||
|
active_refs: frozenset[str] = frozenset(),
|
||||||
) -> list[AuthoringPathOptionPayload]:
|
) -> list[AuthoringPathOptionPayload]:
|
||||||
"""Emit bounded object children beneath one foreach ``item`` schema."""
|
"""Emit bounded object children beneath one foreach ``item`` schema.
|
||||||
|
|
||||||
|
Dangling ``$ref`` values resolve against the nearest enclosing ``$defs``
|
||||||
|
table (kept by recursive item schemas); a repeated reference stays
|
||||||
|
selectable at its own path but is not expanded again, mirroring the
|
||||||
|
input/state inventory. Composition keywords are a union: children come
|
||||||
|
from every object branch, deduplicated by path.
|
||||||
|
"""
|
||||||
from .models.authoring_contracts import AuthoringPathOptionPayload as _Payload
|
from .models.authoring_contracts import AuthoringPathOptionPayload as _Payload
|
||||||
|
|
||||||
if depth >= _MAX_LOCAL_SCHEMA_REFERENCE_DEPTH:
|
if depth >= _MAX_LOCAL_SCHEMA_REFERENCE_DEPTH:
|
||||||
return []
|
return []
|
||||||
properties = item_schema.get("properties")
|
table = item_schema.get("$defs")
|
||||||
|
if isinstance(table, Mapping):
|
||||||
|
definitions = table
|
||||||
|
elif definitions is None:
|
||||||
|
definitions = {}
|
||||||
|
options: list[_Payload] = []
|
||||||
|
for branch in schema_union_branches(item_schema):
|
||||||
|
options.extend(
|
||||||
|
_branch_item_children(
|
||||||
|
branch,
|
||||||
|
owner_id,
|
||||||
|
availability,
|
||||||
|
depth=depth,
|
||||||
|
prefix_parts=prefix_parts,
|
||||||
|
definitions=definitions,
|
||||||
|
active_refs=active_refs,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
seen: set[str] = set()
|
||||||
|
deduped: list[_Payload] = []
|
||||||
|
for option in options:
|
||||||
|
if option["path"] not in seen:
|
||||||
|
seen.add(option["path"])
|
||||||
|
deduped.append(option)
|
||||||
|
return deduped
|
||||||
|
|
||||||
|
|
||||||
|
def _branch_item_children(
|
||||||
|
branch: Mapping[str, Any],
|
||||||
|
owner_id: str,
|
||||||
|
availability: str,
|
||||||
|
*,
|
||||||
|
depth: int,
|
||||||
|
prefix_parts: tuple[str, ...],
|
||||||
|
definitions: Mapping[str, Any],
|
||||||
|
active_refs: frozenset[str],
|
||||||
|
) -> list[AuthoringPathOptionPayload]:
|
||||||
|
from .models.authoring_contracts import AuthoringPathOptionPayload as _Payload
|
||||||
|
|
||||||
|
if isinstance(branch.get("$ref"), str):
|
||||||
|
reference = normalize_definition_reference(branch["$ref"])
|
||||||
|
if reference in active_refs:
|
||||||
|
return []
|
||||||
|
resolved = resolve_schema_reference(definitions, branch)
|
||||||
|
if resolved is branch:
|
||||||
|
return []
|
||||||
|
return _nested_item_subpaths(
|
||||||
|
resolved,
|
||||||
|
owner_id,
|
||||||
|
availability,
|
||||||
|
depth=depth,
|
||||||
|
prefix_parts=prefix_parts,
|
||||||
|
definitions=definitions,
|
||||||
|
active_refs=active_refs | {reference},
|
||||||
|
)
|
||||||
|
properties = branch.get("properties")
|
||||||
if not isinstance(properties, Mapping):
|
if not isinstance(properties, Mapping):
|
||||||
return []
|
return []
|
||||||
options: list[_Payload] = []
|
options: list[_Payload] = []
|
||||||
@@ -347,6 +414,8 @@ def _nested_item_subpaths(
|
|||||||
availability,
|
availability,
|
||||||
depth=depth + 1,
|
depth=depth + 1,
|
||||||
prefix_parts=(*prefix_parts, name),
|
prefix_parts=(*prefix_parts, name),
|
||||||
|
definitions=definitions,
|
||||||
|
active_refs=active_refs,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return options
|
return options
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ from .context_scopes import (
|
|||||||
context_fields_by_node,
|
context_fields_by_node,
|
||||||
context_schema_for_node,
|
context_schema_for_node,
|
||||||
context_schemas_by_node,
|
context_schemas_by_node,
|
||||||
|
normalize_definition_reference,
|
||||||
|
resolve_schema_reference,
|
||||||
root_context_schema,
|
root_context_schema,
|
||||||
|
schema_union_branches,
|
||||||
)
|
)
|
||||||
from .control_regions import (
|
from .control_regions import (
|
||||||
ControlRegionAnalysis,
|
ControlRegionAnalysis,
|
||||||
@@ -27,5 +30,8 @@ __all__ = [
|
|||||||
"context_fields_by_node",
|
"context_fields_by_node",
|
||||||
"context_schema_for_node",
|
"context_schema_for_node",
|
||||||
"context_schemas_by_node",
|
"context_schemas_by_node",
|
||||||
|
"normalize_definition_reference",
|
||||||
|
"resolve_schema_reference",
|
||||||
"root_context_schema",
|
"root_context_schema",
|
||||||
|
"schema_union_branches",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -385,7 +385,14 @@ def _foreach_item_schema(
|
|||||||
resolved_items = _resolve_local_reference(document, items)
|
resolved_items = _resolve_local_reference(document, items)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return {}
|
return {}
|
||||||
return deepcopy(dict(_inline_local_refs(document, resolved_items)))
|
result = dict(_inline_local_refs(document, resolved_items))
|
||||||
|
if _has_dangling_ref(result):
|
||||||
|
# Cut recursions keep their definitions table so downstream walkers
|
||||||
|
# can resolve through them instead of meeting a bare `$ref`.
|
||||||
|
definitions = _collect_definitions(document)
|
||||||
|
if definitions:
|
||||||
|
result["$defs"] = definitions
|
||||||
|
return deepcopy(result)
|
||||||
|
|
||||||
|
|
||||||
def _schema_at_path(
|
def _schema_at_path(
|
||||||
@@ -478,6 +485,133 @@ def _schema_document(
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_definition_reference(reference: str) -> str:
|
||||||
|
"""Normalize legacy ``#/definitions/`` refs to ``#/$defs/`` form."""
|
||||||
|
if reference.startswith("#/definitions/"):
|
||||||
|
return "#/$defs/" + reference.removeprefix("#/definitions/")
|
||||||
|
return reference
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_ref_siblings(
|
||||||
|
target: Mapping[str, object], node: Mapping[str, object]
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Merge ``$ref`` siblings over the resolved target (2020-12 conjunction).
|
||||||
|
|
||||||
|
Scalar siblings (``description``, ``title``) override; ``properties`` union
|
||||||
|
per key with the sibling winning; ``required`` unions. ``$ref`` itself is
|
||||||
|
consumed unless the target chains to another reference.
|
||||||
|
"""
|
||||||
|
merged = dict(target)
|
||||||
|
for key, value in node.items():
|
||||||
|
if key == "$ref":
|
||||||
|
continue
|
||||||
|
existing_properties = merged.get("properties")
|
||||||
|
if (
|
||||||
|
key == "properties"
|
||||||
|
and isinstance(value, Mapping)
|
||||||
|
and isinstance(existing_properties, Mapping)
|
||||||
|
):
|
||||||
|
merged["properties"] = {**existing_properties, **value}
|
||||||
|
continue
|
||||||
|
existing_required = merged.get("required")
|
||||||
|
if (
|
||||||
|
key == "required"
|
||||||
|
and isinstance(value, list)
|
||||||
|
and isinstance(existing_required, list)
|
||||||
|
):
|
||||||
|
merged["required"] = [
|
||||||
|
*existing_required,
|
||||||
|
*[item for item in value if item not in existing_required],
|
||||||
|
]
|
||||||
|
continue
|
||||||
|
merged[key] = value
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_definition(
|
||||||
|
definitions: Mapping[str, object], reference: str
|
||||||
|
) -> Mapping[str, object] | None:
|
||||||
|
"""Walk a definition pointer beneath a merged definitions table, leniently.
|
||||||
|
|
||||||
|
Only definition-table pointers resolve here; anything else returns
|
||||||
|
``None`` so callers fail closed.
|
||||||
|
"""
|
||||||
|
normalized = normalize_definition_reference(reference)
|
||||||
|
if not normalized.startswith("#/$defs/"):
|
||||||
|
return None
|
||||||
|
current: object = definitions
|
||||||
|
for raw_part in normalized.removeprefix("#/$defs/").split("/"):
|
||||||
|
part = raw_part.replace("~1", "/").replace("~0", "~")
|
||||||
|
if not isinstance(current, Mapping) or part not in current:
|
||||||
|
return None
|
||||||
|
current = current[part]
|
||||||
|
return current if isinstance(current, Mapping) else None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_schema_reference(
|
||||||
|
definitions: Mapping[str, object], node: Mapping[str, object]
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
"""Leniently resolve one node's ``$ref`` chain against a definitions table.
|
||||||
|
|
||||||
|
Unresolvable, external, or cyclic references return ``node`` unchanged so
|
||||||
|
schema walkers fail closed. Sibling constraints merge like the strict
|
||||||
|
resolver.
|
||||||
|
"""
|
||||||
|
current = node
|
||||||
|
seen: set[str] = set()
|
||||||
|
while True:
|
||||||
|
raw = current.get("$ref")
|
||||||
|
if not isinstance(raw, str):
|
||||||
|
return current
|
||||||
|
reference = normalize_definition_reference(raw)
|
||||||
|
if reference in seen or len(seen) >= _MAX_LOCAL_SCHEMA_REFERENCE_DEPTH:
|
||||||
|
return node
|
||||||
|
seen.add(reference)
|
||||||
|
target = _lookup_definition(definitions, reference)
|
||||||
|
if target is None:
|
||||||
|
return node
|
||||||
|
current = _merge_ref_siblings(target, current)
|
||||||
|
|
||||||
|
|
||||||
|
def schema_union_branches(node: Mapping[str, object]) -> list[Mapping[str, object]]:
|
||||||
|
"""Return object-candidate branches: the node plus anyOf/oneOf/allOf members.
|
||||||
|
|
||||||
|
Composition keywords are a union approximation for path walking: a path is
|
||||||
|
readable when some branch declares it. This matches ``Optional[X]``
|
||||||
|
(pydantic ``anyOf``) and subclass ``allOf`` shapes; exotic intersections
|
||||||
|
may over-accept, which path allowlisting prefers to false rejection.
|
||||||
|
"""
|
||||||
|
branches = [node]
|
||||||
|
for key in ("anyOf", "oneOf", "allOf"):
|
||||||
|
members = node.get(key)
|
||||||
|
if isinstance(members, list):
|
||||||
|
branches.extend(member for member in members if isinstance(member, Mapping))
|
||||||
|
return branches
|
||||||
|
|
||||||
|
|
||||||
|
def _subtree_references(node: object) -> set[str]:
|
||||||
|
"""Collect normalized ``$ref`` strings in a subtree (bounded scan)."""
|
||||||
|
found: set[str] = set()
|
||||||
|
seen: set[int] = set()
|
||||||
|
stack: list[object] = [node]
|
||||||
|
while stack:
|
||||||
|
current = stack.pop()
|
||||||
|
if isinstance(current, Mapping):
|
||||||
|
if id(current) in seen:
|
||||||
|
continue
|
||||||
|
seen.add(id(current))
|
||||||
|
reference = current.get("$ref")
|
||||||
|
if isinstance(reference, str):
|
||||||
|
found.add(normalize_definition_reference(reference))
|
||||||
|
stack.extend(current.values())
|
||||||
|
elif isinstance(current, list):
|
||||||
|
if id(current) in seen:
|
||||||
|
continue
|
||||||
|
seen.add(id(current))
|
||||||
|
stack.extend(current)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
def _inline_local_refs(
|
def _inline_local_refs(
|
||||||
root_schema: Mapping[str, object],
|
root_schema: Mapping[str, object],
|
||||||
candidate: Mapping[str, object],
|
candidate: Mapping[str, object],
|
||||||
@@ -486,11 +620,12 @@ def _inline_local_refs(
|
|||||||
|
|
||||||
:func:`_foreach_item_schema` detaches the resolved item schema from its
|
:func:`_foreach_item_schema` detaches the resolved item schema from its
|
||||||
source document, which would strand nested ``$ref`` pointers whose
|
source document, which would strand nested ``$ref`` pointers whose
|
||||||
``$defs`` live at the document root. Inlining here keeps every downstream
|
``$defs`` live at the document root. Inlining here resolves the
|
||||||
schema walker (validation, authoring inventory) working on plain
|
acyclic majority (including ``anyOf``/``allOf``/``oneOf`` composition and
|
||||||
``properties`` without threading definition tables through per-node
|
``$ref`` siblings) so downstream walkers mostly see plain ``properties``.
|
||||||
schemas. Cyclic or otherwise unresolvable refs are left in place:
|
Cut recursions keep a normalized dangling ``$ref``; their definitions
|
||||||
downstream walkers already treat a bare ``$ref`` as fail-closed.
|
table travels with the item schema (see :func:`_foreach_item_schema`) for
|
||||||
|
ref-aware walkers.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def inline(node: object, active: frozenset[str], depth: int) -> object:
|
def inline(node: object, active: frozenset[str], depth: int) -> object:
|
||||||
@@ -502,13 +637,27 @@ def _inline_local_refs(
|
|||||||
return node
|
return node
|
||||||
reference = node.get("$ref")
|
reference = node.get("$ref")
|
||||||
if isinstance(reference, str):
|
if isinstance(reference, str):
|
||||||
if reference in active:
|
lookup = normalize_definition_reference(reference)
|
||||||
return node
|
if lookup in active:
|
||||||
|
rewritten = dict(node)
|
||||||
|
rewritten["$ref"] = lookup
|
||||||
|
return rewritten
|
||||||
try:
|
try:
|
||||||
resolved = _resolve_local_reference(root_schema, node)
|
resolved = _resolve_local_reference(root_schema, node)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return node
|
rewritten = dict(node)
|
||||||
return inline(resolved, active | {reference}, depth + 1)
|
if lookup != reference:
|
||||||
|
rewritten["$ref"] = lookup
|
||||||
|
return rewritten
|
||||||
|
target_refs = _subtree_references(resolved)
|
||||||
|
if lookup in target_refs or not target_refs.isdisjoint(active):
|
||||||
|
# Recursive shape: expanding would re-enter this reference or
|
||||||
|
# an ancestor, so keep it dangling and let the attached
|
||||||
|
# definitions table serve ref-aware walkers instead.
|
||||||
|
rewritten = dict(node)
|
||||||
|
rewritten["$ref"] = lookup
|
||||||
|
return rewritten
|
||||||
|
return inline(resolved, active | {lookup}, depth + 1)
|
||||||
inlined = dict(node)
|
inlined = dict(node)
|
||||||
properties = inlined.get("properties")
|
properties = inlined.get("properties")
|
||||||
if isinstance(properties, Mapping):
|
if isinstance(properties, Mapping):
|
||||||
@@ -524,6 +673,10 @@ def _inline_local_refs(
|
|||||||
prefix = inlined.get("prefixItems")
|
prefix = inlined.get("prefixItems")
|
||||||
if isinstance(prefix, list):
|
if isinstance(prefix, list):
|
||||||
inlined["prefixItems"] = inline(prefix, active, depth + 1)
|
inlined["prefixItems"] = inline(prefix, active, depth + 1)
|
||||||
|
for key in ("anyOf", "oneOf", "allOf"):
|
||||||
|
members = inlined.get(key)
|
||||||
|
if isinstance(members, list):
|
||||||
|
inlined[key] = [inline(member, active, depth + 1) for member in members]
|
||||||
return inlined
|
return inlined
|
||||||
|
|
||||||
inlined = inline(candidate, frozenset(), 0)
|
inlined = inline(candidate, frozenset(), 0)
|
||||||
@@ -532,11 +685,49 @@ def _inline_local_refs(
|
|||||||
return inlined
|
return inlined
|
||||||
|
|
||||||
|
|
||||||
|
def _has_dangling_ref(node: object) -> bool:
|
||||||
|
"""Return whether any nested mapping still carries a string ``$ref``."""
|
||||||
|
seen: set[int] = set()
|
||||||
|
stack: list[object] = [node]
|
||||||
|
while stack:
|
||||||
|
current = stack.pop()
|
||||||
|
if isinstance(current, Mapping):
|
||||||
|
if id(current) in seen:
|
||||||
|
continue
|
||||||
|
seen.add(id(current))
|
||||||
|
if isinstance(current.get("$ref"), str):
|
||||||
|
return True
|
||||||
|
stack.extend(current.values())
|
||||||
|
elif isinstance(current, list):
|
||||||
|
if id(current) in seen:
|
||||||
|
continue
|
||||||
|
seen.add(id(current))
|
||||||
|
stack.extend(current)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_definitions(document: Mapping[str, object]) -> dict[str, object]:
|
||||||
|
"""Merge a document's ``definitions``/``$defs`` tables (``$defs`` wins)."""
|
||||||
|
collected: dict[str, object] = {}
|
||||||
|
legacy = document.get("definitions")
|
||||||
|
if isinstance(legacy, Mapping):
|
||||||
|
collected.update(legacy)
|
||||||
|
modern = document.get("$defs")
|
||||||
|
if isinstance(modern, Mapping):
|
||||||
|
collected.update(modern)
|
||||||
|
return collected
|
||||||
|
|
||||||
|
|
||||||
def _resolve_local_reference(
|
def _resolve_local_reference(
|
||||||
root_schema: Mapping[str, object],
|
root_schema: Mapping[str, object],
|
||||||
candidate: Mapping[str, object],
|
candidate: Mapping[str, object],
|
||||||
) -> Mapping[str, object]:
|
) -> Mapping[str, object]:
|
||||||
"""Resolve bounded repository-local refs without becoming a full resolver."""
|
"""Resolve bounded repository-local refs without becoming a full resolver.
|
||||||
|
|
||||||
|
``$ref`` siblings merge over the resolved target (JSON Schema 2020-12
|
||||||
|
conjunction, bounded to scalar override plus ``properties``/``required``
|
||||||
|
union); the merged result keeps resolving when the target chains.
|
||||||
|
"""
|
||||||
current = candidate
|
current = candidate
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
while "$ref" in current:
|
while "$ref" in current:
|
||||||
@@ -563,5 +754,5 @@ def _resolve_local_reference(
|
|||||||
resolved = resolved[part]
|
resolved = resolved[part]
|
||||||
if not isinstance(resolved, Mapping):
|
if not isinstance(resolved, Mapping):
|
||||||
raise ValueError(f"schema reference {reference!r} is not an object")
|
raise ValueError(f"schema reference {reference!r} is not an object")
|
||||||
current = resolved
|
current = _merge_ref_siblings(resolved, current)
|
||||||
return current
|
return current
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ from __future__ import annotations
|
|||||||
from collections.abc import Iterator, Mapping
|
from collections.abc import Iterator, Mapping
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from wf_core.analysis.context_scopes import ContextSchema, root_context_schema
|
from wf_core.analysis.context_scopes import (
|
||||||
|
ContextSchema,
|
||||||
|
resolve_schema_reference,
|
||||||
|
root_context_schema,
|
||||||
|
schema_union_branches,
|
||||||
|
)
|
||||||
from wf_core.analysis.control_regions import ControlRegionAnalysis
|
from wf_core.analysis.control_regions import ControlRegionAnalysis
|
||||||
from wf_core.context_contracts import RESERVED_CONTEXT_KEYS
|
from wf_core.context_contracts import RESERVED_CONTEXT_KEYS
|
||||||
from wf_core.models.conditions import (
|
from wf_core.models.conditions import (
|
||||||
@@ -199,32 +204,77 @@ def _failing_segment(
|
|||||||
"""Return the first unknown segment plus the keys available there.
|
"""Return the first unknown segment plus the keys available there.
|
||||||
|
|
||||||
Returns ``(None, "")`` when the path walks declared properties (or
|
Returns ``(None, "")`` when the path walks declared properties (or
|
||||||
permissive unconstrained schemas). A bare ``$ref`` fails closed: generated
|
permissive unconstrained schemas). Dangling ``$ref`` values resolve
|
||||||
per-node schemas are inline except for cyclic shapes, which cannot be
|
against the nearest enclosing ``$defs`` table (kept by recursive item
|
||||||
proven valid statically.
|
schemas); unresolvable refs fail closed. Composition keywords are a
|
||||||
|
union: a path is readable when some branch declares it.
|
||||||
"""
|
"""
|
||||||
if not parts:
|
if not parts:
|
||||||
return None, ""
|
return None, ""
|
||||||
current: Any = schema
|
table = schema.get("$defs")
|
||||||
for part in parts:
|
definitions = table if isinstance(table, Mapping) else {}
|
||||||
if not isinstance(current, Mapping):
|
return _walk_schema(schema, parts, definitions)
|
||||||
return part, ""
|
|
||||||
while isinstance(current.get("$ref"), str):
|
|
||||||
return part, ""
|
def _walk_schema(
|
||||||
properties = current.get("properties")
|
node: Any,
|
||||||
|
parts: tuple[str, ...],
|
||||||
|
definitions: Mapping[str, Any],
|
||||||
|
) -> tuple[str | None, str]:
|
||||||
|
"""Walk one schema level: normalize, resolve refs, try union branches."""
|
||||||
|
if not parts:
|
||||||
|
return None, ""
|
||||||
|
if not isinstance(node, Mapping):
|
||||||
|
return parts[0], ""
|
||||||
|
table = node.get("$defs")
|
||||||
|
if isinstance(table, Mapping):
|
||||||
|
definitions = table
|
||||||
|
if isinstance(node.get("$ref"), str):
|
||||||
|
resolved = resolve_schema_reference(definitions, node)
|
||||||
|
if resolved is node:
|
||||||
|
return parts[0], ""
|
||||||
|
return _walk_schema(resolved, parts, definitions)
|
||||||
|
failures: list[tuple[str, str]] = []
|
||||||
|
for branch in schema_union_branches(node):
|
||||||
|
failing, available = _walk_branch(branch, parts, definitions)
|
||||||
|
if failing is None:
|
||||||
|
return None, ""
|
||||||
|
failures.append((failing, available))
|
||||||
|
# Prefer the failure that names available keys; single-branch schemas
|
||||||
|
# behave exactly as before.
|
||||||
|
for failing, available in failures:
|
||||||
|
if available:
|
||||||
|
return failing, available
|
||||||
|
return failures[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _walk_branch(
|
||||||
|
branch: Mapping[str, Any],
|
||||||
|
parts: tuple[str, ...],
|
||||||
|
definitions: Mapping[str, Any],
|
||||||
|
) -> tuple[str | None, str]:
|
||||||
|
"""Walk literal parts through one branch's declared properties."""
|
||||||
|
if isinstance(branch.get("$ref"), str):
|
||||||
|
# A referenced branch resolves first so recursion through definitions
|
||||||
|
# tables validates; unresolvable branches simply cannot accept.
|
||||||
|
resolved = resolve_schema_reference(definitions, branch)
|
||||||
|
if resolved is branch:
|
||||||
|
return parts[0], ""
|
||||||
|
return _walk_schema(resolved, parts, definitions)
|
||||||
|
part = parts[0]
|
||||||
|
properties = branch.get("properties")
|
||||||
if not isinstance(properties, Mapping):
|
if not isinstance(properties, Mapping):
|
||||||
if current == {}:
|
if branch == {}:
|
||||||
return None, ""
|
return None, ""
|
||||||
if (
|
if (
|
||||||
current.get("type") == "object"
|
branch.get("type") == "object"
|
||||||
and current.get("additionalProperties", True) is not False
|
and branch.get("additionalProperties", True) is not False
|
||||||
):
|
):
|
||||||
return None, ""
|
return None, ""
|
||||||
return part, ""
|
return part, ""
|
||||||
if part not in properties:
|
if part not in properties:
|
||||||
return part, ",".join(sorted(str(key) for key in properties))
|
return part, ",".join(sorted(str(key) for key in properties))
|
||||||
current = properties[part]
|
return _walk_schema(properties[part], parts[1:], definitions)
|
||||||
return None, ""
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_workflow_output(workflow: Workflow, report: ValidationReport) -> None:
|
def _validate_workflow_output(workflow: Workflow, report: ValidationReport) -> None:
|
||||||
|
|||||||
@@ -424,3 +424,105 @@ def test_scoped_cycle_terminates_and_preserves_scoped_field_availability() -> No
|
|||||||
# A canonical back-edge pops the item stack, so the controller itself
|
# A canonical back-edge pops the item stack, so the controller itself
|
||||||
# stays in the outer region and exposes no item alias.
|
# stays in the outer region and exposes no item alias.
|
||||||
assert "item" not in _field_map(workflow, "each")
|
assert "item" not in _field_map(workflow, "each")
|
||||||
|
|
||||||
|
|
||||||
|
def _composer_state_schema() -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"orders_list": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"$ref": "#/$defs/Order"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"Order": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"sku": {"type": "string"},
|
||||||
|
"nick": {
|
||||||
|
"$ref": "#/$defs/Detail",
|
||||||
|
"description": "Short display name",
|
||||||
|
"properties": {"label": {"type": "string"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Detail": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"name": {"type": "string"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _composer_workflow() -> Workflow:
|
||||||
|
return _workflow(
|
||||||
|
start="orders",
|
||||||
|
nodes=[
|
||||||
|
_foreach("orders", over="state.orders_list", alias="order"),
|
||||||
|
_node("body"),
|
||||||
|
],
|
||||||
|
edges=[
|
||||||
|
{"from": "orders", "outcome": "loop", "to": "body"},
|
||||||
|
{"from": "body", "outcome": "ok", "to": "orders"},
|
||||||
|
{"from": "orders", "outcome": "done", "to": END},
|
||||||
|
],
|
||||||
|
state_schema=_composer_state_schema(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ref_sibling_metadata_survives_inlining() -> None:
|
||||||
|
from wf_core.analysis.context_scopes import context_schema_for_node
|
||||||
|
|
||||||
|
schema = context_schema_for_node(_composer_workflow(), "body")
|
||||||
|
item = schema["properties"]["foreach"]["properties"]["orders"]["properties"][
|
||||||
|
"item"
|
||||||
|
]
|
||||||
|
assert set(item["properties"]) == {"sku", "nick"}
|
||||||
|
nick = item["properties"]["nick"]
|
||||||
|
assert nick["description"] == "Short display name"
|
||||||
|
assert set(nick["properties"]) == {"name", "label"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_recursive_item_schema_carries_definitions() -> None:
|
||||||
|
from wf_core.analysis.context_scopes import context_schema_for_node
|
||||||
|
|
||||||
|
workflow = _workflow(
|
||||||
|
start="cats",
|
||||||
|
nodes=[
|
||||||
|
_foreach("cats", over="state.cats", alias="cat"),
|
||||||
|
_node("body"),
|
||||||
|
],
|
||||||
|
edges=[
|
||||||
|
{"from": "cats", "outcome": "loop", "to": "body"},
|
||||||
|
{"from": "body", "outcome": "ok", "to": "cats"},
|
||||||
|
{"from": "cats", "outcome": "done", "to": END},
|
||||||
|
],
|
||||||
|
state_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"cats": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"$ref": "#/$defs/Category"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"Category": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"parent": {
|
||||||
|
"anyOf": [
|
||||||
|
{"$ref": "#/$defs/Category"},
|
||||||
|
{"type": "null"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
schema = context_schema_for_node(workflow, "body")
|
||||||
|
item = schema["properties"]["foreach"]["properties"]["cats"]["properties"]["item"]
|
||||||
|
# The cut recursion keeps its definitions table instead of a bare $ref.
|
||||||
|
assert item["$defs"]["Category"]["properties"]["name"] == {"type": "string"}
|
||||||
|
|||||||
@@ -592,3 +592,143 @@ def test_nested_ref_item_unknown_leaf_reports_available_keys() -> None:
|
|||||||
assert issue is not None
|
assert issue is not None
|
||||||
assert "'bogus'" in issue.message
|
assert "'bogus'" in issue.message
|
||||||
assert "available: name" in issue.message
|
assert "available: name" in issue.message
|
||||||
|
|
||||||
|
|
||||||
|
def _composer_ref_workflow(*, work_path: str) -> Workflow:
|
||||||
|
"""State schema with anyOf-optional, sibling-extended, recursive shapes."""
|
||||||
|
workflow = _base_workflow(work_path=work_path)
|
||||||
|
workflow.state_schema = StateSchema.model_validate(
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"items": {"type": "array", "items": {"type": "string"}},
|
||||||
|
"orders_list": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"$ref": "#/$defs/Order"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"Order": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"sku": {"type": "string"},
|
||||||
|
"detail": {
|
||||||
|
"anyOf": [
|
||||||
|
{"$ref": "#/$defs/Detail"},
|
||||||
|
{"type": "null"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nick": {
|
||||||
|
"$ref": "#/$defs/Detail",
|
||||||
|
"description": "Short display name",
|
||||||
|
"properties": {"label": {"type": "string"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["sku"],
|
||||||
|
},
|
||||||
|
"Detail": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"name": {"type": "string"}},
|
||||||
|
"required": ["name"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return workflow
|
||||||
|
|
||||||
|
|
||||||
|
def test_optional_nested_model_subpath_is_accepted() -> None:
|
||||||
|
from wf_core.validation import validate_workflow
|
||||||
|
|
||||||
|
workflow = _composer_ref_workflow(
|
||||||
|
work_path="context.foreach.orders.item.detail.name"
|
||||||
|
)
|
||||||
|
report = validate_workflow(workflow)
|
||||||
|
assert [
|
||||||
|
issue
|
||||||
|
for issue in report.errors
|
||||||
|
if issue.code == ValidationIssueCode.INVALID_CONTEXT_PATH
|
||||||
|
] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_ref_sibling_properties_extend_the_target() -> None:
|
||||||
|
from wf_core.validation import validate_workflow
|
||||||
|
|
||||||
|
for work_path in (
|
||||||
|
"context.foreach.orders.item.nick.name",
|
||||||
|
"context.foreach.orders.item.nick.label",
|
||||||
|
):
|
||||||
|
workflow = _composer_ref_workflow(work_path=work_path)
|
||||||
|
report = validate_workflow(workflow)
|
||||||
|
assert [
|
||||||
|
issue
|
||||||
|
for issue in report.errors
|
||||||
|
if issue.code == ValidationIssueCode.INVALID_CONTEXT_PATH
|
||||||
|
] == []
|
||||||
|
|
||||||
|
|
||||||
|
def _recursive_ref_workflow(*, work_path: str) -> Workflow:
|
||||||
|
workflow = _base_workflow(work_path=work_path)
|
||||||
|
workflow.state_schema = StateSchema.model_validate(
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"items": {"type": "array", "items": {"type": "string"}},
|
||||||
|
"orders_list": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"$ref": "#/$defs/Category"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"Category": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"parent": {
|
||||||
|
"anyOf": [
|
||||||
|
{"$ref": "#/$defs/Category"},
|
||||||
|
{"type": "null"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"children": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"$ref": "#/$defs/Category"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["name"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return workflow
|
||||||
|
|
||||||
|
|
||||||
|
def test_recursive_ref_paths_validate_through_definitions() -> None:
|
||||||
|
from wf_core.validation import validate_workflow
|
||||||
|
|
||||||
|
for work_path in (
|
||||||
|
"context.foreach.orders.item.parent.name",
|
||||||
|
"context.foreach.orders.item.children",
|
||||||
|
):
|
||||||
|
workflow = _recursive_ref_workflow(work_path=work_path)
|
||||||
|
report = validate_workflow(workflow)
|
||||||
|
assert [
|
||||||
|
issue
|
||||||
|
for issue in report.errors
|
||||||
|
if issue.code == ValidationIssueCode.INVALID_CONTEXT_PATH
|
||||||
|
] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_recursive_ref_unknown_leaf_reports_defined_keys() -> None:
|
||||||
|
from wf_core.validation import validate_workflow
|
||||||
|
|
||||||
|
workflow = _recursive_ref_workflow(
|
||||||
|
work_path="context.foreach.orders.item.parent.bogus"
|
||||||
|
)
|
||||||
|
report = validate_workflow(workflow)
|
||||||
|
issue = _issue(
|
||||||
|
report, ValidationIssueCode.INVALID_CONTEXT_PATH, "nodes[2].input[0].path"
|
||||||
|
)
|
||||||
|
assert issue is not None
|
||||||
|
assert "'bogus'" in issue.message
|
||||||
|
assert "available: children,name,parent" in issue.message
|
||||||
|
|||||||
@@ -586,3 +586,134 @@ def test_root_inventory_offers_foreach_map() -> None:
|
|||||||
paths = {option["path"] for option in options}
|
paths = {option["path"] for option in options}
|
||||||
assert "context.foreach" in paths
|
assert "context.foreach" in paths
|
||||||
assert "context.prior_outcome" in paths
|
assert "context.prior_outcome" in paths
|
||||||
|
|
||||||
|
|
||||||
|
def _composer_inventory_workflow():
|
||||||
|
from wf_core import END, Edge, ForeachNode, NodeUse, SchemaRef, Workflow
|
||||||
|
from wf_core.models.schemas import StateSchema
|
||||||
|
|
||||||
|
return Workflow(
|
||||||
|
name="inventory_composer_refs",
|
||||||
|
input_schema=SchemaRef(type="object"),
|
||||||
|
state_schema=StateSchema.model_validate(
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"orders": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"$ref": "#/$defs/Order"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"Order": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"sku": {"type": "string"},
|
||||||
|
"detail": {
|
||||||
|
"anyOf": [
|
||||||
|
{"$ref": "#/$defs/Detail"},
|
||||||
|
{"type": "null"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nick": {
|
||||||
|
"$ref": "#/$defs/Detail",
|
||||||
|
"description": "Short display name",
|
||||||
|
"properties": {"label": {"type": "string"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Detail": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"name": {"type": "string"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
output_schema=SchemaRef(type="object"),
|
||||||
|
start="orders",
|
||||||
|
nodes=[
|
||||||
|
ForeachNode.model_validate(
|
||||||
|
{"id": "orders", "type": "foreach", "over": "state.orders", "as": "order"}
|
||||||
|
),
|
||||||
|
NodeUse(id="body", type="node", node="noop"),
|
||||||
|
],
|
||||||
|
edges=[
|
||||||
|
Edge.model_validate({"from": "orders", "outcome": "loop", "to": "body"}),
|
||||||
|
Edge.model_validate({"from": "body", "outcome": "ok", "to": "orders"}),
|
||||||
|
Edge.model_validate({"from": "orders", "outcome": "done", "to": END}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_composer_ref_children_appear_in_authoring_inventory() -> None:
|
||||||
|
from wf_api.authoring_contracts import context_path_options_for_node
|
||||||
|
|
||||||
|
options = context_path_options_for_node(_composer_inventory_workflow(), "body")
|
||||||
|
paths = {option["path"] for option in options}
|
||||||
|
assert "context.foreach.orders.item.detail.name" in paths
|
||||||
|
assert "context.foreach.orders.item.nick.name" in paths
|
||||||
|
assert "context.foreach.orders.item.nick.label" in paths
|
||||||
|
|
||||||
|
|
||||||
|
def _recursive_inventory_workflow():
|
||||||
|
from wf_core import END, Edge, ForeachNode, NodeUse, SchemaRef, Workflow
|
||||||
|
from wf_core.models.schemas import StateSchema
|
||||||
|
|
||||||
|
return Workflow(
|
||||||
|
name="inventory_recursive_refs",
|
||||||
|
input_schema=SchemaRef(type="object"),
|
||||||
|
state_schema=StateSchema.model_validate(
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"cats": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"$ref": "#/$defs/Category"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"Category": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"parent": {
|
||||||
|
"anyOf": [
|
||||||
|
{"$ref": "#/$defs/Category"},
|
||||||
|
{"type": "null"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"children": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"$ref": "#/$defs/Category"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
output_schema=SchemaRef(type="object"),
|
||||||
|
start="cats",
|
||||||
|
nodes=[
|
||||||
|
ForeachNode.model_validate(
|
||||||
|
{"id": "cats", "type": "foreach", "over": "state.cats", "as": "cat"}
|
||||||
|
),
|
||||||
|
NodeUse(id="body", type="node", node="noop"),
|
||||||
|
],
|
||||||
|
edges=[
|
||||||
|
Edge.model_validate({"from": "cats", "outcome": "loop", "to": "body"}),
|
||||||
|
Edge.model_validate({"from": "body", "outcome": "ok", "to": "cats"}),
|
||||||
|
Edge.model_validate({"from": "cats", "outcome": "done", "to": END}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_recursive_ref_inventory_stays_bounded() -> None:
|
||||||
|
from wf_api.authoring_contracts import context_path_options_for_node
|
||||||
|
|
||||||
|
options = context_path_options_for_node(_recursive_inventory_workflow(), "body")
|
||||||
|
paths = {option["path"] for option in options}
|
||||||
|
assert "context.foreach.cats.item.parent.name" in paths
|
||||||
|
assert "context.foreach.cats.item.children" in paths
|
||||||
|
# The repeated reference stays selectable but is not expanded again.
|
||||||
|
assert "context.foreach.cats.item.parent.parent" in paths
|
||||||
|
assert "context.foreach.cats.item.parent.parent.name" not in paths
|
||||||
|
|||||||
Reference in New Issue
Block a user