diff --git a/features/consolidated_binding_resolution.feature b/features/consolidated_binding_resolution.feature index ea6ac0542..35497f8be 100644 --- a/features/consolidated_binding_resolution.feature +++ b/features/consolidated_binding_resolution.feature @@ -261,12 +261,12 @@ Feature: Consolidated Binding Resolution Then a binding ValidationError should be raised mentioning "Ambiguous contextual binding" # --------------------------------------------------------------------------- - # _is_type_compatible -- show_type raises NotFoundError (lines 369-370) + # _is_type_compatible -- is_subtype_of returns False for unknown type # --------------------------------------------------------------------------- @type_compat @not_found - Scenario: Type compatibility returns False when show_type raises NotFoundError + Scenario: Type compatibility returns False for unknown type Given a mock resource registry And a binding resolution service using the registry Given a project "local/typed" with a linked resource: @@ -278,3 +278,80 @@ Feature: Consolidated Binding Resolution When the binding resolution is attempted Then a binding ValidationError should be raised mentioning "No resource of type" + + # --------------------------------------------------------------------------- + # Multi-level inheritance -- contextual binding (ADR-042 fix) + # --------------------------------------------------------------------------- + + + @type_compat @inheritance @multi_level + Scenario: Contextual binding resolves via two-level inheritance chain + Given a mock resource registry + And a binding resolution service using the registry + Given a project "local/typed" with a linked resource: + | resource_id | type_name | alias | + | 01HGZ6FE0AQDYTR4BX00000100 | devcontainer-instance | env | + And the resource type "container-instance" inherits from nothing + And the resource type "devcontainer-instance" inherits from "container-instance" + And a tool "local/runner" with a contextual slot "env" of type "container-instance" + When the bindings are resolved + Then the binding for slot "env" should have resource_id "01HGZ6FE0AQDYTR4BX00000100" + And the binding for slot "env" should have mode "contextual" + And the binding for slot "env" should not be deferred + + @type_compat @inheritance @multi_level + Scenario: Contextual binding resolves via three-level inheritance chain + Given a mock resource registry + And a binding resolution service using the registry + Given a project "local/typed" with a linked resource: + | resource_id | type_name | alias | + | 01HGZ6FE0AQDYTR4BX00000101 | local/special-dev-container | env | + And the resource type "container-instance" inherits from nothing + And the resource type "devcontainer-instance" inherits from "container-instance" + And the resource type "local/special-dev-container" inherits from "devcontainer-instance" + And a tool "local/runner" with a contextual slot "env" of type "container-instance" + When the bindings are resolved + Then the binding for slot "env" should have resource_id "01HGZ6FE0AQDYTR4BX00000101" + And the binding for slot "env" should have mode "contextual" + And the binding for slot "env" should not be deferred + + @type_compat @inheritance @unrelated_type + Scenario: Contextual binding rejects unrelated type even with inheritance present + Given a mock resource registry + And a binding resolution service using the registry + Given a project "local/typed" with a linked resource: + | resource_id | type_name | alias | + | 01HGZ6FE0AQDYTR4BX00000102 | fs-directory | dir | + And the resource type "container-instance" inherits from nothing + And a tool "local/runner" with a contextual slot "env" of type "container-instance" + And the slot "env" is required + When the binding resolution is attempted + Then a binding ValidationError should be raised mentioning "No resource of type" + + @type_compat @inheritance @static_binding + Scenario: Static binding resolves via two-level inheritance chain + Given a mock resource registry + And a binding resolution service using the registry + Given a resource "local/my-devcontainer" of type "devcontainer-instance" in the registry + And the resource type "container-instance" inherits from nothing + And the resource type "devcontainer-instance" inherits from "container-instance" + And a tool "local/runner" with a static slot "env" bound to "local/my-devcontainer" of type "container-instance" + And a project "local/proj" with no linked resources + When the bindings are resolved + Then the binding for slot "env" should have mode "static" + And the binding for slot "env" should not be deferred + + @type_compat @inheritance @parameter_binding + Scenario: Parameter binding resolves via two-level inheritance chain + Given a mock resource registry + And a binding resolution service using the registry + Given a named resource "01HGZ6FE0AQDYTR4BX00000103" called "local/my-devcontainer" of type "devcontainer-instance" in the registry + And the resource type "container-instance" inherits from nothing + And the resource type "devcontainer-instance" inherits from "container-instance" + And a tool "local/runner" with a parameter slot "env" of type "container-instance" + And a project "local/proj" with no linked resources + When the bindings are resolved with invocation params: + | slot | ref | + | env | local/my-devcontainer | + Then the binding for slot "env" should have mode "parameter" + And the binding for slot "env" should not be deferred diff --git a/features/steps/binding_resolution_steps.py b/features/steps/binding_resolution_steps.py index f6c0f8e53..6a8040234 100644 --- a/features/steps/binding_resolution_steps.py +++ b/features/steps/binding_resolution_steps.py @@ -75,6 +75,8 @@ def _make_type_spec( @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] = { @@ -85,6 +87,13 @@ def step_mock_registry(context: Context) -> None: ), "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) @@ -104,8 +113,17 @@ def step_mock_registry(context: Context) -> None: ) 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 @@ -223,6 +241,10 @@ def step_type_parent(context: Context, tname: str, parent: str) -> None: 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') @@ -432,3 +454,29 @@ def _find_result(context: Context, slot_name: str) -> BindingResult: 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 diff --git a/src/cleveragents/application/services/binding_resolution_service.py b/src/cleveragents/application/services/binding_resolution_service.py index 3b37f8a45..3b5ae3c36 100644 --- a/src/cleveragents/application/services/binding_resolution_service.py +++ b/src/cleveragents/application/services/binding_resolution_service.py @@ -43,7 +43,6 @@ from cleveragents.domain.models.core.project import ( ) from cleveragents.domain.models.core.resource import Resource from cleveragents.domain.models.core.resource_slot import BindingResult -from cleveragents.domain.models.core.resource_type import ResourceTypeSpec from cleveragents.domain.models.core.tool import ( BindingMode, ResourceSlot, @@ -60,7 +59,7 @@ class BindingResolutionService: look-ups) and the *project* that owns the plan being activated. The registry is injected as a protocol-compatible object exposing - ``show_resource`` and ``show_type`` methods (matching + ``show_resource`` and ``is_subtype_of`` methods (matching :class:`~cleveragents.application.services.resource_registry_service.ResourceRegistryService`). """ @@ -69,7 +68,7 @@ class BindingResolutionService: Args: resource_registry: Object with ``show_resource(name_or_id)`` - and ``show_type(name)`` methods. + and ``is_subtype_of(type_name, ancestor_name)`` methods. """ self._registry = resource_registry @@ -355,21 +354,12 @@ class BindingResolutionService: ) -> bool: """Check if *actual_type* satisfies *required_type*. - Exact match always passes. If *actual_type* declares - *required_type* in its ``parent_types`` hierarchy, the - check also passes (sub-type relationship). + Exact match always passes. Uses the full inheritance chain via + is_subtype_of() on the registry so that multi-level + inheritance (e.g. devcontainer-instance → container-instance) + is correctly resolved (ADR-042 §Tool Binding Polymorphism). """ - if actual_type == required_type: - return True - - try: - spec: ResourceTypeSpec = self._registry.show_type( - actual_type, - ) - except NotFoundError: - return False - - return required_type in spec.parent_types + return self._registry.is_subtype_of(actual_type, required_type) def _check_type_compatibility( self,