fix(resource-registry): use full inheritance chain in contextual binding type compatibility
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
This commit is contained in:
2026-04-05 09:57:24 +00:00
parent ffb67e15b9
commit 3224f2136e
3 changed files with 134 additions and 19 deletions
@@ -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
@@ -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
@@ -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,