Files
cleveragents-core/features/steps/binding_resolution_steps.py
T
freemo 3224f2136e
CI / lint (pull_request) Successful in 27s
CI / typecheck (pull_request) Successful in 52s
CI / quality (pull_request) Successful in 32s
CI / security (pull_request) Successful in 59s
CI / build (pull_request) Successful in 29s
CI / helm (pull_request) Successful in 23s
CI / unit_tests (pull_request) Successful in 6m58s
CI / e2e_tests (pull_request) Successful in 19m39s
CI / integration_tests (pull_request) Successful in 22m49s
CI / coverage (pull_request) Successful in 10m43s
CI / docker (pull_request) Successful in 1m23s
CI / status-check (pull_request) Successful in 1s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Successful in 56m57s
fix(resource-registry): use full inheritance chain in contextual binding type compatibility
Replace the flat `parent_types` membership check in
`BindingResolutionService._is_type_compatible` with a call to
`self._registry.is_subtype_of()`, which uses
`resolve_inheritance_chain()` from `cleveragents.resource.inheritance`
to walk the full ADR-042 inheritance chain.

Previously, a tool requiring type `container-instance` would fail to
bind a `devcontainer-instance` resource because the check only looked
at the immediate `parent_types` list rather than traversing the full
chain.  The fix ensures multi-level inheritance (e.g.
`local/special-dev-container` → `devcontainer-instance` →
`container-instance`) is correctly resolved for contextual, static,
and parameter bindings.

Changes:
- `binding_resolution_service.py`: `_is_type_compatible` now delegates
  to `registry.is_subtype_of()`; removed unused `ResourceTypeSpec`
  import.
- `consolidated_binding_resolution.feature`: 4 new scenarios covering
  2-level chain, 3-level chain, unrelated-type rejection, and static
  binding via inheritance chain.
- `binding_resolution_steps.py`: mock registry exposes
  `is_subtype_of()` backed by the real inheritance engine; new step
  definitions for inheritance chain setup.

ISSUES CLOSED: #2929
2026-04-05 17:55:54 +00:00

483 lines
15 KiB
Python

"""Step definitions for binding_resolution.feature."""
from __future__ import annotations
from unittest.mock import MagicMock
from behave import given, then, when
from behave.runner import Context
from cleveragents.application.services.binding_resolution_service import (
BindingResolutionService,
)
from cleveragents.core.exceptions import NotFoundError, ValidationError
from cleveragents.domain.models.core.project import (
LinkedResource,
NamespacedProject,
)
from cleveragents.domain.models.core.resource import (
PhysVirt,
Resource,
)
from cleveragents.domain.models.core.resource_slot import BindingResult
from cleveragents.domain.models.core.resource_type import (
ResourceKind,
ResourceTypeSpec,
SandboxStrategy,
)
from cleveragents.domain.models.core.tool import (
BindingMode,
ResourceAccessMode,
ResourceSlot,
Tool,
ToolSource,
ToolType,
)
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _make_resource(
resource_id: str,
type_name: str,
name: str | None = None,
) -> Resource:
return Resource(
resource_id=resource_id,
name=name,
resource_type_name=type_name,
classification=PhysVirt.PHYSICAL,
description=f"Test resource {resource_id}",
)
def _make_type_spec(
name: str,
parent_types: list[str] | None = None,
) -> ResourceTypeSpec:
is_builtin = "/" not in name
return ResourceTypeSpec(
name=name,
description=f"Type {name}",
resource_kind=ResourceKind.PHYSICAL,
sandbox_strategy=SandboxStrategy.NONE,
parent_types=parent_types or [],
built_in=is_builtin,
)
# ------------------------------------------------------------------
# Background
# ------------------------------------------------------------------
@given("a mock resource registry")
def step_mock_registry(context: Context) -> None:
from cleveragents.resource.inheritance import is_subtype_of as _is_subtype_of
registry = MagicMock()
registry._resources: dict[str, Resource] = {}
registry._types: dict[str, ResourceTypeSpec] = {
"git-checkout": _make_type_spec("git-checkout", parent_types=[]),
"fs-directory": _make_type_spec(
"fs-directory",
parent_types=["git-checkout", "fs-directory"],
),
"fs-file": _make_type_spec("fs-file", parent_types=[]),
}
# Inheritance map used by is_subtype_of: maps type name -> inherits value.
# Populated by step helpers when multi-level inheritance is needed.
registry._inherits: dict[str, str | None] = {
"git-checkout": None,
"fs-directory": None,
"fs-file": None,
}
def show_resource(name_or_id: str) -> Resource:
res = registry._resources.get(name_or_id)
if res is None:
raise NotFoundError(
resource_type="resource",
resource_id=name_or_id,
)
return res
def show_type(name: str) -> ResourceTypeSpec:
spec = registry._types.get(name)
if spec is None:
raise NotFoundError(
resource_type="resource_type",
resource_id=name,
)
return spec
def is_subtype_of_mock(type_name: str, ancestor_name: str) -> bool:
"""Walk the full inheritance chain using the _inherits map."""
type_registry = {
t: {"inherits": registry._inherits.get(t)}
for t in set(list(registry._types.keys()) + list(registry._inherits.keys()))
}
return _is_subtype_of(type_name, ancestor_name, type_registry)
registry.show_resource = show_resource
registry.show_type = show_type
registry.is_subtype_of = is_subtype_of_mock
context.mock_registry = registry
@given("a binding resolution service using the registry")
def step_service(context: Context) -> None:
context.binding_service = BindingResolutionService(
resource_registry=context.mock_registry,
)
# ------------------------------------------------------------------
# Given: projects
# ------------------------------------------------------------------
@given('a project "{name}" with a linked resource:')
def step_project_one_resource(context: Context, name: str) -> None:
parts = name.split("/", 1)
namespace = parts[0] if len(parts) == 2 else "local"
proj_name = parts[1] if len(parts) == 2 else parts[0]
links: list[LinkedResource] = []
for row in context.table:
rid = row["resource_id"]
tname = row["type_name"]
alias = row["alias"] if row["alias"] else None
resource = _make_resource(rid, tname, name=None)
context.mock_registry._resources[rid] = resource
links.append(
LinkedResource(
resource_id=rid,
alias=alias,
)
)
context.project = NamespacedProject(
name=proj_name,
namespace=namespace,
linked_resources=links,
)
@given('a project "{name}" with linked resources:')
def step_project_multi_resources(context: Context, name: str) -> None:
parts = name.split("/", 1)
namespace = parts[0] if len(parts) == 2 else "local"
proj_name = parts[1] if len(parts) == 2 else parts[0]
links: list[LinkedResource] = []
for row in context.table:
rid = row["resource_id"]
tname = row["type_name"]
alias = row["alias"] if row["alias"] else None
resource = _make_resource(rid, tname, name=None)
context.mock_registry._resources[rid] = resource
links.append(
LinkedResource(
resource_id=rid,
alias=alias,
)
)
context.project = NamespacedProject(
name=proj_name,
namespace=namespace,
linked_resources=links,
)
@given('a project "{name}" with no linked resources')
def step_project_empty(context: Context, name: str) -> None:
parts = name.split("/", 1)
namespace = parts[0] if len(parts) == 2 else "local"
proj_name = parts[1] if len(parts) == 2 else parts[0]
context.project = NamespacedProject(
name=proj_name,
namespace=namespace,
linked_resources=[],
)
# ------------------------------------------------------------------
# Given: resources in registry
# ------------------------------------------------------------------
@given('a resource "{res_name}" of type "{type_name}" in the registry')
def step_resource_in_registry(
context: Context,
res_name: str,
type_name: str,
) -> None:
rid = "01HGZ6FE0AQDYTR4BX00000020"
if res_name == "local/wrong-type":
rid = "01HGZ6FE0AQDYTR4BX00000050"
resource = _make_resource(rid, type_name, name=res_name)
context.mock_registry._resources[res_name] = resource
context.mock_registry._resources[rid] = resource
# ------------------------------------------------------------------
# Given: resource type overrides
# ------------------------------------------------------------------
@given('the resource type "{tname}" has parent_types including "{parent}"')
def step_type_parent(context: Context, tname: str, parent: str) -> None:
existing = context.mock_registry._types.get(tname)
parents = list(existing.parent_types) if existing else []
if parent not in parents:
parents.append(parent)
context.mock_registry._types[tname] = _make_type_spec(tname, parent_types=parents)
# Also update the _inherits map so is_subtype_of() walks the chain correctly.
# Use the first parent as the direct ancestor (single-inheritance model).
if parents:
context.mock_registry._inherits[tname] = parents[0]
@given('the resource type "{tname}" has no parent_types')
def step_type_no_parents(context: Context, tname: str) -> None:
context.mock_registry._types[tname] = _make_type_spec(tname, parent_types=[])
# ------------------------------------------------------------------
# Given: tools
# ------------------------------------------------------------------
@given('a tool "{tool_name}" with a contextual slot "{slot}" of type "{rtype}"')
def step_tool_contextual(
context: Context,
tool_name: str,
slot: str,
rtype: str,
) -> None:
context.tool = Tool(
name=tool_name,
description=f"Tool {tool_name}",
source=ToolSource.BUILTIN,
tool_type=ToolType.TOOL,
resource_slots=[
ResourceSlot(
name=slot,
resource_type=rtype,
access=ResourceAccessMode.READ_WRITE,
binding=BindingMode.CONTEXTUAL,
),
],
)
@given(
'a tool "{tool_name}" with a static slot "{slot}" bound to'
' "{res}" of type "{rtype}"'
)
def step_tool_static(
context: Context,
tool_name: str,
slot: str,
res: str,
rtype: str,
) -> None:
context.tool = Tool(
name=tool_name,
description=f"Tool {tool_name}",
source=ToolSource.BUILTIN,
tool_type=ToolType.TOOL,
resource_slots=[
ResourceSlot(
name=slot,
resource_type=rtype,
access=ResourceAccessMode.READ_ONLY,
binding=BindingMode.STATIC,
static_resource=res,
),
],
)
@given('a tool "{tool_name}" with a parameter slot "{slot}" of type "{rtype}"')
def step_tool_parameter(
context: Context,
tool_name: str,
slot: str,
rtype: str,
) -> None:
context.tool = Tool(
name=tool_name,
description=f"Tool {tool_name}",
source=ToolSource.BUILTIN,
tool_type=ToolType.TOOL,
resource_slots=[
ResourceSlot(
name=slot,
resource_type=rtype,
access=ResourceAccessMode.READ_WRITE,
binding=BindingMode.PARAMETER,
),
],
)
@given('a tool "{tool_name}" with mixed slots:')
def step_tool_mixed(context: Context, tool_name: str) -> None:
slots: list[ResourceSlot] = []
for row in context.table:
binding = BindingMode(row["binding"])
static_res: str | None = (
row["static_resource"] if row["static_resource"] else None
)
slots.append(
ResourceSlot(
name=row["name"],
resource_type=row["resource_type"],
access=ResourceAccessMode.READ_WRITE,
binding=binding,
static_resource=static_res,
)
)
context.tool = Tool(
name=tool_name,
description=f"Tool {tool_name}",
source=ToolSource.BUILTIN,
tool_type=ToolType.TOOL,
resource_slots=slots,
)
# ------------------------------------------------------------------
# When
# ------------------------------------------------------------------
@when("the bindings are resolved")
def step_resolve(context: Context) -> None:
context.binding_results = context.binding_service.resolve(
context.tool,
context.project,
)
@when("the binding resolution is attempted")
def step_resolve_attempt(context: Context) -> None:
try:
context.binding_results = context.binding_service.resolve(
context.tool,
context.project,
)
context.binding_error = None
except ValidationError as exc:
context.binding_error = exc
context.binding_results = None
# ------------------------------------------------------------------
# Then
# ------------------------------------------------------------------
@then('the binding for slot "{slot}" should have mode "{mode}"')
def step_check_mode(context: Context, slot: str, mode: str) -> None:
result = _find_result(context, slot)
assert result.binding_mode == mode, (
f"Expected mode '{mode}', got '{result.binding_mode}'"
)
@then('the binding for slot "{slot}" should have resource_id "{rid}"')
def step_check_rid(context: Context, slot: str, rid: str) -> None:
result = _find_result(context, slot)
assert result.resource_id == rid, (
f"Expected resource_id '{rid}', got '{result.resource_id}'"
)
@then('the binding for slot "{slot}" should not be deferred')
def step_not_deferred(context: Context, slot: str) -> None:
result = _find_result(context, slot)
assert not result.deferred, f"Slot '{slot}' should not be deferred"
@then('the binding for slot "{slot}" should be deferred')
def step_is_deferred(context: Context, slot: str) -> None:
result = _find_result(context, slot)
assert result.deferred, f"Slot '{slot}' should be deferred"
@then('the binding for slot "{slot}" should have no resource_id')
def step_no_rid(context: Context, slot: str) -> None:
result = _find_result(context, slot)
assert result.resource_id is None, (
f"Expected None resource_id, got '{result.resource_id}'"
)
@then('a binding ValidationError should be raised mentioning "{text}"')
def step_binding_error(context: Context, text: str) -> None:
assert context.binding_error is not None, (
"Expected ValidationError but none was raised"
)
assert isinstance(context.binding_error, ValidationError), (
f"Expected ValidationError, got {type(context.binding_error).__name__}"
)
assert text in str(context.binding_error), (
f"Expected '{text}' in error, got '{context.binding_error}'"
)
@then("there should be {count:d} binding results")
def step_count_results(context: Context, count: int) -> None:
assert len(context.binding_results) == count, (
f"Expected {count} results, got {len(context.binding_results)}"
)
# ------------------------------------------------------------------
# Internal helper
# ------------------------------------------------------------------
def _find_result(context: Context, slot_name: str) -> BindingResult:
for result in context.binding_results:
if result.slot_name == slot_name:
return result
raise AssertionError(f"No binding result for slot '{slot_name}'")
# ------------------------------------------------------------------
# Given: inheritance chain setup for multi-level tests
# ------------------------------------------------------------------
@given('the resource type "{tname}" inherits from nothing')
def step_type_inherits_nothing(context: Context, tname: str) -> None:
"""Register a root type with no parent in the inheritance chain."""
if tname not in context.mock_registry._types:
context.mock_registry._types[tname] = _make_type_spec(tname, parent_types=[])
context.mock_registry._inherits[tname] = None
@given('the resource type "{tname}" inherits from "{parent}"')
def step_type_inherits_from(context: Context, tname: str, parent: str) -> None:
"""Register a type that directly inherits from *parent*."""
if tname not in context.mock_registry._types:
context.mock_registry._types[tname] = _make_type_spec(
tname, parent_types=[parent]
)
# Ensure parent is also in the inherits map (as a root if not already set).
if parent not in context.mock_registry._inherits:
context.mock_registry._inherits[parent] = None
context.mock_registry._inherits[tname] = parent