diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ef8c5553..eb19eabbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,14 @@ datetimes. Wired into the DI container. Includes 28 Behave BDD scenarios, 3 Robot Framework integration tests, ASV benchmarks (5 time + 2 track), and reference documentation. (#195) + +### Added +- Resource type single-inheritance via `inherits` field (ADR-042) (#513) +- Inheritance chain resolution, field merging, and polymorphic type matching +- `ToolRegistry.find_tools_for_resource()` for polymorphic tool binding +- Polymorphic handler resolution with ancestor-type fallback +- CLI: `agents resource type list` shows Inherits column; `type show` displays inheritance chain +- Alembic migration `m6_004_resource_type_inherits` adds `inherits` column to `resource_types` - Fixed `agents project show` not finding a project immediately after creation. Extended the `session.commit()` fix from #589 to also cover `update()` and `delete()` in `NamespacedProjectRepository`, and updated the class docstring diff --git a/alembic/versions/m6_004_resource_type_inherits.py b/alembic/versions/m6_004_resource_type_inherits.py new file mode 100644 index 000000000..c60b31215 --- /dev/null +++ b/alembic/versions/m6_004_resource_type_inherits.py @@ -0,0 +1,37 @@ +"""Add inherits column to resource_types for type inheritance (ADR-042). + +Revision ID: m6_004_resource_type_inherits +Revises: m6_003_async_jobs_table +Create Date: 2026-03-06 00:00:00 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "m6_004_resource_type_inherits" +down_revision: str | Sequence[str] | None = "m6_003_async_jobs_table" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Add inherits column and index to resource_types.""" + op.add_column( + "resource_types", + sa.Column("inherits", sa.String(255), nullable=True), + ) + op.create_index( + "ix_resource_types_inherits", + "resource_types", + ["inherits"], + ) + + +def downgrade() -> None: + """Remove inherits column and index from resource_types.""" + op.drop_index("ix_resource_types_inherits", table_name="resource_types") + op.drop_column("resource_types", "inherits") diff --git a/benchmarks/resource_type_inheritance_bench.py b/benchmarks/resource_type_inheritance_bench.py new file mode 100644 index 000000000..585e70587 --- /dev/null +++ b/benchmarks/resource_type_inheritance_bench.py @@ -0,0 +1,157 @@ +"""ASV benchmarks for resource type inheritance resolution engine.""" + +from __future__ import annotations + +from typing import Any + +from cleveragents.resource.inheritance import ( + find_subtypes, + is_subtype_of, + resolve_fields, + resolve_inheritance_chain, +) + + +def _build_linear_registry(depth: int) -> tuple[dict[str, Any], str]: + """Build a linear inheritance chain of *depth* levels. + + Returns ``(registry, leaf_name)`` where the chain is:: + + leaf -> level-(depth-2) -> ... -> level-0 -> root + """ + registry: dict[str, Any] = {"root": {}} + prev = "root" + for i in range(depth - 1): + name = f"level-{i}" + registry[name] = {"inherits": prev} + prev = name + return registry, prev + + +def _build_wide_registry(width: int) -> dict[str, Any]: + """Build a registry with *width* direct children of a single ancestor.""" + registry: dict[str, Any] = {"ancestor": {}} + for i in range(width): + registry[f"child-{i}"] = {"inherits": "ancestor"} + return registry + + +def _build_field_registry(depth: int) -> tuple[dict[str, Any], str]: + """Build a registry with mergeable fields at each level.""" + registry: dict[str, Any] = { + "root": { + "sandbox_strategy": "copy_on_write", + "resource_kind": "physical", + "cli_args": [{"name": "base-arg", "type": "string"}], + "child_types": ["root-child"], + }, + } + prev = "root" + for i in range(depth - 1): + name = f"level-{i}" + registry[name] = { + "inherits": prev, + "resource_kind": f"kind-{i}", + "cli_args": [{"name": f"arg-{i}", "type": "string"}], + "child_types": [f"ct-{i}"], + } + prev = name + return registry, prev + + +# --------------------------------------------------------------------------- +# Chain resolution benchmarks +# --------------------------------------------------------------------------- + + +class InheritanceChainResolutionSuite: + """Benchmark resolve_inheritance_chain at various depths.""" + + def setup(self) -> None: + self.reg_1, self.leaf_1 = _build_linear_registry(1) + self.reg_3, self.leaf_3 = _build_linear_registry(3) + self.reg_5, self.leaf_5 = _build_linear_registry(5) + + def time_resolve_chain_1_level(self) -> None: + resolve_inheritance_chain(self.leaf_1, self.reg_1) + + def time_resolve_chain_3_level(self) -> None: + resolve_inheritance_chain(self.leaf_3, self.reg_3) + + def time_resolve_chain_5_level(self) -> None: + resolve_inheritance_chain(self.leaf_5, self.reg_5) + + +# --------------------------------------------------------------------------- +# Polymorphic subtype check benchmarks +# --------------------------------------------------------------------------- + + +class IsSubtypeOfSuite: + """Benchmark is_subtype_of at various depths.""" + + def setup(self) -> None: + self.reg_1, self.leaf_1 = _build_linear_registry(1) + self.reg_3, self.leaf_3 = _build_linear_registry(3) + self.reg_5, self.leaf_5 = _build_linear_registry(5) + + def time_is_subtype_identity(self) -> None: + is_subtype_of("root", "root", self.reg_1) + + def time_is_subtype_1_level(self) -> None: + is_subtype_of(self.leaf_1, "root", self.reg_1) + + def time_is_subtype_3_level(self) -> None: + is_subtype_of(self.leaf_3, "root", self.reg_3) + + def time_is_subtype_5_level(self) -> None: + is_subtype_of(self.leaf_5, "root", self.reg_5) + + def time_is_subtype_negative(self) -> None: + is_subtype_of("root", self.leaf_5, self.reg_5) + + +# --------------------------------------------------------------------------- +# Field resolution benchmarks +# --------------------------------------------------------------------------- + + +class ResolveFieldsSuite: + """Benchmark resolve_fields with field merging at various depths.""" + + def setup(self) -> None: + self.reg_1, self.leaf_1 = _build_field_registry(1) + self.reg_3, self.leaf_3 = _build_field_registry(3) + self.reg_5, self.leaf_5 = _build_field_registry(5) + + def time_resolve_fields_1_level(self) -> None: + resolve_fields(self.leaf_1, self.reg_1) + + def time_resolve_fields_3_level(self) -> None: + resolve_fields(self.leaf_3, self.reg_3) + + def time_resolve_fields_5_level(self) -> None: + resolve_fields(self.leaf_5, self.reg_5) + + +# --------------------------------------------------------------------------- +# find_subtypes benchmarks +# --------------------------------------------------------------------------- + + +class FindSubtypesSuite: + """Benchmark find_subtypes with varying registry widths.""" + + def setup(self) -> None: + self.reg_10 = _build_wide_registry(10) + self.reg_50 = _build_wide_registry(50) + self.reg_200 = _build_wide_registry(200) + + def time_find_subtypes_10(self) -> None: + find_subtypes("ancestor", self.reg_10) + + def time_find_subtypes_50(self) -> None: + find_subtypes("ancestor", self.reg_50) + + def time_find_subtypes_200(self) -> None: + find_subtypes("ancestor", self.reg_200) diff --git a/docs/reference/resource_type_inheritance.md b/docs/reference/resource_type_inheritance.md new file mode 100644 index 000000000..3208b87cd --- /dev/null +++ b/docs/reference/resource_type_inheritance.md @@ -0,0 +1,233 @@ +# Resource Type Inheritance + +## Overview + +Resource type inheritance allows custom resource types to derive from existing +types, inheriting their field definitions, CLI arguments, DAG constraints, and +tool bindings. A child type can override or extend its parent's configuration +while remaining compatible with tools and handlers that target the parent type. + +This feature was introduced in [ADR-042](../adr/ADR-042-resource-type-inheritance.md) +to support polymorphic tool matching and reduce configuration duplication across +related resource types. + +**Key benefits:** + +- Define specialised resource types without repeating shared configuration. +- Tools bound to a parent type automatically match resources of any subtype. +- Handler resolution falls back through the inheritance chain when a + type-specific handler is not registered. +- DAG queries respect the type hierarchy, returning resources whose type is a + subtype of the requested ancestor. + +## The `inherits` Field + +A resource type declares its parent by setting the `inherits` field to the name +of an existing resource type: + +```yaml +name: myorg/python-repo +inherits: git-checkout +resource_kind: physical +sandbox_strategy: git_worktree +description: A Git checkout containing a Python project. +cli_args: + - name: python-version + type: string + required: false + description: Expected Python version. +``` + +In this example `myorg/python-repo` inherits all fields from `git-checkout` and +adds a `python-version` CLI argument. + +The `inherits` field is optional. When omitted the type is a root type with no +parent. + +## Inheritance Chain Rules + +The following five rules govern inheritance chains. Violations are reported by +`validate_chain()` and prevent the type from being registered. + +### 1. Single Inheritance Only + +A resource type may declare at most one parent via `inherits`. Multiple +inheritance is not supported. + +### 2. Maximum Depth of 5 Levels + +The inheritance chain from a type to its root ancestor must not exceed 5 levels +(i.e., at most 4 `inherits` hops). Deeper chains raise +`ResourceTypeInheritanceDepthError`. + +``` +root -> A -> B -> C -> D # depth 5 — OK +root -> A -> B -> C -> D -> E # depth 6 — rejected +``` + +### 3. No Circular Inheritance + +An inheritance chain must not contain cycles. If registering type `A` with +`inherits: B` would create a cycle (e.g., `B` already inherits from `A`, +directly or transitively), `ResourceTypeCircularInheritanceError` is raised. + +### 4. Built-in Types May Not Inherit from Custom Types + +Built-in resource types (`built_in: true`) are not permitted to set `inherits` +to a custom (namespaced) type. Custom types may inherit from built-in types or +from other custom types. + +### 5. Cannot Remove a Parent While Subtypes Exist + +A resource type's `inherits` field cannot be cleared or changed to a different +parent while other registered types list it as their parent. Attempting to do so +raises `ResourceTypeParentRemovalError`. Remove or re-parent all subtypes first. + +## Field Resolution + +When a type declares `inherits`, its effective configuration is computed by +overlaying the child's explicitly declared scalar fields on top of the parent's +resolved fields (which may themselves be inherited). This is a simple +**last-writer-wins** override for scalar values: + +| Field | Resolution | +|--------------------|---------------------------------------------------| +| `description` | Child value wins if set, otherwise parent's value. | +| `resource_kind` | Child value wins if set, otherwise parent's value. | +| `sandbox_strategy` | Child value wins if set, otherwise parent's value. | +| `user_addable` | Child value wins if set, otherwise parent's value. | +| `handler` | Child value wins if set, otherwise parent's value. | +| `capabilities` | Child value wins if set, otherwise parent's value. | +| `auto_discovery` | Child value wins if set, otherwise parent's value. | +| `equivalence` | Child value wins if set, otherwise parent's value. | + +The `name`, `built_in`, and `inherits` fields are never inherited — they are +intrinsic to the declaring type. + +## Collection Field Merging + +Collection fields use **additive merging** by default: the child's entries are +appended to the parent's resolved entries (duplicates are deduplicated). To +replace the parent's collection entirely, set the corresponding +`_replace` flag to `true`. + +### `cli_args` + +Parent CLI arguments are included in the child type. The child may add new +arguments or override a parent argument by declaring an argument with the same +`name`. To discard all parent arguments and use only the child's definitions: + +```yaml +cli_args_replace: true +cli_args: + - name: custom-arg + type: string +``` + +### `child_types` + +The child type's allowed `child_types` list is the union of the parent's and +the child's declarations. To replace: + +```yaml +child_types_replace: true +child_types: + - myorg/sub-resource +``` + +### `parent_types` + +Behaves identically to `child_types` — additive merge with +`parent_types_replace: true` for full replacement. + +## Polymorphism Guarantees + +### Tool Binding + +`ToolRegistry.find_tools_for_resource()` returns tools bound to the resource's +own type **and** every ancestor type in its inheritance chain. A tool registered +for `git-checkout` will match resources of type `myorg/python-repo` if +`myorg/python-repo` inherits from `git-checkout`. + +### Handler Resolution + +When resolving a handler for a resource, the system walks the inheritance chain +from the most specific type to the root. The first type in the chain that has a +registered handler is used. This allows specialised types to override behaviour +while falling back to the parent handler when no override exists. + +### DAG Queries + +Resource DAG queries that filter by type respect the inheritance hierarchy. A +query for all resources of type `git-checkout` also returns resources whose type +is a subtype of `git-checkout`. Use `is_subtype_of()` for explicit type checks. + +## API Reference + +Public functions exported from `cleveragents.resource.inheritance`: + +| Function | Signature | Description | +|----------|-----------|-------------| +| `resolve_inheritance_chain` | `(type_name: str, type_registry: dict) -> list[str]` | Returns the ordered inheritance chain from `type_name` to the root ancestor (inclusive). Raises `ResourceTypeCircularInheritanceError` on cycles and `ResourceTypeParentNotFoundError` if an ancestor is missing. | +| `validate_chain` | `(type_name: str, inherits: str \| None, type_registry: dict, is_built_in: bool) -> list[str]` | Validates the proposed inheritance chain and returns a list of error messages (empty on success). Checks all five chain rules. | +| `is_subtype_of` | `(type_name: str, ancestor_name: str, type_registry: dict) -> bool` | Returns `True` if `type_name` is equal to or a subtype of `ancestor_name`. | +| `resolve_fields` | `(type_name: str, type_registry: dict) -> dict` | Returns the fully resolved field dictionary for `type_name` after applying inheritance chain field resolution and collection merging. | +| `find_subtypes` | `(ancestor_name: str, type_registry: dict) -> list[str]` | Returns all registered type names that are subtypes of `ancestor_name` (direct and transitive). | + +## Exceptions + +The following exceptions are defined in `cleveragents.resource.inheritance` and +raised when inheritance rules are violated: + +| Exception | Raised When | +|-----------|-------------| +| `ResourceTypeInheritanceDepthError` | The inheritance chain exceeds the maximum depth of 5 levels. | +| `ResourceTypeCircularInheritanceError` | A circular inheritance chain is detected. | +| `ResourceTypeParentNotFoundError` | The type named in `inherits` is not present in the type registry. | +| `ResourceTypeParentRemovalError` | An attempt is made to remove or change a type's parent while subtypes still reference it. | + +All exceptions inherit from `CleverAgentsError` and include the type name and +chain details in their message for diagnostics. + +## CLI + +### `agents resource type list` + +The tabular output includes an **Inherits** column showing the direct parent +type name (blank for root types): + +``` +Name Kind Sandbox Inherits +────────────────────── ───────── ──────────────── ────────────── +git-checkout physical git_worktree +fs-directory physical copy_on_write +myorg/python-repo physical git_worktree git-checkout +``` + +### `agents resource type show ` + +Displays the full resolved configuration for the type, including an +**Inheritance Chain** section: + +``` +Name: myorg/python-repo +Inherits: git-checkout +Inheritance Chain: myorg/python-repo -> git-checkout +Resource Kind: physical +Sandbox Strategy: git_worktree +... +``` + +## Database + +The `resource_types` table includes an `inherits` column: + +| Column | Type | Nullable | Description | +|------------|---------------|----------|------------------------------------------| +| `inherits` | `String(255)` | Yes | Name of the parent resource type, or `NULL` for root types. | + +This column is added by the Alembic migration +`m6_004_resource_type_inherits`. The column is indexed to support efficient +subtype lookups. Foreign-key integrity is enforced at the application layer +(not via a database constraint) because type registration order during +migrations may not satisfy FK ordering requirements. diff --git a/docs/schema/resource_type.schema.yaml b/docs/schema/resource_type.schema.yaml index 51bb269ef..2e294a97a 100644 --- a/docs/schema/resource_type.schema.yaml +++ b/docs/schema/resource_type.schema.yaml @@ -34,6 +34,13 @@ fields: description: > Human-readable description of the resource type. + # ─── Inheritance ────────────────────────────────────────────── + inherits: + type: string + required: false + description: > + Parent resource type name. See ADR-042. + # ─── Classification ──────────────────────────────────────────── resource_kind: type: string diff --git a/features/resource_cli_coverage_boost.feature b/features/resource_cli_coverage_boost.feature index da92d9237..c33f665d8 100644 --- a/features/resource_cli_coverage_boost.feature +++ b/features/resource_cli_coverage_boost.feature @@ -34,21 +34,21 @@ Feature: Resource CLI coverage boost for remaining uncovered lines Then the CliRunner exit code should be non-zero And the CliRunner output should contain "Aborted" - # ---- type_remove row is None (lines 260-262) ---- + # ---- type_remove NotFoundError from service (lines 263-265) ---- - Scenario: type_remove aborts when DB row is None after query - Given a mock resource service whose session returns no row for the type + Scenario: type_remove aborts when service.remove_type raises NotFoundError + Given a mock resource service whose remove_type raises NotFoundError When I invoke type remove with --yes via CliRunner for the phantom type Then the CliRunner exit code should be non-zero And the CliRunner output should contain "Resource type not found" - # ---- type_remove generic exception triggers rollback (lines 267-269) ---- + # ---- type_remove ValidationError from service (lines 269-271) ---- - Scenario: type_remove rolls back session on unexpected exception - Given a mock resource service whose session delete raises a generic exception + Scenario: type_remove aborts when service.remove_type raises ValidationError + Given a mock resource service whose remove_type raises ValidationError When I invoke type remove with --yes via CliRunner for the failing type Then the CliRunner exit code should be non-zero - And the mock session rollback should have been called for type remove + And the CliRunner output should contain "Error:" # ---- resource_remove edge_count > 0 (lines 653-658) ---- diff --git a/features/resource_type_inheritance.feature b/features/resource_type_inheritance.feature new file mode 100644 index 000000000..74ccb8580 --- /dev/null +++ b/features/resource_type_inheritance.feature @@ -0,0 +1,332 @@ +Feature: Resource type inheritance and polymorphic matching + ADR-042 defines single-inheritance for resource types. + Types may declare an `inherits` field pointing to one parent type. + The inheritance engine resolves chains, merges fields, detects cycles, + enforces a depth limit, and enables polymorphic tool matching. + + # ── 1. Schema validation ────────────────────────────────── + + Scenario: YAML with inherits field parses correctly + Given a type-inherit YAML string with inherits "container-instance" + When I parse the type-inherit YAML via ResourceTypeConfigSchema + Then the type-inherit schema inherits field is "container-instance" + + Scenario: YAML with inherits set to null parses as root type + Given a type-inherit YAML string with no inherits + When I parse the type-inherit YAML via ResourceTypeConfigSchema + Then the type-inherit schema inherits field is null + + Scenario: Schema rejects self-inheritance + Given a type-inherit YAML string where name equals inherits "acme/looper" + When I attempt to parse the type-inherit YAML expecting an error + Then the type-inherit parse error mentions "inherit from itself" + + Scenario: Schema rejects built-in inheriting from custom type + Given a type-inherit built-in YAML inheriting from custom "acme/custom-rt" + When I attempt to parse the type-inherit YAML expecting an error + Then the type-inherit parse error mentions "cannot inherit from custom" + + Scenario: YAML with replace flags parses correctly + Given a type-inherit YAML string with cli_args_replace true + When I parse the type-inherit YAML via ResourceTypeConfigSchema + Then the type-inherit schema cli_args_replace is true + + # ── 2. Field resolution ─────────────────────────────────── + + Scenario: Scalar fields in subtype override parent + Given a type-inherit registry with parent "container-instance" description "Parent desc" + And a type-inherit child "acme/my-container" inheriting from "container-instance" with description "Child desc" + When I resolve type-inherit fields for "acme/my-container" + Then the type-inherit resolved description is "Child desc" + + Scenario: Scalar fields fall through from parent when absent in child + Given a type-inherit registry with parent "container-instance" having handler "parent.Handler" + And a type-inherit child "acme/my-container" inheriting from "container-instance" without handler + When I resolve type-inherit fields for "acme/my-container" + Then the type-inherit resolved handler is "parent.Handler" + + # ── 3. Collection merging ───────────────────────────────── + + Scenario: cli_args additive merge — parent args preserved, child appended + Given a type-inherit parent "container-instance" with cli_args "path,port" + And a type-inherit child "acme/my-container" inheriting from "container-instance" with cli_args "env" + When I resolve type-inherit fields for "acme/my-container" + Then the type-inherit resolved cli_args names are "path, port, env" + + Scenario: cli_args same-name replacement during merge + Given a type-inherit parent "container-instance" with cli_args "path,port" + And a type-inherit child "acme/my-container" inheriting from "container-instance" with cli_args "port" + When I resolve type-inherit fields for "acme/my-container" + Then the type-inherit resolved cli_args count is 2 + And the type-inherit resolved cli_args names are "path, port" + + Scenario: cli_args replace mode replaces parent entirely + Given a type-inherit parent "container-instance" with cli_args "path,port" + And a type-inherit child "acme/my-container" inheriting from "container-instance" with cli_args "env" and cli_args_replace + When I resolve type-inherit fields for "acme/my-container" + Then the type-inherit resolved cli_args names are "env" + + Scenario: child_types additive merge — union deduplication + Given a type-inherit parent "container-instance" with child_types "fs-directory,fs-file" + And a type-inherit child "acme/my-container" inheriting from "container-instance" with child_types "fs-file,fs-mount" + When I resolve type-inherit fields for "acme/my-container" + Then the type-inherit resolved child_types are "fs-directory, fs-file, fs-mount" + + Scenario: child_types replace mode replaces parent entirely + Given a type-inherit parent "container-instance" with child_types "fs-directory,fs-file" + And a type-inherit child "acme/my-container" inheriting from "container-instance" with child_types "fs-mount" and child_types_replace + When I resolve type-inherit fields for "acme/my-container" + Then the type-inherit resolved child_types are "fs-mount" + + Scenario: parent_types additive merge + Given a type-inherit parent "container-instance" with parent_types "git-checkout" + And a type-inherit child "acme/my-container" inheriting from "container-instance" with parent_types "fs-mount" + When I resolve type-inherit fields for "acme/my-container" + Then the type-inherit resolved parent_types are "git-checkout, fs-mount" + + Scenario: parent_types replace mode + Given a type-inherit parent "container-instance" with parent_types "git-checkout" + And a type-inherit child "acme/my-container" inheriting from "container-instance" with parent_types "fs-mount" and parent_types_replace + When I resolve type-inherit fields for "acme/my-container" + Then the type-inherit resolved parent_types are "fs-mount" + + # ── 4. Chain resolution ─────────────────────────────────── + + Scenario: Single-level inheritance chain resolution + Given a type-inherit registry with "container-instance" as root + And a type-inherit type "acme/devcontainer" inheriting from "container-instance" + When I resolve the type-inherit chain for "acme/devcontainer" + Then the type-inherit chain is "acme/devcontainer, container-instance" + + Scenario: Multi-level inheritance chain resolution + Given a type-inherit registry with "container-instance" as root + And a type-inherit type "acme/app-container" inheriting from "container-instance" + And a type-inherit type "acme/web-container" inheriting from "acme/app-container" + When I resolve the type-inherit chain for "acme/web-container" + Then the type-inherit chain is "acme/web-container, acme/app-container, container-instance" + + Scenario: Root type chain is just itself + Given a type-inherit registry with "container-instance" as root + When I resolve the type-inherit chain for "container-instance" + Then the type-inherit chain is "container-instance" + + Scenario: validate_chain succeeds for valid chain + Given a type-inherit registry with "container-instance" as root + When I validate type-inherit chain for "acme/devcontainer" inheriting "container-instance" + Then the type-inherit validated chain is "acme/devcontainer, container-instance" + + Scenario: validate_chain rejects missing parent + Given a type-inherit registry with "container-instance" as root + When I validate type-inherit chain for "acme/devcontainer" inheriting "nonexistent" expecting error + Then the type-inherit validation error is ResourceTypeParentNotFoundError + + # ── 5. Polymorphism ─────────────────────────────────────── + + Scenario: is_subtype_of returns true for direct child + Given a type-inherit registry with "container-instance" as root + And a type-inherit type "acme/devcontainer" inheriting from "container-instance" + When I check type-inherit is_subtype_of "acme/devcontainer" and "container-instance" + Then the type-inherit subtype check is true + + Scenario: is_subtype_of returns true for same type + Given a type-inherit registry with "container-instance" as root + When I check type-inherit is_subtype_of "container-instance" and "container-instance" + Then the type-inherit subtype check is true + + Scenario: is_subtype_of returns false for unrelated types + Given a type-inherit registry with "container-instance" as root + And a type-inherit registry also has "git-checkout" as root + When I check type-inherit is_subtype_of "container-instance" and "git-checkout" + Then the type-inherit subtype check is false + + Scenario: is_subtype_of returns true for transitive ancestor + Given a type-inherit registry with "container-instance" as root + And a type-inherit type "acme/app-container" inheriting from "container-instance" + And a type-inherit type "acme/web-container" inheriting from "acme/app-container" + When I check type-inherit is_subtype_of "acme/web-container" and "container-instance" + Then the type-inherit subtype check is true + + Scenario: Tool bound to parent matches subtype resource + Given a type-inherit registry with "container-instance" as root + And a type-inherit type "acme/devcontainer" inheriting from "container-instance" + And a type-inherit tool "ns/deploy" bound to resource type "container-instance" + When I find type-inherit tools for resource type "acme/devcontainer" + Then the type-inherit matched tools include "ns/deploy" + + Scenario: Tool bound to child does not match parent resource + Given a type-inherit registry with "container-instance" as root + And a type-inherit type "acme/devcontainer" inheriting from "container-instance" + And a type-inherit tool "ns/special" bound to resource type "acme/devcontainer" + When I find type-inherit tools for resource type "container-instance" + Then the type-inherit matched tools list is empty + + Scenario: find_subtypes returns all descendants + Given a type-inherit registry with "container-instance" as root + And a type-inherit type "acme/app-container" inheriting from "container-instance" + And a type-inherit type "acme/web-container" inheriting from "acme/app-container" + When I find type-inherit subtypes of "container-instance" + Then the type-inherit subtypes include "acme/app-container" + And the type-inherit subtypes include "acme/web-container" + + # ── 6. Cycle detection ──────────────────────────────────── + + Scenario: Circular two-type cycle is rejected + Given a type-inherit registry with "acme/alpha" inheriting from "acme/beta" + And a type-inherit registry with "acme/beta" inheriting from "acme/alpha" + When I resolve the type-inherit chain for "acme/alpha" expecting error + Then the type-inherit chain error is ResourceTypeCircularInheritanceError + + # ── 7. Depth limit ──────────────────────────────────────── + + Scenario: Chain exceeding MAX_CHAIN_DEPTH is rejected + Given a type-inherit registry with a chain of depth 6 + When I resolve the type-inherit chain for the deepest type expecting error + Then the type-inherit chain error is ResourceTypeInheritanceDepthError + + Scenario: Chain at exactly MAX_CHAIN_DEPTH succeeds + Given a type-inherit registry with a chain of depth 5 + When I resolve the type-inherit chain for the deepest type + Then the type-inherit chain length is 5 + + # ── 8. Parent removal guard ─────────────────────────────── + + Scenario: Parent type with subtypes cannot be removed + Given a type-inherit registry with "container-instance" as root + And a type-inherit type "acme/devcontainer" inheriting from "container-instance" + When I attempt to type-inherit remove "container-instance" + Then the type-inherit removal error is ResourceTypeParentRemovalError + + # ── 9. Additional coverage ──────────────────────────────── + + Scenario: Circular three-type cycle is rejected + Given a type-inherit registry with "acme/alpha" inheriting from "acme/beta" + And a type-inherit registry with "acme/beta" inheriting from "acme/gamma" + And a type-inherit registry with "acme/gamma" inheriting from "acme/alpha" + When I resolve the type-inherit chain for "acme/alpha" expecting error + Then the type-inherit chain error is ResourceTypeCircularInheritanceError + + Scenario: Three-level field merge — grandchild overrides child and inherits root + Given a type-inherit parent "container-instance" with cli_args "path,port" + And a type-inherit child "acme/app-container" inheriting from "container-instance" with cli_args "env" + And a type-inherit grandchild "acme/web-container" inheriting from "acme/app-container" with cli_args "host" + When I resolve type-inherit fields for "acme/web-container" + Then the type-inherit resolved cli_args names are "path, port, env, host" + + Scenario: resolve_handler_polymorphic falls back to ancestor handler + Given a type-inherit registry with "container-instance" as root with handler "cleveragents.resource.handlers.fs_directory:FsDirectoryHandler" + And a type-inherit type "acme/devcontainer" inheriting from "container-instance" + When I resolve type-inherit handler polymorphic for "acme/devcontainer" + Then the type-inherit resolved handler is not None + + Scenario: validate_chain rejects built-in type inheriting custom parent + Given a type-inherit registry with "acme/custom-parent" as root + When I validate type-inherit chain for built-in "my-builtin" inheriting "acme/custom-parent" expecting error + Then the type-inherit validation error is ValueError + + # ── 10. Coverage: edge cases ────────────────────────────── + + Scenario: Chain resolution raises ParentNotFoundError for missing parent + Given a type-inherit registry with "acme/child" inheriting from "acme/missing" + When I resolve the type-inherit chain for "acme/child" expecting error + Then the type-inherit chain error is ResourceTypeParentNotFoundError + + Scenario: Chain resolution returns singleton for unregistered type + When I resolve the type-inherit chain for "acme/never-registered" + Then the type-inherit chain is "acme/never-registered" + + Scenario: validate_chain returns singleton when inherits is None + Given a type-inherit registry with "container-instance" as root + When I validate type-inherit chain for "acme/standalone" inheriting nothing + Then the type-inherit validated chain is "acme/standalone" + + Scenario: is_subtype_of returns false when chain has errors + Given a type-inherit registry with "acme/broken" inheriting from "acme/missing" + When I check type-inherit is_subtype_of "acme/broken" and "acme/missing" + Then the type-inherit subtype check is false + + Scenario: resolve_fields with root-only type returns its own fields + Given a type-inherit registry with parent "container-instance" description "Root container type" + When I resolve type-inherit fields for "container-instance" + Then the type-inherit resolved description is "Root container type" + + Scenario: _get_inherits works with object-style entries + Given a type-inherit registry with object-style entry "acme/obj-child" inheriting "container-instance" + And a type-inherit registry with "container-instance" as root + When I resolve the type-inherit chain for "acme/obj-child" + Then the type-inherit chain is "acme/obj-child, container-instance" + + Scenario: _to_dict works with Pydantic model_dump entries + Given a type-inherit registry with pydantic-style entry "acme/pydantic-type" having description "Pydantic type" + When I resolve type-inherit fields for "acme/pydantic-type" + Then the type-inherit resolved description is "Pydantic type" + + Scenario: _merge_cli_args returns child when parent has no args + Given a type-inherit parent "empty-parent" with no cli_args + And a type-inherit child "acme/child-only" inheriting from "empty-parent" with cli_args "path" + When I resolve type-inherit fields for "acme/child-only" + Then the type-inherit resolved cli_args names are "path" + + Scenario: _merge_cli_args returns parent when child has no args + Given a type-inherit parent "container-instance" with cli_args "path,port" + And a type-inherit child "acme/bare-child" inheriting from "container-instance" with no cli_args + When I resolve type-inherit fields for "acme/bare-child" + Then the type-inherit resolved cli_args names are "path, port" + + Scenario: resolve_handler_polymorphic raises when no handler in chain + Given a type-inherit registry with "container-instance" as root + And a type-inherit type "acme/no-handler" inheriting from "container-instance" + When I resolve type-inherit handler polymorphic for "acme/no-handler" expecting error + Then the type-inherit handler error is HandlerResolutionError + + Scenario: Tool registry duplicate add raises ToolError + Given a type-inherit tool registry with tool "ns/dup-tool" bound to "container-instance" + When I add type-inherit tool "ns/dup-tool" again expecting error + Then the type-inherit tool error is ToolError + + Scenario: Tool registry get returns None for unknown tool + Given a type-inherit empty tool registry + When I get type-inherit tool "ns/unknown" + Then the type-inherit tool result is None + + Scenario: Tool registry list_tools with namespace filter + Given a type-inherit tool registry with tool "ns/tool-a" bound to "container-instance" + And a type-inherit tool registry with tool "other/tool-b" bound to "git-checkout" + When I list type-inherit tools with namespace "ns" + Then the type-inherit tools list has 1 entry + + Scenario: Tool registry remove returns true for existing tool + Given a type-inherit tool registry with tool "ns/to-remove" bound to "container-instance" + When I remove type-inherit tool "ns/to-remove" + Then the type-inherit tool remove result is true + + Scenario: Tool registry remove returns false for missing tool + Given a type-inherit empty tool registry + When I remove type-inherit tool "ns/nonexistent" + Then the type-inherit tool remove result is false + + Scenario: find_tools_for_resource skips tools with no bindings + Given a type-inherit registry with "container-instance" as root + And a type-inherit tool registry with unbound tool "ns/no-bindings" + When I find type-inherit tools for resource type "container-instance" + Then the type-inherit matched tools list is empty + + Scenario: Tool registry list_tools with source filter + Given a type-inherit tool registry with tool "ns/tool-x" bound to "container-instance" + When I list type-inherit tools with source "test" + Then the type-inherit tools list has 1 entry + + Scenario: resolve_handler raises on empty reference + When I resolve type-inherit handler with empty reference expecting error + Then the type-inherit handler error is HandlerResolutionError + + Scenario: resolve_handler raises on missing module + When I resolve type-inherit handler with reference "nonexistent.module:Cls" expecting error + Then the type-inherit handler error is HandlerResolutionError + + Scenario: resolve_handler raises on missing class + When I resolve type-inherit handler with reference "cleveragents.resource.handlers.fs_directory:NonexistentClass" expecting error + Then the type-inherit handler error is HandlerResolutionError + + Scenario: resolve_handler raises on malformed colon-only reference + When I resolve type-inherit handler with reference ":ClassName" expecting error + Then the type-inherit handler error is HandlerResolutionError diff --git a/features/steps/resource_cli_coverage_boost_steps.py b/features/steps/resource_cli_coverage_boost_steps.py index bcc516dcf..b444349bb 100644 --- a/features/steps/resource_cli_coverage_boost_steps.py +++ b/features/steps/resource_cli_coverage_boost_steps.py @@ -6,8 +6,8 @@ Covers the remaining uncovered lines and partial branches in - Lines 79-81: ``_get_registry_service()`` - Lines 194-195: ``type_add`` FileNotFoundError handler - Lines 234-236: ``type_remove`` user declines confirmation -- Lines 260-262: ``type_remove`` DB row is None -- Lines 267-269: ``type_remove`` generic Exception → rollback +- Lines 263-265: ``type_remove`` NotFoundError from service +- Lines 269-271: ``type_remove`` ValidationError from service - Lines 653-658: ``resource_remove`` edge_count > 0 - Lines 668-672: ``resource_remove`` generic Exception → rollback """ @@ -215,39 +215,25 @@ def step_invoke_type_remove_no(context: Context) -> None: # --------------------------------------------------------------------------- -# Scenario: type_remove aborts when DB row is None after query +# Scenario: type_remove aborts when service.remove_type raises NotFoundError # --------------------------------------------------------------------------- -@given("a mock resource service whose session returns no row for the type") -def step_mock_service_type_row_none(context: Context) -> None: +@given("a mock resource service whose remove_type raises NotFoundError") +def step_mock_service_remove_type_not_found(context: Context) -> None: + from cleveragents.core.exceptions import NotFoundError + svc = MagicMock() svc.show_type.return_value = _make_mock_type_spec(built_in=False) - - # Build the mock session with smart query dispatch - mock_session = MagicMock() - - # ResourceModel query → count returns 0 (no resources reference this type) - resource_model_query = MagicMock() - resource_model_query.filter.return_value.count.return_value = 0 - - # ResourceTypeModel query → first returns None (row not found) - type_model_query = MagicMock() - type_model_query.filter_by.return_value.first.return_value = None - - mock_session.query.side_effect = _smart_query_side_effect( - { - "ResourceModel": resource_model_query, - "ResourceTypeModel": type_model_query, - } + svc.remove_type.side_effect = NotFoundError( + resource_type="resource_type", + resource_id="test/mock-type", ) - svc._session.return_value = mock_session - context.rcb_mock_service = svc @when("I invoke type remove with --yes via CliRunner for the phantom type") -def step_invoke_type_remove_row_none(context: Context) -> None: +def step_invoke_type_remove_not_found(context: Context) -> None: from cleveragents.cli.commands.resource import app runner = CliRunner() @@ -262,43 +248,23 @@ def step_invoke_type_remove_row_none(context: Context) -> None: # --------------------------------------------------------------------------- -# Scenario: type_remove rolls back session on unexpected exception +# Scenario: type_remove aborts when service.remove_type raises ValidationError # --------------------------------------------------------------------------- -@given("a mock resource service whose session delete raises a generic exception") -def step_mock_service_type_delete_exception(context: Context) -> None: +@given("a mock resource service whose remove_type raises ValidationError") +def step_mock_service_remove_type_validation_error(context: Context) -> None: svc = MagicMock() svc.show_type.return_value = _make_mock_type_spec(built_in=False) - - mock_session = MagicMock() - - # ResourceModel query → count returns 0 - resource_model_query = MagicMock() - resource_model_query.filter.return_value.count.return_value = 0 - - # ResourceTypeModel query → first returns a mock row - mock_row = MagicMock() - type_model_query = MagicMock() - type_model_query.filter_by.return_value.first.return_value = mock_row - - mock_session.query.side_effect = _smart_query_side_effect( - { - "ResourceModel": resource_model_query, - "ResourceTypeModel": type_model_query, - } + svc.remove_type.side_effect = ValidationError( + message="Cannot remove type: 5 resource(s) still reference it.", + details={"name": "test/mock-type"}, ) - # session.delete raises a generic exception - mock_session.delete.side_effect = RuntimeError("unexpected DB failure") - - svc._session.return_value = mock_session - context.rcb_mock_service = svc - context.rcb_mock_session = mock_session @when("I invoke type remove with --yes via CliRunner for the failing type") -def step_invoke_type_remove_exception(context: Context) -> None: +def step_invoke_type_remove_validation_error(context: Context) -> None: from cleveragents.cli.commands.resource import app runner = CliRunner() @@ -312,11 +278,6 @@ def step_invoke_type_remove_exception(context: Context) -> None: ) -@then("the mock session rollback should have been called for type remove") -def step_verify_type_rollback(context: Context) -> None: - context.rcb_mock_session.rollback.assert_called() - - # --------------------------------------------------------------------------- # Scenario: resource_remove aborts when resource has edges # --------------------------------------------------------------------------- diff --git a/features/steps/resource_type_inheritance_steps.py b/features/steps/resource_type_inheritance_steps.py new file mode 100644 index 000000000..049881137 --- /dev/null +++ b/features/steps/resource_type_inheritance_steps.py @@ -0,0 +1,1023 @@ +"""Step definitions for resource_type_inheritance.feature. + +Tests the ADR-042 resource type inheritance engine including chain +resolution, field merging, polymorphic type checks, cycle detection, +depth limits, and parent-removal guards. +""" + +from __future__ import annotations + +from typing import Any + +from behave import given, then, when + +from cleveragents.resource.inheritance import ( + ResourceTypeCircularInheritanceError, + ResourceTypeInheritanceDepthError, + ResourceTypeParentNotFoundError, + ResourceTypeParentRemovalError, + find_subtypes, + is_subtype_of, + resolve_fields, + resolve_inheritance_chain, + validate_chain, +) +from cleveragents.resource.schema import ResourceTypeConfigSchema +from cleveragents.tool.registry import ToolRegistry +from cleveragents.tool.runtime import ToolSpec + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_NOOP_HANDLER = lambda **kw: None # noqa: E731 + + +def _make_root_entry( + *, + description: str = "Root type", + handler: str | None = None, + cli_args: list[dict[str, Any]] | None = None, + child_types: list[str] | None = None, + parent_types: list[str] | None = None, +) -> dict[str, Any]: + """Create a minimal root type registry entry (no inherits).""" + entry: dict[str, Any] = {"description": description} + if handler is not None: + entry["handler"] = handler + if cli_args is not None: + entry["cli_args"] = cli_args + if child_types is not None: + entry["child_types"] = child_types + if parent_types is not None: + entry["parent_types"] = parent_types + return entry + + +def _make_child_entry( + parent: str, + *, + description: str | None = None, + handler: str | None = None, + cli_args: list[dict[str, Any]] | None = None, + cli_args_replace: bool = False, + child_types: list[str] | None = None, + child_types_replace: bool = False, + parent_types: list[str] | None = None, + parent_types_replace: bool = False, +) -> dict[str, Any]: + """Create a child type registry entry pointing at *parent*.""" + entry: dict[str, Any] = {"inherits": parent} + if description is not None: + entry["description"] = description + if handler is not None: + entry["handler"] = handler + if cli_args is not None: + entry["cli_args"] = cli_args + if cli_args_replace: + entry["cli_args_replace"] = True + if child_types is not None: + entry["child_types"] = child_types + if child_types_replace: + entry["child_types_replace"] = True + if parent_types is not None: + entry["parent_types"] = parent_types + if parent_types_replace: + entry["parent_types_replace"] = True + return entry + + +def _names_from_csv(csv: str) -> list[str]: + """Split a comma-separated string into stripped tokens.""" + return [t.strip() for t in csv.split(",") if t.strip()] + + +def _ensure_registry(context: Any) -> dict[str, Any]: + """Return (and lazily create) the type registry on *context*.""" + if not hasattr(context, "type_inherit_registry"): + context.type_inherit_registry = {} # type: ignore[attr-defined] + return context.type_inherit_registry # type: ignore[attr-defined] + + +def _ensure_tool_registry(context: Any) -> ToolRegistry: + """Return (and lazily create) the ToolRegistry on *context*.""" + if not hasattr(context, "type_inherit_tool_registry"): + context.type_inherit_tool_registry = ToolRegistry() # type: ignore[attr-defined] + return context.type_inherit_tool_registry # type: ignore[attr-defined] + + +# ═══════════════════════════════════════════════════════════════ +# 1. Schema validation — GIVEN / WHEN / THEN +# ═══════════════════════════════════════════════════════════════ + + +@given('a type-inherit YAML string with inherits "{parent}"') +def step_yaml_with_inherits(context: Any, parent: str) -> None: + """Prepare a YAML string that declares an inherits field.""" + context.type_inherit_yaml = ( # type: ignore[attr-defined] + f"name: acme/child-type\n" + f"resource_kind: physical\n" + f"sandbox_strategy: none\n" + f"inherits: {parent}\n" + ) + + +@given("a type-inherit YAML string with no inherits") +def step_yaml_without_inherits(context: Any) -> None: + """Prepare a YAML string with no inherits field.""" + context.type_inherit_yaml = ( # type: ignore[attr-defined] + "name: acme/root-type\nresource_kind: physical\nsandbox_strategy: none\n" + ) + + +@given('a type-inherit YAML string where name equals inherits "{name}"') +def step_yaml_self_inherit(context: Any, name: str) -> None: + """Prepare a YAML string where name == inherits (self-loop).""" + context.type_inherit_yaml = ( # type: ignore[attr-defined] + f"name: {name}\n" + f"resource_kind: physical\n" + f"sandbox_strategy: none\n" + f"inherits: {name}\n" + ) + + +@given('a type-inherit built-in YAML inheriting from custom "{parent}"') +def step_yaml_builtin_inherits_custom(context: Any, parent: str) -> None: + """Prepare a YAML for a built-in type inheriting from a custom type.""" + context.type_inherit_yaml = ( # type: ignore[attr-defined] + "name: my-builtin\n" + "resource_kind: physical\n" + "sandbox_strategy: none\n" + "built_in: true\n" + f"inherits: {parent}\n" + ) + + +@given("a type-inherit YAML string with cli_args_replace true") +def step_yaml_with_replace_flag(context: Any) -> None: + """Prepare a YAML string with a replace flag enabled.""" + context.type_inherit_yaml = ( # type: ignore[attr-defined] + "name: acme/child-type\n" + "resource_kind: physical\n" + "sandbox_strategy: none\n" + "inherits: container-instance\n" + "cli_args_replace: true\n" + ) + + +@when("I parse the type-inherit YAML via ResourceTypeConfigSchema") +def step_parse_yaml(context: Any) -> None: + """Parse the prepared YAML via the schema model.""" + context.type_inherit_schema = ResourceTypeConfigSchema.from_yaml( # type: ignore[attr-defined] + context.type_inherit_yaml # type: ignore[attr-defined] + ) + + +@when("I attempt to parse the type-inherit YAML expecting an error") +def step_parse_yaml_error(context: Any) -> None: + """Attempt to parse YAML and capture any validation error.""" + try: + ResourceTypeConfigSchema.from_yaml( + context.type_inherit_yaml # type: ignore[attr-defined] + ) + context.type_inherit_parse_error = None # type: ignore[attr-defined] + except Exception as exc: + context.type_inherit_parse_error = exc # type: ignore[attr-defined] + + +@then('the type-inherit schema inherits field is "{value}"') +def step_schema_inherits_value(context: Any, value: str) -> None: + """Assert the parsed schema has the expected inherits value.""" + schema = context.type_inherit_schema # type: ignore[attr-defined] + assert schema.inherits == value, ( + f"Expected inherits={value!r}, got {schema.inherits!r}" + ) + + +@then("the type-inherit schema inherits field is null") +def step_schema_inherits_null(context: Any) -> None: + """Assert the parsed schema has inherits=None.""" + schema = context.type_inherit_schema # type: ignore[attr-defined] + assert schema.inherits is None, f"Expected inherits=None, got {schema.inherits!r}" + + +@then('the type-inherit parse error mentions "{fragment}"') +def step_parse_error_mentions(context: Any, fragment: str) -> None: + """Assert the captured parse error contains the expected fragment.""" + err = context.type_inherit_parse_error # type: ignore[attr-defined] + assert err is not None, "Expected a parse error but none was raised" + assert fragment.lower() in str(err).lower(), ( + f"Expected error to mention {fragment!r}, got: {err}" + ) + + +@then("the type-inherit schema cli_args_replace is true") +def step_schema_cli_args_replace(context: Any) -> None: + """Assert the replace flag is True.""" + schema = context.type_inherit_schema # type: ignore[attr-defined] + assert schema.cli_args_replace is True, ( + f"Expected cli_args_replace=True, got {schema.cli_args_replace!r}" + ) + + +# ═══════════════════════════════════════════════════════════════ +# 2. Field resolution — GIVEN / WHEN / THEN +# ═══════════════════════════════════════════════════════════════ + + +@given('a type-inherit registry with parent "{name}" description "{desc}"') +def step_registry_parent_with_desc(context: Any, name: str, desc: str) -> None: + """Add a root type with a specific description.""" + reg = _ensure_registry(context) + reg[name] = _make_root_entry(description=desc) + + +@given( + 'a type-inherit child "{child}" inheriting from "{parent}" with description "{desc}"' +) +def step_registry_child_with_desc( + context: Any, child: str, parent: str, desc: str +) -> None: + """Add a child type with a specific description.""" + reg = _ensure_registry(context) + reg[child] = _make_child_entry(parent, description=desc) + + +@given('a type-inherit registry with parent "{name}" having handler "{handler}"') +def step_registry_parent_with_handler(context: Any, name: str, handler: str) -> None: + """Add a root type with a specific handler.""" + reg = _ensure_registry(context) + reg[name] = _make_root_entry(handler=handler) + + +@given('a type-inherit child "{child}" inheriting from "{parent}" without handler') +def step_registry_child_no_handler(context: Any, child: str, parent: str) -> None: + """Add a child type that does not set its own handler.""" + reg = _ensure_registry(context) + reg[child] = _make_child_entry(parent) + + +@when('I resolve type-inherit fields for "{name}"') +def step_resolve_fields(context: Any, name: str) -> None: + """Resolve merged fields for the given type.""" + reg = _ensure_registry(context) + context.type_inherit_resolved = resolve_fields(name, reg) # type: ignore[attr-defined] + + +@then('the type-inherit resolved description is "{desc}"') +def step_resolved_desc(context: Any, desc: str) -> None: + """Assert the resolved description.""" + resolved = context.type_inherit_resolved # type: ignore[attr-defined] + assert resolved["description"] == desc, ( + f"Expected description={desc!r}, got {resolved['description']!r}" + ) + + +@then('the type-inherit resolved handler is "{handler}"') +def step_resolved_handler(context: Any, handler: str) -> None: + """Assert the resolved handler.""" + resolved = context.type_inherit_resolved # type: ignore[attr-defined] + assert resolved.get("handler") == handler, ( + f"Expected handler={handler!r}, got {resolved.get('handler')!r}" + ) + + +# ═══════════════════════════════════════════════════════════════ +# 3. Collection merging — GIVEN / WHEN / THEN +# ═══════════════════════════════════════════════════════════════ + + +@given('a type-inherit parent "{name}" with cli_args "{args_csv}"') +def step_parent_cli_args(context: Any, name: str, args_csv: str) -> None: + """Add a root type whose cli_args contain the given named arguments.""" + reg = _ensure_registry(context) + cli_args = [{"name": n} for n in _names_from_csv(args_csv)] + reg[name] = _make_root_entry(cli_args=cli_args) + + +@given( + 'a type-inherit child "{child}" inheriting from "{parent}" with cli_args "{args_csv}"' +) +def step_child_cli_args(context: Any, child: str, parent: str, args_csv: str) -> None: + """Add a child type with its own cli_args.""" + reg = _ensure_registry(context) + cli_args = [{"name": n} for n in _names_from_csv(args_csv)] + reg[child] = _make_child_entry(parent, cli_args=cli_args) + + +@given( + 'a type-inherit child "{child}" inheriting from "{parent}" with cli_args "{args_csv}" and cli_args_replace' +) +def step_child_cli_args_replace( + context: Any, child: str, parent: str, args_csv: str +) -> None: + """Add a child type with cli_args and the replace flag.""" + reg = _ensure_registry(context) + cli_args = [{"name": n} for n in _names_from_csv(args_csv)] + reg[child] = _make_child_entry(parent, cli_args=cli_args, cli_args_replace=True) + + +@given('a type-inherit parent "{name}" with child_types "{types_csv}"') +def step_parent_child_types(context: Any, name: str, types_csv: str) -> None: + """Add a root type with specific child_types.""" + reg = _ensure_registry(context) + reg[name] = _make_root_entry(child_types=_names_from_csv(types_csv)) + + +@given( + 'a type-inherit child "{child}" inheriting from "{parent}" with child_types "{types_csv}"' +) +def step_child_child_types( + context: Any, child: str, parent: str, types_csv: str +) -> None: + """Add a child type with its own child_types.""" + reg = _ensure_registry(context) + reg[child] = _make_child_entry(parent, child_types=_names_from_csv(types_csv)) + + +@given( + 'a type-inherit child "{child}" inheriting from "{parent}" with child_types "{types_csv}" and child_types_replace' +) +def step_child_child_types_replace( + context: Any, child: str, parent: str, types_csv: str +) -> None: + """Add a child type with child_types and the replace flag.""" + reg = _ensure_registry(context) + reg[child] = _make_child_entry( + parent, child_types=_names_from_csv(types_csv), child_types_replace=True + ) + + +@given('a type-inherit parent "{name}" with parent_types "{types_csv}"') +def step_parent_parent_types(context: Any, name: str, types_csv: str) -> None: + """Add a root type with specific parent_types.""" + reg = _ensure_registry(context) + reg[name] = _make_root_entry(parent_types=_names_from_csv(types_csv)) + + +@given( + 'a type-inherit child "{child}" inheriting from "{parent}" with parent_types "{types_csv}"' +) +def step_child_parent_types( + context: Any, child: str, parent: str, types_csv: str +) -> None: + """Add a child type with its own parent_types.""" + reg = _ensure_registry(context) + reg[child] = _make_child_entry(parent, parent_types=_names_from_csv(types_csv)) + + +@given( + 'a type-inherit child "{child}" inheriting from "{parent}" with parent_types "{types_csv}" and parent_types_replace' +) +def step_child_parent_types_replace( + context: Any, child: str, parent: str, types_csv: str +) -> None: + """Add a child type with parent_types and the replace flag.""" + reg = _ensure_registry(context) + reg[child] = _make_child_entry( + parent, parent_types=_names_from_csv(types_csv), parent_types_replace=True + ) + + +@then('the type-inherit resolved cli_args names are "{expected_csv}"') +def step_resolved_cli_args_names(context: Any, expected_csv: str) -> None: + """Assert the resolved cli_args name list (order-sensitive).""" + resolved = context.type_inherit_resolved # type: ignore[attr-defined] + names = [a["name"] for a in resolved.get("cli_args", [])] + expected = _names_from_csv(expected_csv) + assert names == expected, f"Expected cli_args names {expected}, got {names}" + + +@then("the type-inherit resolved cli_args count is {count:d}") +def step_resolved_cli_args_count(context: Any, count: int) -> None: + """Assert the number of resolved cli_args.""" + resolved = context.type_inherit_resolved # type: ignore[attr-defined] + actual = len(resolved.get("cli_args", [])) + assert actual == count, f"Expected {count} cli_args, got {actual}" + + +@then('the type-inherit resolved child_types are "{expected_csv}"') +def step_resolved_child_types(context: Any, expected_csv: str) -> None: + """Assert the resolved child_types list.""" + resolved = context.type_inherit_resolved # type: ignore[attr-defined] + actual = resolved.get("child_types", []) + expected = _names_from_csv(expected_csv) + assert actual == expected, f"Expected child_types {expected}, got {actual}" + + +@then('the type-inherit resolved parent_types are "{expected_csv}"') +def step_resolved_parent_types(context: Any, expected_csv: str) -> None: + """Assert the resolved parent_types list.""" + resolved = context.type_inherit_resolved # type: ignore[attr-defined] + actual = resolved.get("parent_types", []) + expected = _names_from_csv(expected_csv) + assert actual == expected, f"Expected parent_types {expected}, got {actual}" + + +# ═══════════════════════════════════════════════════════════════ +# 4. Chain resolution — GIVEN / WHEN / THEN +# ═══════════════════════════════════════════════════════════════ + + +@given('a type-inherit registry with "{name}" as root') +def step_registry_root(context: Any, name: str) -> None: + """Add a root type (no inherits) to the registry.""" + reg = _ensure_registry(context) + reg[name] = _make_root_entry() + + +@given('a type-inherit type "{child}" inheriting from "{parent}"') +def step_registry_child(context: Any, child: str, parent: str) -> None: + """Add a child type to the registry.""" + reg = _ensure_registry(context) + reg[child] = _make_child_entry(parent) + + +@given('a type-inherit registry also has "{name}" as root') +def step_registry_additional_root(context: Any, name: str) -> None: + """Add another root type to the existing registry.""" + reg = _ensure_registry(context) + reg[name] = _make_root_entry() + + +@when('I resolve the type-inherit chain for "{name}"') +def step_resolve_chain(context: Any, name: str) -> None: + """Resolve the inheritance chain for the named type.""" + reg = _ensure_registry(context) + context.type_inherit_chain = resolve_inheritance_chain(name, reg) # type: ignore[attr-defined] + + +@when('I resolve the type-inherit chain for "{name}" expecting error') +def step_resolve_chain_error(context: Any, name: str) -> None: + """Attempt to resolve a chain and capture the raised exception.""" + reg = _ensure_registry(context) + try: + resolve_inheritance_chain(name, reg) + context.type_inherit_chain_error = None # type: ignore[attr-defined] + except Exception as exc: + context.type_inherit_chain_error = exc # type: ignore[attr-defined] + + +@when('I validate type-inherit chain for "{name}" inheriting "{parent}"') +def step_validate_chain(context: Any, name: str, parent: str) -> None: + """Run validate_chain for a new type.""" + reg = _ensure_registry(context) + context.type_inherit_validated_chain = validate_chain(name, parent, reg) # type: ignore[attr-defined] + + +@when( + 'I validate type-inherit chain for "{name}" inheriting "{parent}" expecting error' +) +def step_validate_chain_error(context: Any, name: str, parent: str) -> None: + """Run validate_chain and capture any error.""" + reg = _ensure_registry(context) + try: + validate_chain(name, parent, reg) + context.type_inherit_validation_error = None # type: ignore[attr-defined] + except Exception as exc: + context.type_inherit_validation_error = exc # type: ignore[attr-defined] + + +@then('the type-inherit chain is "{expected_csv}"') +def step_chain_value(context: Any, expected_csv: str) -> None: + """Assert the chain matches the expected comma-separated list.""" + chain = context.type_inherit_chain # type: ignore[attr-defined] + expected = _names_from_csv(expected_csv) + assert chain == expected, f"Expected chain {expected}, got {chain}" + + +@then('the type-inherit validated chain is "{expected_csv}"') +def step_validated_chain_value(context: Any, expected_csv: str) -> None: + """Assert the validated chain matches.""" + chain = context.type_inherit_validated_chain # type: ignore[attr-defined] + expected = _names_from_csv(expected_csv) + assert chain == expected, f"Expected validated chain {expected}, got {chain}" + + +@then("the type-inherit validation error is ResourceTypeParentNotFoundError") +def step_validation_error_parent_not_found(context: Any) -> None: + """Assert the validation raised ResourceTypeParentNotFoundError.""" + err = context.type_inherit_validation_error # type: ignore[attr-defined] + assert err is not None, "Expected an error but none was raised" + assert isinstance(err, ResourceTypeParentNotFoundError), ( + f"Expected ResourceTypeParentNotFoundError, got {type(err).__name__}: {err}" + ) + + +# ═══════════════════════════════════════════════════════════════ +# 5. Polymorphism — GIVEN / WHEN / THEN +# ═══════════════════════════════════════════════════════════════ + + +@when('I check type-inherit is_subtype_of "{child}" and "{ancestor}"') +def step_is_subtype_of(context: Any, child: str, ancestor: str) -> None: + """Run the is_subtype_of polymorphic check.""" + reg = _ensure_registry(context) + context.type_inherit_subtype_result = is_subtype_of(child, ancestor, reg) # type: ignore[attr-defined] + + +@then("the type-inherit subtype check is true") +def step_subtype_true(context: Any) -> None: + """Assert is_subtype_of returned True.""" + result = context.type_inherit_subtype_result # type: ignore[attr-defined] + assert result is True, f"Expected True, got {result}" + + +@then("the type-inherit subtype check is false") +def step_subtype_false(context: Any) -> None: + """Assert is_subtype_of returned False.""" + result = context.type_inherit_subtype_result # type: ignore[attr-defined] + assert result is False, f"Expected False, got {result}" + + +@given('a type-inherit tool "{tool_name}" bound to resource type "{rt}"') +def step_register_tool_bound(context: Any, tool_name: str, rt: str) -> None: + """Register a tool that declares a resource binding.""" + tool_reg = _ensure_tool_registry(context) + spec = ToolSpec( + name=tool_name, + description=f"Tool bound to {rt}", + handler=_NOOP_HANDLER, + source_metadata={"resource_bindings": [{"resource_type": rt}]}, + ) + tool_reg.register(spec) + + +@when('I find type-inherit tools for resource type "{rt}"') +def step_find_tools(context: Any, rt: str) -> None: + """Use ToolRegistry.find_tools_for_resource to match tools.""" + tool_reg = _ensure_tool_registry(context) + type_reg = _ensure_registry(context) + context.type_inherit_matched_tools = tool_reg.find_tools_for_resource(rt, type_reg) # type: ignore[attr-defined] + + +@then('the type-inherit matched tools include "{tool_name}"') +def step_matched_tools_include(context: Any, tool_name: str) -> None: + """Assert the matched tools list includes the named tool.""" + matched = context.type_inherit_matched_tools # type: ignore[attr-defined] + names = [t.name for t in matched] + assert tool_name in names, f"Expected {tool_name!r} in {names}" + + +@then("the type-inherit matched tools list is empty") +def step_matched_tools_empty(context: Any) -> None: + """Assert no tools were matched.""" + matched = context.type_inherit_matched_tools # type: ignore[attr-defined] + assert len(matched) == 0, f"Expected empty, got {[t.name for t in matched]}" + + +@when('I find type-inherit subtypes of "{ancestor}"') +def step_find_subtypes(context: Any, ancestor: str) -> None: + """Run find_subtypes for the given ancestor.""" + reg = _ensure_registry(context) + context.type_inherit_subtypes = find_subtypes(ancestor, reg) # type: ignore[attr-defined] + + +@then('the type-inherit subtypes include "{name}"') +def step_subtypes_include(context: Any, name: str) -> None: + """Assert the subtypes list includes the named type.""" + subtypes = context.type_inherit_subtypes # type: ignore[attr-defined] + assert name in subtypes, f"Expected {name!r} in {subtypes}" + + +# ═══════════════════════════════════════════════════════════════ +# 6. Cycle detection — GIVEN / WHEN / THEN +# ═══════════════════════════════════════════════════════════════ + + +@given('a type-inherit registry with "{name}" inheriting from "{parent}"') +def step_registry_entry_with_inherits(context: Any, name: str, parent: str) -> None: + """Add a type that inherits from another (may create a cycle).""" + reg = _ensure_registry(context) + reg[name] = {"inherits": parent} + + +@then("the type-inherit chain error is ResourceTypeCircularInheritanceError") +def step_chain_error_circular(context: Any) -> None: + """Assert the chain resolution raised a circular-inheritance error.""" + err = context.type_inherit_chain_error # type: ignore[attr-defined] + assert err is not None, "Expected an error but none was raised" + assert isinstance(err, ResourceTypeCircularInheritanceError), ( + f"Expected ResourceTypeCircularInheritanceError, got {type(err).__name__}: {err}" + ) + + +# ═══════════════════════════════════════════════════════════════ +# 7. Depth limit — GIVEN / WHEN / THEN +# ═══════════════════════════════════════════════════════════════ + + +@given("a type-inherit registry with a chain of depth {depth:d}") +def step_registry_deep_chain(context: Any, depth: int) -> None: + """Build a linear chain of the requested depth. + + depth=5 means 5 types: level-0 (root), level-1, ... level-4. + depth=6 means 6 types: level-0 (root), level-1, ... level-5. + """ + reg = _ensure_registry(context) + for i in range(depth): + name = f"acme/level-{i}" if i > 0 else "level-0" + if i == 0: + reg[name] = _make_root_entry(description=f"Level {i}") + else: + parent = f"acme/level-{i - 1}" if i > 1 else "level-0" + reg[name] = _make_child_entry(parent, description=f"Level {i}") + # Store the deepest type name for subsequent steps. + deepest = f"acme/level-{depth - 1}" if depth > 1 else "level-0" + context.type_inherit_deepest = deepest # type: ignore[attr-defined] + + +@when("I resolve the type-inherit chain for the deepest type expecting error") +def step_resolve_deepest_error(context: Any) -> None: + """Resolve chain for the deepest type, expecting an error.""" + reg = _ensure_registry(context) + deepest = context.type_inherit_deepest # type: ignore[attr-defined] + try: + resolve_inheritance_chain(deepest, reg) + context.type_inherit_chain_error = None # type: ignore[attr-defined] + except Exception as exc: + context.type_inherit_chain_error = exc # type: ignore[attr-defined] + + +@when("I resolve the type-inherit chain for the deepest type") +def step_resolve_deepest(context: Any) -> None: + """Resolve chain for the deepest type (should succeed).""" + reg = _ensure_registry(context) + deepest = context.type_inherit_deepest # type: ignore[attr-defined] + context.type_inherit_chain = resolve_inheritance_chain(deepest, reg) # type: ignore[attr-defined] + + +@then("the type-inherit chain error is ResourceTypeInheritanceDepthError") +def step_chain_error_depth(context: Any) -> None: + """Assert the chain resolution raised a depth-limit error.""" + err = context.type_inherit_chain_error # type: ignore[attr-defined] + assert err is not None, "Expected an error but none was raised" + assert isinstance(err, ResourceTypeInheritanceDepthError), ( + f"Expected ResourceTypeInheritanceDepthError, got {type(err).__name__}: {err}" + ) + + +@then("the type-inherit chain length is {length:d}") +def step_chain_length(context: Any, length: int) -> None: + """Assert the resolved chain has the expected length.""" + chain = context.type_inherit_chain # type: ignore[attr-defined] + assert len(chain) == length, f"Expected chain length {length}, got {len(chain)}" + + +# ═══════════════════════════════════════════════════════════════ +# 8. Parent removal guard — WHEN / THEN +# ═══════════════════════════════════════════════════════════════ + + +@when('I attempt to type-inherit remove "{name}"') +def step_attempt_remove(context: Any, name: str) -> None: + """Attempt to remove a type and raise if subtypes exist. + + Mirrors the guard in ``ResourceRegistryService.remove_type()`` + (ADR-042 Rule 5) using the in-memory registry. We cannot call the + real service here because these BDD tests are DB-free engine tests. + """ + reg = _ensure_registry(context) + try: + subtypes = find_subtypes(name, reg) + if subtypes: + raise ResourceTypeParentRemovalError( + f"Cannot remove '{name}': subtypes exist: {subtypes}" + ) + del reg[name] + context.type_inherit_removal_error = None # type: ignore[attr-defined] + except ResourceTypeParentRemovalError as exc: + context.type_inherit_removal_error = exc # type: ignore[attr-defined] + + +@then("the type-inherit removal error is ResourceTypeParentRemovalError") +def step_removal_error(context: Any) -> None: + """Assert the removal raised ResourceTypeParentRemovalError.""" + err = context.type_inherit_removal_error # type: ignore[attr-defined] + assert err is not None, "Expected an error but none was raised" + assert isinstance(err, ResourceTypeParentRemovalError), ( + f"Expected ResourceTypeParentRemovalError, got {type(err).__name__}: {err}" + ) + + +# ═══════════════════════════════════════════════════════════════ +# 9. Additional coverage — GIVEN / WHEN / THEN +# ═══════════════════════════════════════════════════════════════ + + +@given( + 'a type-inherit grandchild "{grandchild}" inheriting from "{parent}" with cli_args "{args_csv}"' +) +def step_grandchild_cli_args( + context: Any, grandchild: str, parent: str, args_csv: str +) -> None: + """Add a grandchild type with its own cli_args.""" + reg = _ensure_registry(context) + cli_args = [{"name": n} for n in _names_from_csv(args_csv)] + reg[grandchild] = _make_child_entry(parent, cli_args=cli_args) + + +@given('a type-inherit registry with "{name}" as root with handler "{handler}"') +def step_registry_root_with_handler(context: Any, name: str, handler: str) -> None: + """Add a root type with a specific handler reference.""" + reg = _ensure_registry(context) + reg[name] = _make_root_entry(handler=handler) + + +@when('I resolve type-inherit handler polymorphic for "{name}"') +def step_resolve_handler_polymorphic(context: Any, name: str) -> None: + """Resolve a handler via the polymorphic resolver.""" + from cleveragents.resource.handlers.resolver import resolve_handler_polymorphic + + reg = _ensure_registry(context) + context.type_inherit_resolved_handler = resolve_handler_polymorphic(name, reg) # type: ignore[attr-defined] + + +@then("the type-inherit resolved handler is not None") +def step_resolved_handler_not_none(context: Any) -> None: + """Assert the resolved handler is not None.""" + handler = context.type_inherit_resolved_handler # type: ignore[attr-defined] + assert handler is not None, "Expected a handler but got None" + + +@when( + 'I validate type-inherit chain for built-in "{name}" inheriting "{parent}" expecting error' +) +def step_validate_chain_builtin_error(context: Any, name: str, parent: str) -> None: + """Run validate_chain with is_built_in=True and capture any error.""" + reg = _ensure_registry(context) + try: + validate_chain(name, parent, reg, is_built_in=True) + context.type_inherit_validation_error = None # type: ignore[attr-defined] + except Exception as exc: + context.type_inherit_validation_error = exc # type: ignore[attr-defined] + + +@then("the type-inherit validation error is ValueError") +def step_validation_error_value_error(context: Any) -> None: + """Assert the validation raised a ValueError (or subclass).""" + err = context.type_inherit_validation_error # type: ignore[attr-defined] + assert err is not None, "Expected an error but none was raised" + assert isinstance(err, ValueError), ( + f"Expected ValueError, got {type(err).__name__}: {err}" + ) + + +# ═══════════════════════════════════════════════════════════════ +# 10. Coverage edge-case steps +# ═══════════════════════════════════════════════════════════════ + + +@then("the type-inherit chain error is ResourceTypeParentNotFoundError") +def step_chain_error_parent_not_found(context: Any) -> None: + """Assert the chain error is ResourceTypeParentNotFoundError.""" + err = context.type_inherit_chain_error # type: ignore[attr-defined] + assert err is not None, "Expected an error but none was raised" + assert isinstance(err, ResourceTypeParentNotFoundError), ( + f"Expected ResourceTypeParentNotFoundError, got {type(err).__name__}: {err}" + ) + + +@when('I validate type-inherit chain for "{name}" inheriting nothing') +def step_validate_chain_no_parent(context: Any, name: str) -> None: + """Run validate_chain with inherits=None.""" + reg = _ensure_registry(context) + context.type_inherit_validated_chain = validate_chain(name, None, reg) # type: ignore[attr-defined] + + +@given('the type-inherit registry entry for "{name}" is removed') +def step_remove_registry_entry(context: Any, name: str) -> None: + """Remove an entry from the in-memory registry.""" + reg = _ensure_registry(context) + reg.pop(name, None) + + +@then('the type-inherit resolved fields should not contain "{key}"') +def step_resolved_no_key(context: Any, key: str) -> None: + """Assert the resolved fields do NOT contain the given key.""" + resolved = context.type_inherit_resolved # type: ignore[attr-defined] + assert key not in resolved, ( + f"Expected key '{key}' absent, but found: {resolved.get(key)}" + ) + + +class _ObjEntry: + """Minimal object-style registry entry for testing _get_inherits.""" + + def __init__(self, inherits: str | None = None, **kwargs: Any) -> None: + self.inherits = inherits + for k, v in kwargs.items(): + setattr(self, k, v) + + +@given('a type-inherit registry with object-style entry "{name}" inheriting "{parent}"') +def step_registry_obj_entry(context: Any, name: str, parent: str) -> None: + """Add an object-style (non-dict) entry to the registry.""" + reg = _ensure_registry(context) + reg[name] = _ObjEntry(inherits=parent) + + +class _PydanticLikeEntry: + """Fake entry with model_dump() for testing _to_dict branch.""" + + def __init__(self, **kwargs: Any) -> None: + self._data = kwargs + + def model_dump(self) -> dict[str, Any]: + return dict(self._data) + + +@given( + 'a type-inherit registry with pydantic-style entry "{name}" having description "{desc}"' +) +def step_registry_pydantic_entry(context: Any, name: str, desc: str) -> None: + """Add a pydantic-model-like entry to the registry.""" + reg = _ensure_registry(context) + reg[name] = _PydanticLikeEntry(description=desc) + + +@given('a type-inherit parent "{name}" with no cli_args') +def step_parent_no_cli_args(context: Any, name: str) -> None: + """Add a root type with empty cli_args.""" + reg = _ensure_registry(context) + reg[name] = _make_root_entry(cli_args=[]) + + +@given('a type-inherit child "{child}" inheriting from "{parent}" with no cli_args') +def step_child_no_cli_args(context: Any, child: str, parent: str) -> None: + """Add a child type with no cli_args.""" + reg = _ensure_registry(context) + reg[child] = _make_child_entry(parent, cli_args=[]) + + +@when('I resolve type-inherit handler polymorphic for "{name}" expecting error') +def step_resolve_handler_polymorphic_error(context: Any, name: str) -> None: + """Resolve handler and capture any error.""" + from cleveragents.resource.handlers.resolver import resolve_handler_polymorphic + + reg = _ensure_registry(context) + try: + resolve_handler_polymorphic(name, reg) + context.type_inherit_handler_error = None # type: ignore[attr-defined] + except Exception as exc: + context.type_inherit_handler_error = exc # type: ignore[attr-defined] + + +@then("the type-inherit handler error is HandlerResolutionError") +def step_handler_error_check(context: Any) -> None: + """Assert the handler resolution raised HandlerResolutionError.""" + from cleveragents.resource.handlers.resolver import HandlerResolutionError + + err = context.type_inherit_handler_error # type: ignore[attr-defined] + assert err is not None, "Expected an error but none was raised" + assert isinstance(err, HandlerResolutionError), ( + f"Expected HandlerResolutionError, got {type(err).__name__}: {err}" + ) + + +# -- ToolRegistry coverage steps ----------------------------------------- + + +@given('a type-inherit tool registry with tool "{name}" bound to "{res_type}"') +def step_tool_registry_add(context: Any, name: str, res_type: str) -> None: + """Add a tool to the tool registry.""" + tool_reg = _ensure_tool_registry(context) + spec = ToolSpec( + name=name, + description=f"Tool {name}", + handler=_NOOP_HANDLER, + source="test", + source_metadata={"resource_bindings": [{"resource_type": res_type}]}, + ) + tool_reg.register(spec) + + +@when('I add type-inherit tool "{name}" again expecting error') +def step_tool_registry_add_dup(context: Any, name: str) -> None: + """Attempt to add a duplicate tool.""" + tool_reg = _ensure_tool_registry(context) + spec = ToolSpec( + name=name, + description="dup", + handler=_NOOP_HANDLER, + source="test", + source_metadata={}, + ) + try: + tool_reg.register(spec) + context.type_inherit_tool_error = None # type: ignore[attr-defined] + except Exception as exc: + context.type_inherit_tool_error = exc # type: ignore[attr-defined] + + +@then("the type-inherit tool error is ToolError") +def step_tool_error_check(context: Any) -> None: + """Assert a ToolError was raised.""" + from cleveragents.tool.runtime import ToolError + + err = context.type_inherit_tool_error # type: ignore[attr-defined] + assert err is not None, "Expected an error but none was raised" + assert isinstance(err, ToolError), ( + f"Expected ToolError, got {type(err).__name__}: {err}" + ) + + +@given("a type-inherit empty tool registry") +def step_empty_tool_registry(context: Any) -> None: + """Create an empty tool registry.""" + context.type_inherit_tool_registry = ToolRegistry() # type: ignore[attr-defined] + + +@when('I get type-inherit tool "{name}"') +def step_tool_registry_get(context: Any, name: str) -> None: + """Lookup a tool by name.""" + tool_reg = _ensure_tool_registry(context) + context.type_inherit_tool_result = tool_reg.get(name) # type: ignore[attr-defined] + + +@then("the type-inherit tool result is None") +def step_tool_result_none(context: Any) -> None: + """Assert the tool lookup returned None.""" + assert context.type_inherit_tool_result is None # type: ignore[attr-defined] + + +@when('I list type-inherit tools with namespace "{ns}"') +def step_tool_registry_list(context: Any, ns: str) -> None: + """List tools filtered by namespace.""" + tool_reg = _ensure_tool_registry(context) + context.type_inherit_tools_list = tool_reg.list_tools(namespace=ns) # type: ignore[attr-defined] + + +@then("the type-inherit tools list has {count:d} entry") +def step_tools_list_count(context: Any, count: int) -> None: + """Assert the tools list has the expected number of entries.""" + result = context.type_inherit_tools_list # type: ignore[attr-defined] + assert len(result) == count, f"Expected {count}, got {len(result)}" + + +@when('I remove type-inherit tool "{name}"') +def step_tool_registry_remove(context: Any, name: str) -> None: + """Remove a tool from the registry.""" + tool_reg = _ensure_tool_registry(context) + context.type_inherit_tool_remove_result = tool_reg.remove(name) # type: ignore[attr-defined] + + +@then("the type-inherit tool remove result is true") +def step_tool_remove_true(context: Any) -> None: + """Assert tool removal returned True.""" + assert context.type_inherit_tool_remove_result is True # type: ignore[attr-defined] + + +@then("the type-inherit tool remove result is false") +def step_tool_remove_false(context: Any) -> None: + """Assert tool removal returned False.""" + assert context.type_inherit_tool_remove_result is False # type: ignore[attr-defined] + + +@given('a type-inherit tool registry with unbound tool "{name}"') +def step_tool_registry_unbound(context: Any, name: str) -> None: + """Add a tool with no resource bindings.""" + tool_reg = _ensure_tool_registry(context) + spec = ToolSpec( + name=name, + description=f"Unbound tool {name}", + handler=_NOOP_HANDLER, + source="test", + source_metadata={}, + ) + tool_reg.register(spec) + + +@when('I list type-inherit tools with source "{src}"') +def step_tool_registry_list_by_source(context: Any, src: str) -> None: + """List tools filtered by source.""" + tool_reg = _ensure_tool_registry(context) + context.type_inherit_tools_list = tool_reg.list_tools(source=src) # type: ignore[attr-defined] + + +@when("I resolve type-inherit handler with empty reference expecting error") +def step_resolve_handler_empty(context: Any) -> None: + """Try to resolve an empty handler reference.""" + from cleveragents.resource.handlers.resolver import resolve_handler + + try: + resolve_handler("") + context.type_inherit_handler_error = None # type: ignore[attr-defined] + except Exception as exc: + context.type_inherit_handler_error = exc # type: ignore[attr-defined] + + +@when('I resolve type-inherit handler with reference "{ref}" expecting error') +def step_resolve_handler_bad_ref(context: Any, ref: str) -> None: + """Try to resolve a handler with a bad reference.""" + from cleveragents.resource.handlers.resolver import ( + clear_handler_cache, + resolve_handler, + ) + + clear_handler_cache() + try: + resolve_handler(ref) + context.type_inherit_handler_error = None # type: ignore[attr-defined] + except Exception as exc: + context.type_inherit_handler_error = exc # type: ignore[attr-defined] diff --git a/robot/helper_resource_type_inheritance.py b/robot/helper_resource_type_inheritance.py new file mode 100644 index 000000000..f2a4d847d --- /dev/null +++ b/robot/helper_resource_type_inheritance.py @@ -0,0 +1,212 @@ +"""Robot Framework helper for resource type inheritance tests. + +Each public function is exposed as a Robot keyword. When run standalone +via ``python robot/helper_resource_type_inheritance.py `` +each command prints ``-ok`` on success. +""" + +from __future__ import annotations + +import sys +from typing import Any + +from cleveragents.resource.inheritance import ( + MAX_CHAIN_DEPTH, + ResourceTypeCircularInheritanceError, + ResourceTypeInheritanceDepthError, + find_subtypes, + is_subtype_of, + resolve_fields, + resolve_inheritance_chain, + validate_chain, +) + +# --------------------------------------------------------------------------- +# Shared test registries +# --------------------------------------------------------------------------- + +_SIMPLE_REGISTRY: dict[str, Any] = { + "resource": {}, + "container-instance": {"inherits": "resource", "sandbox_strategy": "docker"}, + "devcontainer-instance": { + "inherits": "container-instance", + "sandbox_strategy": "devcontainer", + "cli_args": [{"name": "profile", "type": "string"}], + }, +} + +_DEEP_REGISTRY: dict[str, Any] = { + "root": {}, + "level-1": {"inherits": "root"}, + "level-2": {"inherits": "level-1"}, + "level-3": {"inherits": "level-2"}, + "level-4": {"inherits": "level-3"}, +} + +_FIELD_REGISTRY: dict[str, Any] = { + "base": { + "sandbox_strategy": "copy_on_write", + "resource_kind": "physical", + "cli_args": [{"name": "path", "type": "string"}], + "child_types": ["alpha"], + }, + "mid": { + "inherits": "base", + "resource_kind": "virtual", + "cli_args": [{"name": "tag", "type": "string"}], + "child_types": ["beta"], + }, + "leaf": { + "inherits": "mid", + "sandbox_strategy": "none", + "cli_args": [ + {"name": "path", "type": "int"}, # overrides base's "path" + ], + }, +} + +_WIDE_REGISTRY: dict[str, Any] = { + "ancestor": {}, +} +for _i in range(20): + _WIDE_REGISTRY[f"child-{_i}"] = {"inherits": "ancestor"} + + +# --------------------------------------------------------------------------- +# Robot keywords +# --------------------------------------------------------------------------- + + +def resolve_inheritance_chain_keyword( + type_name: str, +) -> list[str]: + """Resolve an inheritance chain using the simple registry.""" + return resolve_inheritance_chain(type_name, _SIMPLE_REGISTRY) + + +def resolve_deep_chain(type_name: str) -> list[str]: + """Resolve an inheritance chain using the deep (4-level) registry.""" + return resolve_inheritance_chain(type_name, _DEEP_REGISTRY) + + +def check_is_subtype_of( + type_name: str, + ancestor_name: str, +) -> bool: + """Return True if *type_name* is a subtype of *ancestor_name*.""" + return is_subtype_of(type_name, ancestor_name, _SIMPLE_REGISTRY) + + +def check_is_subtype_deep( + type_name: str, + ancestor_name: str, +) -> bool: + """Return True if *type_name* is a subtype of *ancestor_name* (deep).""" + return is_subtype_of(type_name, ancestor_name, _DEEP_REGISTRY) + + +def resolve_merged_fields(type_name: str) -> dict[str, Any]: + """Resolve merged fields for *type_name* using the field registry.""" + return resolve_fields(type_name, _FIELD_REGISTRY) + + +def find_all_subtypes(ancestor_name: str) -> list[str]: + """Find all subtypes of *ancestor_name* in the wide registry.""" + return find_subtypes(ancestor_name, _WIDE_REGISTRY) + + +def validate_chain_keyword( + type_name: str, + inherits: str | None, +) -> list[str]: + """Validate a chain declaration against the simple registry.""" + return validate_chain(type_name, inherits, _SIMPLE_REGISTRY) + + +def get_max_chain_depth() -> int: + """Return the configured MAX_CHAIN_DEPTH constant.""" + return MAX_CHAIN_DEPTH + + +def detect_circular_inheritance() -> str: + """Attempt to resolve a circular chain; return error class name.""" + circular: dict[str, Any] = { + "a": {"inherits": "b"}, + "b": {"inherits": "a"}, + } + try: + resolve_inheritance_chain("a", circular) + return "no-error" + except ResourceTypeCircularInheritanceError: + return "ResourceTypeCircularInheritanceError" + + +def detect_depth_exceeded() -> str: + """Attempt to resolve an overly-deep chain; return error class name.""" + deep: dict[str, Any] = {"t0": {}} + for i in range(1, MAX_CHAIN_DEPTH + 2): + deep[f"t{i}"] = {"inherits": f"t{i - 1}"} + leaf = f"t{MAX_CHAIN_DEPTH + 1}" + try: + resolve_inheritance_chain(leaf, deep) + return "no-error" + except ResourceTypeInheritanceDepthError: + return "ResourceTypeInheritanceDepthError" + + +# --------------------------------------------------------------------------- +# Standalone CLI +# --------------------------------------------------------------------------- + + +def _chain_resolve() -> None: + chain = resolve_inheritance_chain("devcontainer-instance", _SIMPLE_REGISTRY) + assert chain == ["devcontainer-instance", "container-instance", "resource"], ( + f"unexpected chain: {chain}" + ) + print("chain-resolve-ok") + + +def _subtype_check() -> None: + assert is_subtype_of("devcontainer-instance", "resource", _SIMPLE_REGISTRY) + assert not is_subtype_of("resource", "devcontainer-instance", _SIMPLE_REGISTRY) + print("subtype-check-ok") + + +def _field_merge() -> None: + fields = resolve_fields("leaf", _FIELD_REGISTRY) + assert fields["sandbox_strategy"] == "none", f"got {fields['sandbox_strategy']}" + assert fields["resource_kind"] == "virtual", f"got {fields['resource_kind']}" + # cli_args: "path" from leaf overrides base, "tag" from mid kept + names = [a["name"] for a in fields["cli_args"]] + assert "tag" in names, f"tag missing from {names}" + assert "path" in names, f"path missing from {names}" + print("field-merge-ok") + + +def _find_subtypes_cmd() -> None: + subtypes = find_subtypes("ancestor", _WIDE_REGISTRY) + assert len(subtypes) == 20, f"expected 20 subtypes, got {len(subtypes)}" + print("find-subtypes-ok") + + +def _circular_detect() -> None: + result = detect_circular_inheritance() + assert result == "ResourceTypeCircularInheritanceError", f"got {result}" + print("circular-detect-ok") + + +_COMMANDS = { + "chain-resolve": _chain_resolve, + "subtype-check": _subtype_check, + "field-merge": _field_merge, + "find-subtypes": _find_subtypes_cmd, + "circular-detect": _circular_detect, +} + + +if __name__ == "__main__": + if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS: + print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>", file=sys.stderr) + sys.exit(1) + _COMMANDS[sys.argv[1]]() diff --git a/robot/resource_type_inheritance.robot b/robot/resource_type_inheritance.robot new file mode 100644 index 000000000..685c50054 --- /dev/null +++ b/robot/resource_type_inheritance.robot @@ -0,0 +1,114 @@ +*** Settings *** +Documentation Resource type inheritance chain resolution and polymorphic type matching +Library Process +Library OperatingSystem +Library Collections +Library helper_resource_type_inheritance.py + +*** Variables *** +${PYTHON} python + +*** Test Cases *** +Single Inheritance Chain Resolution + [Documentation] Resolve a two-level chain: devcontainer-instance -> container-instance -> resource + ${chain}= Resolve Inheritance Chain Keyword devcontainer-instance + Length Should Be ${chain} 3 + Should Be Equal ${chain}[0] devcontainer-instance + Should Be Equal ${chain}[1] container-instance + Should Be Equal ${chain}[2] resource + +Deep Chain Resolution + [Documentation] Resolve a four-level chain through the deep registry + ${chain}= Resolve Deep Chain level-4 + Length Should Be ${chain} 5 + Should Be Equal ${chain}[0] level-4 + Should Be Equal ${chain}[4] root + +Root Type Returns Single Element Chain + [Documentation] A root type with no parent returns a one-element chain + ${chain}= Resolve Inheritance Chain Keyword resource + Length Should Be ${chain} 1 + Should Be Equal ${chain}[0] resource + +Polymorphic Subtype Check Positive + [Documentation] devcontainer-instance is a subtype of resource (transitive) + ${result}= Check Is Subtype Of devcontainer-instance resource + Should Be True ${result} + +Polymorphic Subtype Check Negative + [Documentation] resource is NOT a subtype of devcontainer-instance + ${result}= Check Is Subtype Of resource devcontainer-instance + Should Not Be True ${result} + +Polymorphic Identity Check + [Documentation] A type is always a subtype of itself + ${result}= Check Is Subtype Of container-instance container-instance + Should Be True ${result} + +Deep Polymorphic Subtype Check + [Documentation] level-4 is a subtype of root through four levels + ${result}= Check Is Subtype Deep level-4 root + Should Be True ${result} + +Field Merging Scalars Override + [Documentation] Scalar fields from child override parent values + ${fields}= Resolve Merged Fields leaf + Should Be Equal ${fields}[sandbox_strategy] none + Should Be Equal ${fields}[resource_kind] virtual + +Field Merging Collections Additive + [Documentation] Collection fields merge additively by default + ${fields}= Resolve Merged Fields mid + ${child_types}= Get From Dictionary ${fields} child_types + Should Contain ${child_types} alpha + Should Contain ${child_types} beta + +Field Merging CLI Args Same Name Replacement + [Documentation] cli_args with matching name from child replace parent + ${fields}= Resolve Merged Fields leaf + ${cli_args}= Get From Dictionary ${fields} cli_args + # "path" from leaf replaces "path" from base; "tag" from mid preserved + ${names}= Evaluate [a["name"] for a in $cli_args] + Should Contain ${names} path + Should Contain ${names} tag + +Find All Subtypes Of Ancestor + [Documentation] find_subtypes returns all direct children of a wide registry + ${subtypes}= Find All Subtypes ancestor + Length Should Be ${subtypes} 20 + +Circular Inheritance Detected + [Documentation] Circular chain raises ResourceTypeCircularInheritanceError + ${err}= Detect Circular Inheritance + Should Be Equal ${err} ResourceTypeCircularInheritanceError + +Depth Limit Exceeded Detected + [Documentation] Chain exceeding MAX_CHAIN_DEPTH raises depth error + ${err}= Detect Depth Exceeded + Should Be Equal ${err} ResourceTypeInheritanceDepthError + +Max Chain Depth Constant + [Documentation] MAX_CHAIN_DEPTH is exposed and equals 5 + ${depth}= Get Max Chain Depth + Should Be Equal As Integers ${depth} 5 + +Standalone Helper Chain Resolve + [Documentation] Run helper as standalone script: chain-resolve + ${result}= Run Process ${PYTHON} robot/helper_resource_type_inheritance.py chain-resolve + ... env:PYTHONPATH=src + Should Be Equal As Integers ${result.rc} 0 helper failed: ${result.stderr} + Should Contain ${result.stdout} chain-resolve-ok + +Standalone Helper Subtype Check + [Documentation] Run helper as standalone script: subtype-check + ${result}= Run Process ${PYTHON} robot/helper_resource_type_inheritance.py subtype-check + ... env:PYTHONPATH=src + Should Be Equal As Integers ${result.rc} 0 helper failed: ${result.stderr} + Should Contain ${result.stdout} subtype-check-ok + +Standalone Helper Field Merge + [Documentation] Run helper as standalone script: field-merge + ${result}= Run Process ${PYTHON} robot/helper_resource_type_inheritance.py field-merge + ... env:PYTHONPATH=src + Should Be Equal As Integers ${result.rc} 0 helper failed: ${result.stderr} + Should Contain ${result.stdout} field-merge-ok diff --git a/src/cleveragents/application/services/resource_registry_service.py b/src/cleveragents/application/services/resource_registry_service.py index db20e7607..9ee3fcb32 100644 --- a/src/cleveragents/application/services/resource_registry_service.py +++ b/src/cleveragents/application/services/resource_registry_service.py @@ -55,6 +55,14 @@ from cleveragents.infrastructure.database.models import ( ResourceModel, ResourceTypeModel, ) +from cleveragents.resource.inheritance import ( + ResourceTypeParentNotFoundError, + ResourceTypeParentRemovalError, + find_subtypes, + is_subtype_of, + resolve_inheritance_chain, + validate_chain, +) logger = logging.getLogger(__name__) @@ -295,6 +303,9 @@ class ResourceRegistryService: def register_type(self, config_path: str | Path) -> ResourceTypeSpec: """Load a YAML resource type config, validate, and persist. + If the type declares ``inherits``, the parent type must already + be registered and the resulting chain is validated (ADR-042). + Args: config_path: Path to the YAML configuration file. @@ -302,7 +313,8 @@ class ResourceRegistryService: The validated ``ResourceTypeSpec``. Raises: - ValidationError: If the config is invalid. + ValidationError: If the config is invalid or chain rules + are violated. """ path = Path(config_path) if not path.exists(): @@ -326,6 +338,27 @@ class ResourceRegistryService: details={"name": spec.name}, ) + # Validate inheritance chain (ADR-042 rules 1-4). + # NOTE: ADR-042 steps 3 (pre-compute merged fields) and 5 + # (validate handler loadability) are deferred to runtime. + if spec.inherits is not None: + registry = self._load_type_registry() + try: + validate_chain( + spec.name, + spec.inherits, + registry, + is_built_in=spec.built_in, + ) + except ( + ResourceTypeParentNotFoundError, + ValueError, + ) as exc: + raise ValidationError( + message=str(exc), + details={"name": spec.name, "inherits": spec.inherits}, + ) from exc + db_model = _spec_to_db(spec, source=str(path)) session.add(db_model) session.flush() @@ -385,6 +418,143 @@ class ResourceRegistryService: finally: session.close() + def remove_type(self, name: str) -> None: + """Remove a custom resource type after safety checks. + + Enforces ADR-042 Rule 5: a type that is a parent of registered + subtypes cannot be removed until the subtypes are removed first. + + Args: + name: Name of the resource type to remove. + + Raises: + NotFoundError: If the type is not registered. + ValidationError: If the type is built-in or has resources. + ResourceTypeParentRemovalError: If subtypes still reference + this type via ``inherits``. + """ + session = self._session() + try: + row = session.query(ResourceTypeModel).filter_by(name=name).first() + if row is None: + raise NotFoundError( + resource_type="resource_type", + resource_id=name, + ) + + spec = _db_to_spec(row) + if spec.built_in: + raise ValidationError( + message=f"Cannot remove built-in resource type: {name}", + details={"name": name}, + ) + + # Guard: reject if resources still reference this type + resource_count: int = ( + session.query(ResourceModel) + .filter(ResourceModel.type_name == name) + .count() + ) + if resource_count > 0: + raise ValidationError( + message=( + f"Cannot remove type '{name}': " + f"{resource_count} resource(s) still reference it." + ), + details={"name": name, "resource_count": resource_count}, + ) + + # Guard: ADR-042 Rule 5 — reject if subtypes exist + registry = self._load_type_registry() + subtypes = find_subtypes(name, registry) + if subtypes: + raise ResourceTypeParentRemovalError( + f"Cannot remove '{name}': subtypes exist: {subtypes}" + ) + + session.delete(row) + session.commit() + except (NotFoundError, ValidationError, ResourceTypeParentRemovalError): + session.rollback() + raise + except Exception: + session.rollback() + raise + finally: + session.close() + + def resolve_type_inheritance_chain(self, type_name: str) -> list[str]: + """Resolve the full inheritance chain for a resource type. + + Walks the ``inherits`` links from *type_name* to the root ancestor. + + Args: + type_name: Name of the resource type to resolve. + + Returns: + List of type names from child to root, e.g. + ``["devcontainer-instance", "container-instance"]``. + + Raises: + NotFoundError: If the type is not registered. + ValidationError: If the chain is invalid (cycle, depth, etc.). + """ + registry = self._load_type_registry() + if type_name not in registry: + raise NotFoundError( + resource_type="resource_type", + resource_id=type_name, + ) + try: + return resolve_inheritance_chain(type_name, registry) + except ValueError as exc: + raise ValidationError( + message=str(exc), + details={"type_name": type_name}, + ) from exc + + def is_subtype_of(self, type_name: str, ancestor_name: str) -> bool: + """Check whether *type_name* is the same as or inherits from *ancestor_name*. + + Args: + type_name: The concrete type to check. + ancestor_name: The declared/expected type. + + Returns: + ``True`` if *type_name* equals *ancestor_name* or + transitively inherits from it. + """ + registry = self._load_type_registry() + return is_subtype_of(type_name, ancestor_name, registry) + + def _load_type_registry(self) -> dict[str, dict[str, Any]]: + """Load all registered types into an in-memory dict for chain ops. + + .. note:: + + Returns a **minimal** registry for chain resolution and subtype + checks only. It does NOT include ``handler``, ``cli_args``, + ``capabilities``, or other fields needed by + ``resolve_fields()`` or ``resolve_handler_polymorphic()``. + + Returns: + Dict mapping type name to its config dict (with ``inherits``). + """ + session = self._session() + try: + rows = session.query(ResourceTypeModel).all() + registry: dict[str, dict[str, Any]] = {} + for row in rows: + name = str(row.name) + raw_inherits = getattr(row, "inherits", None) + registry[name] = { + "inherits": str(raw_inherits) if raw_inherits else None, + "built_in": "/" not in name, + } + return registry + finally: + session.close() + # -- Resource operations ------------------------------------------------- def register_resource( @@ -484,11 +654,20 @@ class ResourceRegistryService: finally: session.close() - def list_resources(self, type_name: str | None = None) -> list[Resource]: + def list_resources( + self, + type_name: str | None = None, + *, + exact: bool = False, + ) -> list[Resource]: """List registered resources. Args: type_name: Optional type name filter. + exact: When ``False`` (default) and *type_name* is given, + include resources whose type is a **subtype** of + *type_name* (ADR-042 polymorphic listing). When + ``True``, match only the exact type name. Returns: List of ``Resource`` domain objects. @@ -497,7 +676,14 @@ class ResourceRegistryService: try: query = session.query(ResourceModel) if type_name is not None: - query = query.filter_by(type_name=type_name) + if exact: + query = query.filter_by(type_name=type_name) + else: + # ADR-042: polymorphic listing — include subtypes + registry = self._load_type_registry() + subtypes = find_subtypes(type_name, registry) + all_types = [type_name, *subtypes] + query = query.filter(ResourceModel.type_name.in_(all_types)) query = query.order_by(ResourceModel.created_at) rows = query.all() return [_db_resource_to_domain(row) for row in rows] @@ -572,20 +758,28 @@ class ResourceRegistryService: allowed_children: list[str] = ( json.loads(str(allowed_raw)) if allowed_raw else [] ) - if ( - allowed_children - and child.resource_type_name not in allowed_children - ): - raise ValidationError( - message=( - f"Type '{child.resource_type_name}' is not allowed " - f"as a child of '{parent.resource_type_name}'" - ), - details={ - "parent_type": parent.resource_type_name, - "child_type": child.resource_type_name, - }, + if allowed_children: + # ADR-042: polymorphic matching — a child whose type + # is a subtype of any allowed type satisfies the + # constraint (e.g. allowed "container-instance" + # accepts "acme/my-container" that inherits from it). + registry = self._load_type_registry() + child_type = child.resource_type_name + match = any( + is_subtype_of(child_type, allowed, registry) + for allowed in allowed_children ) + if not match: + raise ValidationError( + message=( + f"Type '{child_type}' is not allowed " + f"as a child of '{parent.resource_type_name}'" + ), + details={ + "parent_type": parent.resource_type_name, + "child_type": child_type, + }, + ) if parent.resource_id == child.resource_id: raise ValidationError( @@ -887,6 +1081,7 @@ def _spec_to_db(spec: ResourceTypeSpec, source: str) -> ResourceTypeModel: auto_discover_json=auto_discover_json, capabilities_json=capabilities_json, equivalence_json=equivalence_json, + inherits=spec.inherits, source=source, created_at=now_iso, updated_at=now_iso, @@ -938,6 +1133,9 @@ def _db_to_spec(row: ResourceTypeModel) -> ResourceTypeSpec: raw_handler = getattr(row, "handler_ref", None) handler_str: str | None = str(raw_handler) if raw_handler is not None else None + raw_inherits = getattr(row, "inherits", None) + inherits_str: str | None = str(raw_inherits) if raw_inherits is not None else None + return ResourceTypeSpec.from_config( { "name": name_str, @@ -953,6 +1151,7 @@ def _db_to_spec(row: ResourceTypeModel) -> ResourceTypeSpec: "handler": handler_str, "capabilities": capabilities, "built_in": is_builtin, + "inherits": inherits_str, } ) diff --git a/src/cleveragents/cli/commands/resource.py b/src/cleveragents/cli/commands/resource.py index ba6cee0fd..a4782565e 100644 --- a/src/cleveragents/cli/commands/resource.py +++ b/src/cleveragents/cli/commands/resource.py @@ -53,6 +53,7 @@ Based on ``implementation_plan.md`` -- Tasks B0.cli.resources, B1.cli. from __future__ import annotations +import logging from pathlib import Path from typing import Annotated, Any @@ -71,6 +72,9 @@ from cleveragents.core.exceptions import ( NotFoundError, ValidationError, ) +from cleveragents.resource.inheritance import ResourceTypeParentRemovalError + +logger = logging.getLogger(__name__) # Main resource app app = typer.Typer( @@ -113,7 +117,8 @@ def _resource_type_dict(spec: Any) -> dict[str, object]: "default": arg.default, } ) - return { + inherits = getattr(spec, "inherits", None) + result: dict[str, object] = { "name": spec.name, "description": spec.description or "", "resource_kind": str(spec.resource_kind), @@ -126,6 +131,9 @@ def _resource_type_dict(spec: Any) -> dict[str, object]: "handler": spec.handler, "capabilities": spec.capabilities, } + if inherits is not None: + result["inherits"] = inherits + return result def _resource_dict(resource: Any) -> dict[str, object]: @@ -235,7 +243,7 @@ def type_remove( try: service = _get_registry_service() - # Check if the type exists and whether it's built-in + # Pre-check so we can show a friendly message before prompting spec = service.show_type(name) if spec.built_in: console.print(f"[red]Cannot remove built-in resource type:[/red] {name}") @@ -249,46 +257,15 @@ def type_remove( console.print("[yellow]Aborted.[/yellow]") raise typer.Abort() - # Delete via direct session access (service doesn't expose delete yet) - session = service._session() - try: - from cleveragents.infrastructure.database.models import ( - ResourceModel, - ResourceTypeModel, - ) - - # Check for resources referencing this type - resource_count: int = ( - session.query(ResourceModel) - .filter(ResourceModel.type_name == name) - .count() - ) - if resource_count > 0: - console.print( - f"[red]Cannot remove type '{name}': " - f"{resource_count} resource(s) still reference it.[/red]" - ) - raise typer.Abort() - - row = session.query(ResourceTypeModel).filter_by(name=name).first() - if row is None: - console.print(f"[red]Resource type not found:[/red] {name}") - raise typer.Abort() - session.delete(row) - session.commit() - except typer.Abort: - raise - except Exception: - session.rollback() - raise - finally: - session.close() - + service.remove_type(name) console.print(f"[green]Removed resource type:[/green] {name}") except NotFoundError as exc: console.print(f"[red]Resource type not found:[/red] {name}") raise typer.Abort() from exc + except ResourceTypeParentRemovalError as exc: + console.print(f"[red]{exc}[/red]") + raise typer.Abort() from exc except CleverAgentsError as exc: console.print(f"[red]Error:[/red] {exc.message}") raise typer.Abort() from exc @@ -324,6 +301,7 @@ def type_list( table.add_column("Name", style="cyan") table.add_column("Kind", style="blue") table.add_column("Sandbox", style="magenta") + table.add_column("Inherits", style="green") table.add_column("Built-in", justify="center") table.add_column("User-addable", justify="center") table.add_column("Description", style="dim") @@ -332,10 +310,12 @@ def type_list( desc = spec.description or "" if len(desc) > 50: desc = desc[:47] + "..." + inherits = getattr(spec, "inherits", None) or "" table.add_row( spec.name, str(spec.resource_kind), str(spec.sandbox_strategy), + inherits, "yes" if spec.built_in else "no", "yes" if spec.user_addable else "no", desc, @@ -399,9 +379,25 @@ def _print_type_panel(spec: Any) -> None: else: args_display = " (none)" + inherits = getattr(spec, "inherits", None) + inherits_display = inherits or "(none)" + + # Attempt to resolve the full inheritance chain for display. + chain_display = "(root type)" + if inherits is not None: + try: + service = _get_registry_service() + chain = service.resolve_type_inheritance_chain(spec.name) + chain_display = " -> ".join(chain) + except Exception: # CLI display; crash is worse than partial info + logger.debug("Failed to resolve chain for %s: %s", spec.name, "error") + chain_display = f"{spec.name} -> {inherits} -> ..." + details = ( f"[bold]Name:[/bold] {spec.name}\n" f"[bold]Description:[/bold] {spec.description or '(none)'}\n" + f"[bold]Inherits:[/bold] {inherits_display}\n" + f"[bold]Inheritance Chain:[/bold] {chain_display}\n" f"[bold]Kind:[/bold] {spec.resource_kind}\n" f"[bold]Sandbox Strategy:[/bold] {spec.sandbox_strategy}\n" f"[bold]Built-in:[/bold] {'yes' if spec.built_in else 'no'}\n" diff --git a/src/cleveragents/domain/models/core/resource_type.py b/src/cleveragents/domain/models/core/resource_type.py index b64023a3d..e21566f18 100644 --- a/src/cleveragents/domain/models/core/resource_type.py +++ b/src/cleveragents/domain/models/core/resource_type.py @@ -307,6 +307,11 @@ class ResourceTypeSpec(BaseModel): False, description=("Whether this is a built-in type (allows unnamespaced names)."), ) + inherits: str | None = Field( + default=None, + max_length=255, + description="Parent resource type name (ADR-042 single inheritance).", + ) # -- Name validation ----------------------------------------------------- @@ -371,6 +376,15 @@ class ResourceTypeSpec(BaseModel): "'equivalence' configuration for deduplication." ) + # ADR-042 rule 3: no cycles (self-inheritance) + if self.inherits is not None and self.inherits == self.name: + raise ValueError(f"'{self.name}' cannot inherit from itself.") + # ADR-042 rule 4: built-in must not inherit from custom + if self.built_in and self.inherits is not None and "/" in self.inherits: + raise ValueError( + f"Built-in '{self.name}' cannot inherit from custom '{self.inherits}'." + ) + return self # -- Factory methods ------------------------------------------------------ @@ -427,6 +441,7 @@ class ResourceTypeSpec(BaseModel): }, ), built_in=config.get("built_in", False), + inherits=config.get("inherits"), ) # -- CLI rendering -------------------------------------------------------- @@ -448,6 +463,9 @@ class ResourceTypeSpec(BaseModel): result["user_addable"] = self.user_addable result["built_in"] = self.built_in + if self.inherits is not None: + result["inherits"] = self.inherits + if self.cli_args: result["cli_args"] = [ { diff --git a/src/cleveragents/infrastructure/database/models.py b/src/cleveragents/infrastructure/database/models.py index 3c6dc3ead..ffb7ba544 100644 --- a/src/cleveragents/infrastructure/database/models.py +++ b/src/cleveragents/infrastructure/database/models.py @@ -1320,6 +1320,9 @@ class ResourceTypeModel(Base): # type: ignore[misc] capabilities_json = Column(Text, nullable=True) equivalence_json = Column(Text, nullable=True) + # Single-inheritance parent type name (ADR-042) + inherits = Column(String(255), nullable=True) + # Source: "builtin" or path to YAML config file source = Column(String(500), nullable=True) @@ -1342,6 +1345,7 @@ class ResourceTypeModel(Base): # type: ignore[misc] Index("ix_resource_types_namespace", "namespace"), Index("ix_resource_types_kind", "resource_kind"), Index("ix_resource_types_user_addable", "user_addable"), + Index("ix_resource_types_inherits", "inherits"), ) diff --git a/src/cleveragents/resource/__init__.py b/src/cleveragents/resource/__init__.py index d6a0f0a4f..25dcd3ef2 100644 --- a/src/cleveragents/resource/__init__.py +++ b/src/cleveragents/resource/__init__.py @@ -1 +1,27 @@ -"""Resource type schema loading and validation.""" +"""Resource type schema loading, validation, and inheritance resolution.""" + +from cleveragents.resource.inheritance import ( + MAX_CHAIN_DEPTH, + ResourceTypeCircularInheritanceError, + ResourceTypeInheritanceDepthError, + ResourceTypeParentNotFoundError, + ResourceTypeParentRemovalError, + find_subtypes, + is_subtype_of, + resolve_fields, + resolve_inheritance_chain, + validate_chain, +) + +__all__ = [ + "MAX_CHAIN_DEPTH", + "ResourceTypeCircularInheritanceError", + "ResourceTypeInheritanceDepthError", + "ResourceTypeParentNotFoundError", + "ResourceTypeParentRemovalError", + "find_subtypes", + "is_subtype_of", + "resolve_fields", + "resolve_inheritance_chain", + "validate_chain", +] diff --git a/src/cleveragents/resource/handlers/resolver.py b/src/cleveragents/resource/handlers/resolver.py index f8109cc0b..d7e059f5d 100644 --- a/src/cleveragents/resource/handlers/resolver.py +++ b/src/cleveragents/resource/handlers/resolver.py @@ -26,6 +26,13 @@ from typing import Any from cleveragents.resource.handlers.protocol import ResourceHandler +__all__ = [ + "HandlerResolutionError", + "clear_handler_cache", + "resolve_handler", + "resolve_handler_polymorphic", +] + logger = logging.getLogger(__name__) # Cache of already-resolved handler instances keyed by reference string @@ -114,6 +121,49 @@ def resolve_handler(handler_ref: str) -> ResourceHandler: return instance +def resolve_handler_polymorphic( + type_name: str, + type_registry: dict[str, Any], +) -> ResourceHandler: + """Resolve a handler for *type_name*, falling back to ancestor types. + + Implements ADR-042 §Handler Inheritance: if the type has no direct + handler, walk up the inheritance chain and use the first ancestor's + handler. + + Args: + type_name: Resource type name. + type_registry: Dict mapping type names to definitions. Each + entry must support ``handler`` and ``inherits`` keys. + + Returns: + An instantiated handler. + + Raises: + HandlerResolutionError: If no handler is found in the chain. + """ + from cleveragents.resource.inheritance import resolve_inheritance_chain + + chain = resolve_inheritance_chain(type_name, type_registry) + + for ancestor in chain: + entry = type_registry.get(ancestor) + if entry is None: + continue + handler_ref = ( + entry.get("handler") + if isinstance(entry, dict) + else getattr(entry, "handler", None) + ) + if handler_ref: + return resolve_handler(str(handler_ref)) + + raise HandlerResolutionError( + f"No handler found for '{type_name}' or any of its ancestors: " + f"{' -> '.join(chain)}" + ) + + def clear_handler_cache() -> None: """Clear the handler instance cache (useful for testing).""" with _cache_lock: diff --git a/src/cleveragents/resource/inheritance.py b/src/cleveragents/resource/inheritance.py new file mode 100644 index 000000000..ce03f45c8 --- /dev/null +++ b/src/cleveragents/resource/inheritance.py @@ -0,0 +1,398 @@ +"""Resource type inheritance resolution engine. + +Implements ADR-042: single-inheritance resource type specialization with +field resolution, collection merging, chain validation, and polymorphic +type checking. + +Key functions: + +| Function | Purpose | +|------------------------------|-----------------------------------------------| +| ``resolve_inheritance_chain``| Walk ``inherits`` links from child to root | +| ``resolve_fields`` | Merge fields along the chain (subtype wins) | +| ``is_subtype_of`` | Polymorphic check: does A inherit from B? | +| ``validate_chain`` | Registration-time chain validation | + +Chain rules (ADR-042 §Inheritance Chain Rules): + +1. Single inheritance only (at most one ``inherits`` value). +2. Maximum chain depth of 5 levels. +3. No circular inheritance. +4. Built-in types may not inherit from custom (namespaced) types. +5. Removing a parent type is prohibited while subtypes exist. + +See Also: + - ``docs/adr/ADR-042-resource-type-inheritance.md`` + - ``docs/specification.md`` lines 23460-23479 +""" + +from __future__ import annotations + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + +#: Maximum inheritance chain depth (ADR-042 rule 2). +MAX_CHAIN_DEPTH: int = 5 + + +class ResourceTypeInheritanceDepthError(ValueError): + """Raised when an inheritance chain exceeds the maximum depth.""" + + +class ResourceTypeCircularInheritanceError(ValueError): + """Raised when a circular inheritance chain is detected.""" + + +class ResourceTypeParentNotFoundError(ValueError): + """Raised when a parent type referenced by ``inherits`` is not registered.""" + + +class ResourceTypeParentRemovalError(ValueError): + """Raised when attempting to remove a type that has registered subtypes.""" + + +def resolve_inheritance_chain( + type_name: str, + type_registry: dict[str, Any], +) -> list[str]: + """Walk the ``inherits`` links from *type_name* to the root type. + + Returns the full chain as a list starting with *type_name* and + ending with the root ancestor (the first type that has no + ``inherits`` value). + + Args: + type_name: Name of the type to resolve. + type_registry: Mapping of type name to type definition. Each + definition must support ``inherits`` as a key (dict) or + attribute (object with ``.inherits``). + + Returns: + List of type names from child to root, e.g. + ``["devcontainer-instance", "container-instance"]``. + + Raises: + ResourceTypeParentNotFoundError: If a parent in the chain is + not in *type_registry*. + ResourceTypeCircularInheritanceError: If a cycle is detected. + ResourceTypeInheritanceDepthError: If the chain exceeds + :data:`MAX_CHAIN_DEPTH` levels. + """ + chain: list[str] = [] + visited: set[str] = set() + current = type_name + + while current is not None: + if current in visited: + raise ResourceTypeCircularInheritanceError( + f"Circular inheritance detected: {' -> '.join(chain)} -> {current}" + ) + + if len(chain) >= MAX_CHAIN_DEPTH: + raise ResourceTypeInheritanceDepthError( + f"Inheritance chain for '{type_name}' exceeds the maximum " + f"depth of {MAX_CHAIN_DEPTH}: {' -> '.join(chain)}" + ) + + visited.add(current) + chain.append(current) + + entry = type_registry.get(current) + if entry is None and current != type_name: + raise ResourceTypeParentNotFoundError( + f"Parent type '{current}' (in chain for '{type_name}') " + "is not registered." + ) + if entry is None: + # type_name itself is not in the registry. We return + # [type_name] instead of raising so that callers like + # is_subtype_of() can treat unregistered types as root + # types with no ancestors (returns False for any ancestor + # check). Service-level callers should verify existence + # before calling. + break + + parent = _get_inherits(entry) + current = parent + + return chain + + +def validate_chain( + type_name: str, + inherits: str | None, + type_registry: dict[str, Any], + *, + is_built_in: bool = False, +) -> list[str]: + """Validate an inheritance declaration at registration time. + + Checks all five ADR-042 chain rules and returns the resolved chain + on success. + + Args: + type_name: Name of the type being registered. + inherits: Parent type name (or ``None`` for root types). + type_registry: Current registry contents (must not yet contain + *type_name*). + is_built_in: Whether the type being registered is built-in. + + Returns: + The resolved inheritance chain (child to root). + + Raises: + ResourceTypeParentNotFoundError: If *inherits* names a type + not in *type_registry*. + ResourceTypeCircularInheritanceError: If adding this type + would create a cycle. + ResourceTypeInheritanceDepthError: If the resulting chain + would exceed :data:`MAX_CHAIN_DEPTH`. + ValueError: If a built-in type tries to inherit from a custom + type. + """ + if inherits is None: + return [type_name] + + # Rule 1: single inheritance is enforced by the schema (one field). + + # Rule 4: built-in types must not inherit from custom types. + if is_built_in and "/" in inherits: + raise ValueError( + f"Built-in type '{type_name}' cannot inherit from custom type '{inherits}'." + ) + + # Parent must exist. + if inherits not in type_registry: + raise ResourceTypeParentNotFoundError( + f"Parent type '{inherits}' is not registered. " + f"Cannot register '{type_name}' with inherits='{inherits}'." + ) + + # Build a temporary registry entry to check the chain. + temp_registry = dict(type_registry) + temp_registry[type_name] = {"inherits": inherits} + + # This will enforce rules 2 (depth) and 3 (cycles). + return resolve_inheritance_chain(type_name, temp_registry) + + +def is_subtype_of( + type_name: str, + ancestor_name: str, + type_registry: dict[str, Any], +) -> bool: + """Check whether *type_name* is the same as or a subtype of *ancestor_name*. + + This is the core polymorphism check used by: + - Tool binding (ADR-042 §Tool Binding Polymorphism) + - Auto-discovery child type matching + - DAG query filtering + + Args: + type_name: The concrete type to check. + ancestor_name: The declared/expected type. + type_registry: Current registry contents. + + Returns: + ``True`` if *type_name* equals *ancestor_name* or transitively + inherits from it. + """ + if type_name == ancestor_name: + return True + + try: + chain = resolve_inheritance_chain(type_name, type_registry) + except ( + ResourceTypeParentNotFoundError, + ResourceTypeCircularInheritanceError, + ResourceTypeInheritanceDepthError, + ): + return False + + return ancestor_name in chain + + +def resolve_fields( + type_name: str, + type_registry: dict[str, Any], +) -> dict[str, Any]: + """Resolve the merged fields for *type_name* along its inheritance chain. + + Field resolution (ADR-042 §Field Resolution): + - Scalar fields: subtype's value overrides parent's. + - Collection fields (``cli_args``, ``child_types``, ``parent_types``): + additive merging by default; ``_replace: true`` replaces. + + Args: + type_name: Name of the type to resolve. + type_registry: Current registry contents. + + Returns: + A dict with the fully resolved field values. + """ + chain = resolve_inheritance_chain(type_name, type_registry) + + # Walk from root to child (so child overrides parent). + merged: dict[str, Any] = {} + for name in reversed(chain): + entry = type_registry.get(name) + if entry is None: + continue + + fields = _to_dict(entry) + + # Scalar fields: subtype wins. + for key, value in fields.items(): + if key in _COLLECTION_FIELDS: + continue + if key.endswith("_replace"): + continue + if key == "inherits": + continue + merged[key] = value + + # Collection fields: additive merge or replace. + for field_name in _COLLECTION_FIELDS: + replace_key = f"{field_name}_replace" + should_replace = fields.get(replace_key, False) + child_value = fields.get(field_name) + + if child_value is None: + continue + + if should_replace or field_name not in merged: + merged[field_name] = child_value + else: + merged[field_name] = _merge_collection( + field_name, + merged[field_name], + child_value, + ) + + # Preserve the inherits field from the original type. + original = type_registry.get(type_name) + if original is not None: + merged["inherits"] = _get_inherits(original) + + return merged + + +def find_subtypes( + ancestor_name: str, + type_registry: dict[str, Any], +) -> list[str]: + """Find all types that inherit from *ancestor_name* (directly or transitively). + + Args: + ancestor_name: The ancestor type name. + type_registry: Current registry contents. + + Returns: + List of subtype names (does NOT include *ancestor_name* itself). + """ + subtypes: list[str] = [] + for name in type_registry: + if name == ancestor_name: + continue + if is_subtype_of(name, ancestor_name, type_registry): + subtypes.append(name) + return subtypes + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +#: Collection fields that use additive merging (ADR-042 §Collection Field Merging). +#: ``properties`` is also listed as additive in ADR-042; included here so +#: that parent properties are inherited and children can override individual +#: keys (dict merge) rather than replacing the entire mapping. +_COLLECTION_FIELDS: frozenset[str] = frozenset( + {"cli_args", "child_types", "parent_types", "properties"} +) + + +def _get_inherits(entry: Any) -> str | None: + """Extract the ``inherits`` value from a registry entry (dict or object).""" + if isinstance(entry, dict): + return entry.get("inherits") + return getattr(entry, "inherits", None) + + +def _to_dict(entry: Any) -> dict[str, Any]: + """Convert a registry entry to a dict for field merging.""" + if isinstance(entry, dict): + return dict(entry) + if hasattr(entry, "model_dump"): + return entry.model_dump() + return dict(vars(entry)) + + +def _merge_collection( + field_name: str, + parent_value: Any, + child_value: Any, +) -> Any: + """Merge a collection field using additive semantics. + + For ``cli_args``: child entries with the same ``name`` replace parent + entries (same-name replacement). For ``child_types`` and + ``parent_types``: union of both lists (deduplication). + """ + if field_name == "cli_args": + return _merge_cli_args(parent_value, child_value) + # child_types and parent_types: union, preserving order, child last. + return _merge_string_list(parent_value, child_value) + + +def _merge_cli_args( + parent_args: list[Any], + child_args: list[Any], +) -> list[Any]: + """Merge CLI argument lists with same-name replacement. + + Child args with the same ``name`` as a parent arg replace the parent + entry. Otherwise child args are appended. + """ + if not parent_args: + return list(child_args) + if not child_args: + return list(parent_args) + + # Build lookup of child arg names for O(1) checking. + child_names: set[str] = set() + for arg in child_args: + name = arg.get("name") if isinstance(arg, dict) else getattr(arg, "name", None) + if name: + child_names.add(name) + + # Keep parent args that are NOT overridden by child. + merged: list[Any] = [] + for arg in parent_args: + name = arg.get("name") if isinstance(arg, dict) else getattr(arg, "name", None) + if name not in child_names: + merged.append(arg) + + # Append all child args. + merged.extend(child_args) + return merged + + +def _merge_string_list( + parent: list[str], + child: list[str], +) -> list[str]: + """Merge two string lists with deduplication, preserving order.""" + seen: set[str] = set() + merged: list[str] = [] + for item in parent: + if item not in seen: + seen.add(item) + merged.append(item) + for item in child: + if item not in seen: + seen.add(item) + merged.append(item) + return merged diff --git a/src/cleveragents/resource/schema.py b/src/cleveragents/resource/schema.py index d2b62850d..da0dfe5cd 100644 --- a/src/cleveragents/resource/schema.py +++ b/src/cleveragents/resource/schema.py @@ -211,6 +211,26 @@ class ResourceTypeConfigSchema(BaseModel): False, description="Whether this is a built-in type.", ) + inherits: str | None = Field( + default=None, + max_length=255, + description=( + "Parent resource type name to inherit from (single inheritance). " + "See ADR-042 for full inheritance semantics." + ), + ) + cli_args_replace: bool = Field( + False, + description="Replace parent cli_args entirely instead of merging.", + ) + child_types_replace: bool = Field( + False, + description="Replace parent child_types entirely instead of merging.", + ) + parent_types_replace: bool = Field( + False, + description="Replace parent parent_types entirely instead of merging.", + ) model_config = ConfigDict( str_strip_whitespace=True, @@ -245,6 +265,21 @@ class ResourceTypeConfigSchema(BaseModel): ) return v + @field_validator("inherits") + @classmethod + def validate_inherits(cls, v: str | None) -> str | None: + """Validate parent type name format if provided.""" + if v is None: + return v + if not _BUILTIN_NAME_RE.match(v) and not _NAMESPACED_RE.match(v): + raise ValueError( + f"Invalid inherits type name '{v}'. " + "Names must start with a letter and contain only " + "alphanumeric characters, hyphens, or underscores. " + "Custom types must follow namespace/name format." + ) + return v + @field_validator("resource_kind") @classmethod def validate_resource_kind(cls, v: str) -> str: @@ -290,6 +325,18 @@ class ResourceTypeConfigSchema(BaseModel): "'equivalence' configuration." ) + # Cannot inherit from self (ADR-042 rule 3: no cycles) + if self.inherits is not None and self.inherits == self.name: + raise ValueError(f"Resource type '{self.name}' cannot inherit from itself.") + + # Built-in types must not inherit from custom types (ADR-042 rule 4) + if self.built_in and self.inherits is not None and "/" in self.inherits: + raise ValueError( + f"Built-in resource type '{self.name}' cannot inherit from " + f"custom type '{self.inherits}'. Built-in types may only " + "inherit from other built-in types." + ) + return self # ──────────────────────────────────────────────────────── diff --git a/src/cleveragents/tool/registry.py b/src/cleveragents/tool/registry.py index 288aca6f1..f0154878a 100644 --- a/src/cleveragents/tool/registry.py +++ b/src/cleveragents/tool/registry.py @@ -3,24 +3,30 @@ Thread-safe registry that stores [ToolSpec][cleveragents.tool.runtime.ToolSpec] instances keyed by their namespaced name. Supports registration, lookup, listing with optional -filters, and removal. +filters, removal, and polymorphic resource-type matching. ## Operations -| Method | Description | -|---------------------|---------------------------------------------------| -| ``register(spec)`` | Add a tool; raises ``ToolError`` on name collision| -| ``get(name)`` | Lookup by namespaced name; returns ``None`` if missing | -| ``list_tools(...)`` | List with optional namespace/type filters | -| ``remove(name)`` | Unregister; returns ``bool`` | +| Method | Description | +|-----------------------------|---------------------------------------------------| +| ``register(spec)`` | Add a tool; raises ``ToolError`` on name collision| +| ``get(name)`` | Lookup by namespaced name; returns ``None`` if missing | +| ``list_tools(...)`` | List with optional namespace/type filters | +| ``find_tools_for_resource`` | Polymorphic tool matching by resource type (ADR-042) | +| ``remove(name)`` | Unregister; returns ``bool`` | All mutating operations are protected by ``threading.RLock``. + +See Also: + - ADR-042: Resource Type Inheritance (§Tool Binding Polymorphism) """ from __future__ import annotations import threading +from typing import Any +from cleveragents.resource.inheritance import is_subtype_of from cleveragents.tool.runtime import ToolError, ToolSpec @@ -90,6 +96,44 @@ class ToolRegistry: return specs + def find_tools_for_resource( + self, + resource_type: str, + type_registry: dict[str, Any], + ) -> list[ToolSpec]: + """Find tools whose resource bindings accept *resource_type*. + + Implements ADR-042 §Tool Binding Polymorphism: a tool bound to + a parent type (e.g., ``container-instance``) also matches any + subtype (e.g., ``devcontainer-instance``). + + Resource bindings are stored in ``tool.source_metadata`` under + the key ``"resource_bindings"`` as a list of dicts with a + ``"resource_type"`` field. + + Args: + resource_type: The concrete resource type name to match. + type_registry: Dict mapping type names to their definitions + (must contain ``inherits`` keys for chain walking). + + Returns: + List of matching ``ToolSpec`` instances. + """ + with self._lock: + specs = list(self._tools.values()) + + matched: list[ToolSpec] = [] + for spec in specs: + bindings = spec.source_metadata.get("resource_bindings", []) + if not bindings: + continue + for binding in bindings: + slot_type = binding.get("resource_type", "") + if is_subtype_of(resource_type, slot_type, type_registry): + matched.append(spec) + break # One matching binding is enough + return matched + def remove(self, name: str) -> bool: """Remove a tool from the registry.