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
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
483 lines
15 KiB
Python
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
|