fix: complete ref handling with composition, siblings, recursion

This commit is contained in:
lda
2026-09-05 01:40:37 +07:00 Verified
parent 3f718151d6
commit 22c17ed20e
7 changed files with 727 additions and 38 deletions
+74 -24
View File
@@ -3,7 +3,12 @@ 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.context_scopes import (
ContextSchema,
resolve_schema_reference,
root_context_schema,
schema_union_branches,
)
from wf_core.analysis.control_regions import ControlRegionAnalysis
from wf_core.context_contracts import RESERVED_CONTEXT_KEYS
from wf_core.models.conditions import (
@@ -199,32 +204,77 @@ def _failing_segment(
"""Return the first unknown segment plus the keys available there.
Returns ``(None, "")`` when the path walks declared properties (or
permissive unconstrained schemas). A bare ``$ref`` fails closed: generated
per-node schemas are inline except for cyclic shapes, which cannot be
proven valid statically.
permissive unconstrained schemas). Dangling ``$ref`` values resolve
against the nearest enclosing ``$defs`` table (kept by recursive item
schemas); unresolvable refs fail closed. Composition keywords are a
union: a path is readable when some branch declares it.
"""
if not parts:
return None, ""
current: Any = schema
for part in parts:
if not isinstance(current, Mapping):
return part, ""
while isinstance(current.get("$ref"), str):
return part, ""
properties = current.get("properties")
if not isinstance(properties, Mapping):
if current == {}:
return None, ""
if (
current.get("type") == "object"
and current.get("additionalProperties", True) is not False
):
return None, ""
return part, ""
if part not in properties:
return part, ",".join(sorted(str(key) for key in properties))
current = properties[part]
return None, ""
table = schema.get("$defs")
definitions = table if isinstance(table, Mapping) else {}
return _walk_schema(schema, parts, definitions)
def _walk_schema(
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 branch == {}:
return None, ""
if (
branch.get("type") == "object"
and branch.get("additionalProperties", True) is not False
):
return None, ""
return part, ""
if part not in properties:
return part, ",".join(sorted(str(key) for key in properties))
return _walk_schema(properties[part], parts[1:], definitions)
def _validate_workflow_output(workflow: Workflow, report: ValidationReport) -> None: