Compare commits

...

2 Commits

Author SHA1 Message Date
HAL9000 f4837bd467 fix(tests): update patch targets for PR #8667 helper refactor
test step files had stale patch/fixture references that need updating after the validation CLI helper functions were moved into validation_helpers.py.

Details of this change:
- Fix invalid import in new step file (references non-existent module)
- Add mock service injection via patch() in When steps
- Improve Then step assertions to validate output content, not just exit codes
- Update all 7 existing test files with new patch target string pointing to validation_helpers module
2026-05-09 14:01:17 +00:00
HAL9000 f22462ccc5 fix(cli): add agents validation list command to validation CLI
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Failing after 1m14s
CI / push-validation (pull_request) Successful in 48s
CI / helm (pull_request) Successful in 50s
CI / lint (pull_request) Successful in 1m38s
CI / build (pull_request) Successful in 1m18s
CI / typecheck (pull_request) Successful in 2m10s
CI / quality (pull_request) Successful in 2m15s
CI / security (pull_request) Successful in 2m48s
CI / e2e_tests (pull_request) Successful in 5m19s
CI / integration_tests (pull_request) Failing after 7m12s
CI / unit_tests (pull_request) Failing after 8m27s
CI / coverage (pull_request) Has been skipped
CI / docker (pull_request) Has been skipped
CI / status-check (pull_request) Failing after 4s
Add the `list` subcommand to the validation CLI command group. This fixes issue #8621 where users were unable to list registered validations through the CLI interface due to the command not being implemented and registered.

Changes:
- Added `list` command with --namespace/-n, --source/-s, and --pattern/-p (regex) filters for listing validation agents
- Extracted shared helper functions into new validation_helpers.py module
- Updated imports in validation.py to use extracted helpers instead of inline private functions
- Fixed lint and formatting issues

Testing:
- Added BDD regression test feature file (features/validation_list_command.feature) with 7 scenarios covering: empty state, rich table display, namespace/source/pattern filtering, JSON output, and YAML output modes
- Added corresponding step definitions in features/steps/validation_list_command_steps.py

ISSUES CLOSED: #8621
2026-05-08 22:57:03 +00:00
12 changed files with 764 additions and 129 deletions
+8
View File
@@ -5,6 +5,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
- **Added `agents validation list` CLI command with filters** (#8667): The
`validation list` subcommand now lists registered validations with optional
`--namespace`, `--source`, and `--pattern` (regex) filters. Supports rich table,
JSON, YAML, plain, and table output formats. Implemented via extraction of shared
helper functions into a new `validation_helpers.py` module. Added Behave regression
test scenario (`features/validation_list_command.feature`) covering empty state,
all filter combinations, and JSON/YAML output modes.
- Fixed `ReactiveEventBus.emit()` exception handler to log the full exception
message (`str(exc)`) and enable traceback forwarding (`exc_info=True`).
Previously the handler logged only the exception type name (e.g.
@@ -58,7 +58,7 @@ def step_m3_smoke_mock_env(context: Context) -> None:
return_value=context.mock_invariant_service,
)
context.validation_patcher = patch(
"cleveragents.cli.commands.validation._get_tool_registry_service",
"cleveragents.cli.commands.validation_helpers.get_tool_registry_service",
return_value=context.mock_tool_registry_service,
)
context.plan_patcher = patch(
@@ -1,7 +1,7 @@
"""Step definitions for TDD: DI container resolution in _get_tool_registry_service.
"""Step definitions for TDD: DI container resolution in get_tool_registry_service.
These steps verify that ``_get_tool_registry_service`` in
``cleveragents.cli.commands.validation`` delegates to
These steps verify that ``get_tool_registry_service`` in
``cleveragents.cli.commands.validation_helpers`` delegates to
``container.tool_registry_service()`` rather than manually constructing
the service with ``create_engine`` / ``sessionmaker`` / repositories.
@@ -17,6 +17,11 @@ from unittest.mock import MagicMock, patch
from behave import given, then, when
from behave.runner import Context
# Import from the new helpers module (PR #8667).
from cleveragents.cli.commands.validation_helpers import (
get_tool_registry_service as _get_service,
)
# ---------------------------------------------------------------------------
# Patch targets
# ---------------------------------------------------------------------------
@@ -38,7 +43,7 @@ def step_tdd_di_background(context: Context) -> None:
# ---------------------------------------------------------------------------
# Scenario 1: _get_tool_registry_service delegates to container
# Scenario 1: get_tool_registry_service delegates to container
# ---------------------------------------------------------------------------
@@ -52,13 +57,11 @@ def step_tdd_di_container_has_provider(context: Context) -> None:
context.tdd_di_mock_service = mock_service
@when("_get_tool_registry_service is called with the mocked container")
@when("get_tool_registry_service is called with the mocked container")
def step_tdd_di_call_function(context: Context) -> None:
"""Call _get_tool_registry_service with the mocked container injected."""
from cleveragents.cli.commands.validation import _get_tool_registry_service
"""Call get_tool_registry_service with the mocked container injected."""
with patch(_PATCH_GET_CONTAINER, return_value=context.tdd_di_mock_container):
context.tdd_di_returned_service = _get_tool_registry_service()
context.tdd_di_returned_service = _get_service()
@then("the returned service should be the one from container.tool_registry_service")
@@ -67,7 +70,7 @@ def step_tdd_di_verify_service_identity(context: Context) -> None:
assert context.tdd_di_returned_service is context.tdd_di_mock_service, (
f"Expected the service from container.tool_registry_service(), "
f"but got {type(context.tdd_di_returned_service)}. "
"This means _get_tool_registry_service is NOT delegating to the container."
"This means get_tool_registry_service is NOT delegating to the container."
)
@@ -76,7 +79,7 @@ def step_tdd_di_verify_no_manual_construction(context: Context) -> None:
"""Verify that container.database_url() was NOT called (manual DI pattern)."""
mock_container = context.tdd_di_mock_container
assert not mock_container.database_url.called, (
"container.database_url() was called, which means _get_tool_registry_service "
"container.database_url() was called, which means get_tool_registry_service "
"is still using the manual DI pattern instead of delegating to "
"container.tool_registry_service()."
)
@@ -31,7 +31,7 @@ from cleveragents.cli.commands.validation import app as validation_app
_runner = CliRunner()
_PATCH_SVC = "cleveragents.cli.commands.validation._get_tool_registry_service"
_PATCH_SVC = "cleveragents.cli.commands.validation_helpers.get_tool_registry_service"
_VALID_MODES = {"required", "informational"}
+1 -1
View File
@@ -35,7 +35,7 @@ def _patch_tool_svc(context: Context) -> Any:
def _patch_val_svc(context: Context) -> Any:
return patch(
"cleveragents.cli.commands.validation._get_tool_registry_service",
"cleveragents.cli.commands.validation_helpers.get_tool_registry_service",
return_value=context.mock_service,
)
+1 -1
View File
@@ -91,7 +91,7 @@ def _patch_tool_svc(context: Context) -> Any:
def _patch_val_svc(context: Context) -> Any:
return patch(
"cleveragents.cli.commands.validation._get_tool_registry_service",
"cleveragents.cli.commands.validation_helpers.get_tool_registry_service",
return_value=context.mock_service,
)
@@ -35,7 +35,7 @@ def step_type_guard_mock_env(context: Context) -> None:
context.mock_tool_registry_service = MagicMock()
context.validation_patcher = patch(
"cleveragents.cli.commands.validation._get_tool_registry_service",
"cleveragents.cli.commands.validation_helpers.get_tool_registry_service",
return_value=context.mock_tool_registry_service,
)
context.validation_patcher.start()
@@ -2,9 +2,11 @@
Covers two gaps in ``cleveragents.cli.commands.validation``:
1. ``_get_tool_registry_service()`` (lines 75-95) the full construction path
that imports from the container, creates an engine/session-factory, builds
repositories, and returns a ``ToolRegistryService``.
1. ``get_tool_registry_service()`` in
``cleveragents.cli.commands.validation_helpers`` the full construction path
(lines 28-31) that imports from the container, creates an
engine/session-factory, builds repositories, and returns a
``ToolRegistryService``.
2. ``detach`` command branch L356360 when the user confirms the detach
prompt but ``detach_validation`` returns ``False`` (attachment not found).
@@ -20,6 +22,10 @@ from behave.runner import Context
from typer.testing import CliRunner
from cleveragents.cli.commands.validation import app as validation_app
# Import from the helpers module introduced by PR #8667.
from cleveragents.cli.commands.validation_helpers import (
get_tool_registry_service as vcb_get_service,
)
_runner = CliRunner()
@@ -27,12 +33,12 @@ _runner = CliRunner()
# Patch targets
# ---------------------------------------------------------------------------
# We patch get_container *inside* the validation module's lazy import so the
# We patch get_container *inside* the validation_helpers' lazy import so the
# real function body executes but uses our mock container.
_PATCH_GET_CONTAINER = "cleveragents.application.container.get_container"
# For the detach scenario we still patch the whole helper to isolate DB access.
_PATCH_VAL_SVC = "cleveragents.cli.commands.validation._get_tool_registry_service"
_PATCH_VAL_SVC = "cleveragents.cli.commands.validation_helpers.get_tool_registry_service"
# ---------------------------------------------------------------------------
@@ -49,7 +55,7 @@ def step_validation_cli_branch_background(context: Context) -> None:
# ---------------------------------------------------------------------------
# Scenario 1: _get_tool_registry_service construction path (L75-95)
# Scenario 1: get_tool_registry_service construction path (L28-31)
# ---------------------------------------------------------------------------
@@ -58,7 +64,7 @@ def step_vcb_di_container(context: Context) -> None:
"""Set up a mock container whose ``tool_registry_service()`` returns a
real ToolRegistryService backed by an in-memory SQLite database.
After Forgejo #3006, ``_get_tool_registry_service`` delegates directly to
After Forgejo #3006, ``get_tool_registry_service`` delegates directly to
``container.tool_registry_service()`` no manual engine/sessionmaker
construction occurs in the function itself.
"""
@@ -87,12 +93,11 @@ def step_vcb_di_container(context: Context) -> None:
context.vcb_mock_container = mock_container
@when("the validation cli branch _get_tool_registry_service is called")
@when("the validation cli branch get_tool_registry_service is called")
def step_vcb_call_get_tool_registry_service(context: Context) -> None:
from cleveragents.cli.commands.validation import _get_tool_registry_service
"""Call ``get_tool_registry_service`` with the mocked container injected."""
with patch(_PATCH_GET_CONTAINER, return_value=context.vcb_mock_container):
context.vcb_returned_service = _get_tool_registry_service()
context.vcb_returned_service = vcb_get_service()
@then("the validation cli branch returned service should be a ToolRegistryService")
@@ -102,7 +107,7 @@ def step_vcb_verify_service_type(context: Context) -> None:
)
assert context.vcb_returned_service is not None, (
"_get_tool_registry_service returned None"
"get_tool_registry_service returned None"
)
assert isinstance(context.vcb_returned_service, ToolRegistryService), (
f"Expected ToolRegistryService, got {type(context.vcb_returned_service)}"
@@ -0,0 +1,443 @@
"""BDD step definitions for the 'agents validation list' command.
This module provides complete Given/When/Then steps for testing the
``agents validation list`` CLI subcommand, including mock injection so
that tests exercise real filtering and formatting logic without hitting
the database.
The mock service is injected by patching ``get_tool_registry_service`` at
its definition location in ``validation_helpers.py``, ensuring the patched
service is used every time a command handler calls it via its import chain.
"""
from __future__ import annotations
import json
from typing import Any
import yaml
from behave import given, then, when
from behave.runner import Context
from typer.testing import CliRunner
from unittest.mock import MagicMock, patch
# Import the main CLI app for test invocations.
from cleveragents.cli.main import app as main_cli_app # noqa: F401
# ---------------------------------------------------------------------------
# Patch target — where ``get_tool_registry_service`` is defined
# (after PR #8667 refactors, helpers live in validation_helpers)
# ---------------------------------------------------------------------------
_PATCH_VAL_SVC = "cleveragents.cli.commands.validation_helpers.get_tool_registry_service"
# Shared CLI runner instance.
_runner = CliRunner(mix_stderr=False)
# ---------------------------------------------------------------------------
# Helper: build test-validation dicts & set up mock service
# ---------------------------------------------------------------------------
def _make_validation(
name: str = "local/coverage-check",
description: str = "Check code coverage meets threshold",
source: str = "custom",
mode: str = "required",
) -> dict[str, Any]:
"""Create a minimal validation spec dict for test fixtures."""
return {
"name": name,
"description": description,
"source": source,
"tool_type": "validation",
"mode": mode,
}
def _make_attachment(
attachment_id: str = "01HXYZ1234567890ABCDEFGHIJ",
validation_name: str = "local/coverage-check",
resource_id: str = "git-checkout/my-repo",
mode: str = "required",
) -> dict[str, Any]:
"""Create a minimal attachment spec dict for test fixtures."""
return {
"attachment_id": attachment_id,
"validation_name": validation_name,
"resource_id": resource_id,
"mode": mode,
"project_name": None,
"plan_id": None,
"created_at": "2026-01-01T00:00:00Z",
}
def _setup_mock_service(context: Context, validations: list) -> MagicMock:
"""Build a mock service and store it on context for When/Then use."""
svc = MagicMock()
svc.list_tools.return_value = validations
svc.get_tool.return_value = None
svc.attach_validation.return_value = _make_attachment()
context.vlist_mock_service = svc
return svc
# -------------------------------------------------------------------- #
# Given steps #
# -------------------------------------------------------------------- #
@given("a validation list command runner")
def step_validate_list_runner(context: Context) -> None:
"""Set up a CliRunner for the validation CLI app."""
context.vlist_runner = _runner
context.vlist_result = None
context.vlist_mock_service = None
@given("a tool registry with registered validation tools")
def step_mock_validation_service_with_tools(context: Context) -> None:
"""Mock ``get_tool_registry_service`` to return a predefined list."""
validations = [
_make_validation(
"local/coverage-check",
"Check code coverage meets threshold",
"custom",
"required",
),
_make_validation(
"local/lint-check", "Lint violations check", "custom", "informational"
),
_make_validation(
"global/security-scan",
"Security vulnerability scan",
"platform",
"required",
),
]
_setup_mock_service(context, validations)
@given("a tool registry with namespaced validation tools")
def step_mock_namespaced_validations(context: Context) -> None:
"""Mock the service to return namespace-aware validations."""
validations = [
_make_validation(
"local/coverage-check", "Local coverage check", "custom", "required"
),
_make_validation(
"local/lint-check", "Local lint check", "custom", "informational"
),
_make_validation(
"global/security-scan", "Global security scan", "platform", "required"
),
]
_setup_mock_service(context, validations)
@given("a tool registry with different sourced validation tools")
def step_mock_sourced_validations(context: Context) -> None:
"""Mock the service to return varied source-typed validations."""
validations = [
_make_validation(
"local/coverage-check", "Coverage check", "custom", "required"
),
_make_validation(
"platform/unit-test-v1",
"Platform unit test validator",
"platform",
"required",
),
_make_validation(
"system/auth-policy",
"Authentication policy check",
"system",
"informational",
),
]
_setup_mock_service(context, validations)
@given("a tool registry with numbered validation tools")
def step_mock_numbered_validations(context: Context) -> None:
"""Mock the service to return numbered validation names."""
validations = [
_make_validation(
"local/coverage-check-v1", "Coverage check v1", "custom", "required"
),
_make_validation(
"local/lint-runner-2", "Lint runner 2", "custom", "informational"
),
_make_validation(
"global/security-scan-alpha", "Security alpha", "platform", "required"
),
]
_setup_mock_service(context, validations)
# -------------------------------------------------------------------- #
# When steps #
# -------------------------------------------------------------------- #
# Shared list of validation dicts — used as default by ``_new_mock`` when no
# Given step set one up yet.
_LIST_VALIDATIONS = [
_make_validation(
"local/coverage-check",
"Check code coverage meets threshold",
"custom",
"required",
),
_make_validation(
"local/lint-check", "Lint violations check", "custom", "informational"
),
_make_validation(
"global/security-scan",
"Security vulnerability scan",
"platform",
"required",
),
]
# Helper: invoke the CLI with mock injection via patch. This uses the stored
# context.vlist_mock_service when available (set by Given steps); otherwise it
# creates a default one. The patch is applied at the _definition_ site of
# ``get_tool_registry_service`` in ``validation_helpers.py``, which also affects
# the imported alias used throughout ``validation.py``'s command functions. Each
# When step MUST call this function to ensure mocks take effect.
def _run_list_with_mock(
context: Context, args: list | None = None
) -> MagicMock:
"""Run the validation list CLI with mock service injected via patch."""
runner = getattr(context, "vlist_runner", _runner)
# Use the stored mock from Given steps if available.
svc = getattr(context, "vlist_mock_service", None)
if svc is None or not isinstance(svc, MagicMock):
svc = MagicMock()
svc.list_tools.return_value = _LIST_VALIDATIONS
context.vlist_mock_service = svc
with patch(_PATCH_VAL_SVC, return_value=svc):
return runner.invoke(main_cli_app, ["validation", "list"] + (args or []))
@when("I run the validation list command with no registered validations")
def step_run_list_empty(context: Context) -> None:
"""Invoke the list command with a mock that returns an empty list."""
# Override the mock to return nothing so we exercise the empty-state path.
context.vlist_mock_service = MagicMock()
context.vlist_mock_service.list_tools.return_value = []
result = _run_list_with_mock(context)
context.vlist_result = result
@when("I run the validation list command")
def step_run_list_all(context: Context) -> None:
"""Invoke the list command with default options."""
context.vlist_result = _run_list_with_mock(context)
@when('I run the validation list command with namespace filter "local"')
def step_run_list_namespace_filter(context: Context) -> None:
"""Run the list command with a --namespace filter."""
context.vlist_result = _run_list_with_mock(context, ["--namespace", "local"])
@when('I run the validation list command with source filter "custom"')
def step_run_list_source_filter(context: Context) -> None:
"""Run the list command with a --source filter."""
context.vlist_result = _run_list_with_mock(context, ["--source", "custom"])
@when('I run the validation list command with pattern "coverage.*"')
def step_run_list_pattern_filter(context: Context) -> None:
"""Run the list command with a --pattern regex filter."""
context.vlist_result = _run_list_with_mock(context, ["--pattern", "coverage.*"])
@when('I run the validation list command with format "json"')
def step_run_list_json(context: Context) -> None:
"""Run the list command with JSON output format."""
context.vlist_result = _run_list_with_mock(context, ["--format", "json"])
@when('I run the validation list command with format "yaml"')
def step_run_list_yaml(context: Context) -> None:
"""Run the list command with YAML output format."""
context.vlist_result = _run_list_with_mock(context, ["--format", "yaml"])
# -------------------------------------------------------------------- #
# Then steps #
# -------------------------------------------------------------------- #
@then('the output should contain "No validations found"')
def step_output_no_validations(context: Context) -> None:
"""Assert the empty-state message is displayed."""
assert context.vlist_result is not None, "Expected a CLI result"
stdout = getattr(context.vlist_result, "stdout", "") or ""
stderr = getattr(context.vlist_result, "stderr", "") or ""
combined = stdout + stderr
assert "No validations found" in combined, (
f"Expected 'No validations found' in output; got stdout={stdout!r} "
f"stderr={stderr!r}"
)
@then(
"the output should contain "
"'Register one with 'agents validation add --config <file>''"
)
def step_output_register_hint(context: Context) -> None:
"""Assert the registration hint is shown in empty state."""
assert context.vlist_result is not None, "Expected a CLI result"
stdout = getattr(context.vlist_result, "stdout", "") or ""
stderr = getattr(context.vlist_result, "stderr", "") or ""
combined = stdout + stderr
assert (
"Register one with 'agents validation add --config <file>'" in combined
), f"Expected register hint in output; got stdout={stdout!r} stderr={stderr!r}"
@then("the output should display a rich table")
def step_output_rich_table(context: Context) -> None:
"""Assert that the output contains table-like content."""
assert context.vlist_result is not None, "Expected a CLI result"
assert context.vlist_result.exit_code == 0, (
f"Expected exit_code 0, got {context.vlist_result.exit_code}; "
f"output: {context.vlist_result.output!r}"
)
# Rich table rendering goes through ``Rich Console.print()``.
assert "local/coverage-check" in context.vlist_result.output, (
f"Expected 'local/coverage-check' in output; got: "
f"{context.vlist_result.output!r}"
)
@then(
'the table should contain column headers '
'"Name", "Mode", "Source", "Description"'
)
def step_output_table_columns(context: Context) -> None:
"""Verify table columns match expected headers."""
assert context.vlist_result is not None, "Expected a CLI result"
stdout_val = getattr(context.vlist_result, "stdout", "") or ""
stderr_val = getattr(context.vlist_result, "stderr", "") or ""
combined = stdout_val + stderr_val
assert "Name" in combined, f"Expected 'Name' header; got: {combined!r}"
assert "Mode" in combined, f"Expected 'Mode' header; got: {combined!r}"
assert "Source" in combined, f"Expected 'Source' header; got: {combined!r}"
assert (
"Description" in combined
), f"Expected 'Description' header; got: {combined!r}"
@then(
'the output should only include validations matching namespace "local"'
)
def step_output_namespace_filtered(context: Context) -> None:
"""Assert that only namespace-filtered results are shown."""
assert context.vlist_result is not None, "Expected a CLI result"
assert context.vlist_result.exit_code == 0, (
f"Expected exit_code 0, got {context.vlist_result.exit_code}; "
f"output: {context.vlist_result.output!r}"
)
combined = (getattr(context.vlist_result, "stdout", "") or "") + (
getattr(context.vlist_result, "stderr", "") or ""
)
assert "local/coverage-check" in combined, (
f"Expected 'local/coverage-check' in filtered output; got: {combined!r}"
)
@then('the output should only include validations from source "custom"')
def step_output_source_filtered(context: Context) -> None:
"""Assert that only source-filtered results are shown."""
assert context.vlist_result is not None, "Expected a CLI result"
assert context.vlist_result.exit_code == 0, (
f"Expected exit_code 0, got {context.vlist_result.exit_code}; "
f"output: {context.vlist_result.output!r}"
)
combined = (getattr(context.vlist_result, "stdout", "") or "") + (
getattr(context.vlist_result, "stderr", "") or ""
)
assert "local/coverage-check" in combined, (
f"Expected 'local/coverage-check' (source=custom); got: {combined!r}"
)
assert "custom" in combined.lower(), (
f"Expected source='custom'; got: {combined!r}"
)
@then("the output should only include matching validation names")
def step_output_pattern_filtered(context: Context) -> None:
"""Assert that only pattern-matched validations are shown."""
assert context.vlist_result is not None, "Expected a CLI result"
assert context.vlist_result.exit_code == 0, (
f"Expected exit_code 0, got {context.vlist_result.exit_code}; "
f"output: {context.vlist_result.output!r}"
)
combined = (getattr(context.vlist_result, "stdout", "") or "") + (
getattr(context.vlist_result, "stderr", "") or ""
)
# Pattern was ``coverage.*`` — should match validation name containing
# "coverage".
assert "coverage-check" in combined, (
f"Expected 'coverage-check' in regex-filtered output; got: {combined!r}"
)
@then("the output should be valid JSON containing the registered validations")
def step_output_valid_json(context: Context) -> None:
"""Verify the JSON output parses correctly and contains expected items."""
assert context.vlist_result is not None, "Expected a CLI result"
assert context.vlist_result.exit_code == 0, (
f"Expected exit_code 0, got {context.vlist_result.exit_code}; "
f"output: {context.vlist_result.output!r}"
)
# JSON output goes to ``stdout`` via ``sys.stdout.write()`` in
# ``format_output``.
stdout = getattr(context.vlist_result, "stdout", "") or ""
assert stdout.strip(), (
f"Expected non-empty JSON in stdout; got: {context.vlist_result.output!r}"
)
parsed = json.loads(stdout)
# The formatting module wraps output in an envelope.
if isinstance(parsed, dict):
data = parsed.get("data", parsed)
else:
data = parsed
assert isinstance(data, list), (
f"Expected JSON list or envelope; got type {type(data)}"
)
assert len(data) > 0, "Expected at least one validation in JSON output"
@then("the output should be valid YAML containing the registered validations")
def step_output_valid_yaml(context: Context) -> None:
"""Verify the YAML output parses correctly and contains expected items."""
assert context.vlist_result is not None, "Expected a CLI result"
assert context.vlist_result.exit_code == 0, (
f"Expected exit_code 0, got {context.vlist_result.exit_code}; "
f"output: {context.vlist_result.output!r}"
)
# YAML output goes to ``stdout`` via ``sys.stdout.write()`` in
# ``format_output``.
stdout = getattr(context.vlist_result, "stdout", "") or ""
assert stdout.strip(), (
f"Expected non-empty YAML in stdout; got: {context.vlist_result.output!r}"
)
parsed = yaml.safe_load(stdout)
# The formatting module wraps output in an envelope.
if isinstance(parsed, dict):
_data = parsed.get("data", parsed)
else:
_data = parsed
assert isinstance(_data, list), (
f"Expected YAML list or envelope; got type {type(_data)}"
)
assert len(_data) > 0, "Expected at least one validation in YAML output"
+48
View File
@@ -0,0 +1,48 @@
Feature: Validation list command
As a CleverAgents user
I want to list all registered validations through the CLI
So that I can inspect my validation configurations
@tdd_issue_8621
Scenario: List validations shows empty state with no registrations
Given a validation list command runner
When I run the validation list command with no registered validations
Then the output should contain "No validations found"
And the output should contain "Register one with 'agents validation add --config <file>'"
@tdd_issue_8621
Scenario: List validations displays all registrations in rich table format
Given a tool registry with registered validation tools
When I run the validation list command
Then the output should display a rich table
And the table should contain column headers "Name", "Mode", "Source", "Description"
@tdd_issue_8621
Scenario: List validations filters by namespace
Given a tool registry with namespaced validation tools
When I run the validation list command with namespace filter "local"
Then the output should only include validations matching namespace "local"
@tdd_issue_8621
Scenario: List validations filters by source
Given a tool registry with different sourced validation tools
When I run the validation list command with source filter "custom"
Then the output should only include validations from source "custom"
@tdd_issue_8621
Scenario: List validations filters by regex pattern
Given a tool registry with numbered validation tools
When I run the validation list command with pattern "coverage.*"
Then the output should only include matching validation names
@tdd_issue_8621
Scenario: List validations outputs JSON format
Given a tool registry with registered validation tools
When I run the validation list command with format "json"
Then the output should be valid JSON containing the registered validations
@tdd_issue_8621
Scenario: List validations outputs YAML format
Given a tool registry with registered validation tools
When I run the validation list command with format "yaml"
Then the output should be valid YAML containing the registered validations
+104 -101
View File
@@ -8,6 +8,7 @@ subtypes) and their lifecycle attachments to resources.
| Command | Description |
|--------------------------------|------------------------------------------|
| ``agents validation add`` | Register validation from YAML config |
| ``agents validation list`` | List all registered validations |
| ``agents validation attach`` | Attach validation to a resource |
| ``agents validation detach`` | Detach a validation attachment |
@@ -49,14 +50,21 @@ Based on implementation_plan.md -- Task C1.tool.cli.
from __future__ import annotations
from pathlib import Path
from typing import Annotated, Any
from typing import Annotated
import typer
import yaml
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from cleveragents.cli.bootstrap import ensure_cli_database_bootstrapped
from cleveragents.cli.commands.validation_helpers import (
attachment_dict,
compile_pattern,
get_tool_registry_service,
get_validation_name,
print_validation,
validation_spec_dict,
)
from cleveragents.cli.formatting import OutputFormat, format_output
from cleveragents.core.exceptions import (
CleverAgentsError,
@@ -74,97 +82,6 @@ console = Console()
_FORMAT_HELP = "Output format: json, yaml, plain, table, or rich (default: rich)"
def _get_tool_registry_service() -> Any:
"""Get the ToolRegistryService from the DI container.
Delegates to get_container().tool_registry_service() so that
service wiring is managed exclusively by the container (Forgejo #3006).
"""
from cleveragents.application.container import get_container
ensure_cli_database_bootstrapped()
return get_container().tool_registry_service()
def _validation_spec_dict(tool: Any) -> dict[str, Any]:
"""Return validation data as a dict for CLI rendering."""
if hasattr(tool, "as_cli_dict"):
return dict(tool.as_cli_dict())
if isinstance(tool, dict):
result: dict[str, Any] = {
"name": tool.get("name", ""),
"description": tool.get("description", ""),
"source": tool.get("source", ""),
"tool_type": tool.get("tool_type", "validation"),
"mode": tool.get("mode", "required"),
}
if tool.get("wraps"):
result["wraps"] = tool["wraps"]
if tool.get("transform"):
result["transform"] = tool["transform"]
return result
return {"name": str(tool)}
def _attachment_dict(attachment: Any) -> dict[str, Any]:
"""Convert an attachment to a plain dict for output formatting."""
if isinstance(attachment, dict):
return {
"attachment_id": attachment.get("attachment_id", ""),
"validation_name": attachment.get("validation_name", ""),
"resource_id": attachment.get("resource_id", ""),
"mode": attachment.get("mode", "required"),
"project_name": attachment.get("project_name"),
"plan_id": attachment.get("plan_id"),
"created_at": attachment.get("created_at", ""),
}
return {
"attachment_id": getattr(attachment, "attachment_id", ""),
"validation_name": getattr(attachment, "validation_name", ""),
"resource_id": getattr(attachment, "resource_id", ""),
"mode": getattr(attachment, "mode", "required"),
"project_name": getattr(attachment, "project_name", None),
"plan_id": getattr(attachment, "plan_id", None),
"created_at": str(getattr(attachment, "created_at", "")),
}
def _print_validation(
tool: Any,
title: str = "Validation",
fmt: str = OutputFormat.RICH.value,
) -> None:
"""Print validation details in the requested format."""
data = _validation_spec_dict(tool)
if fmt != OutputFormat.RICH.value:
console.print(format_output(data, fmt))
return
name = data.get("name", "")
desc = data.get("description", "")
source = data.get("source", "")
mode = data.get("mode", "required")
details = (
f"[bold]Name:[/bold] {name}\n"
f"[bold]Description:[/bold] {desc}\n"
f"[bold]Source:[/bold] {source}\n"
f"[bold]Mode:[/bold] {mode}"
)
wraps = data.get("wraps")
if wraps:
details += f"\n[bold]Wraps:[/bold] {wraps}"
transform = data.get("transform")
if transform:
details += f"\n[bold]Transform:[/bold] {transform}"
console.print(Panel(details, title=title, expand=False))
@app.command("add")
def add(
config: Annotated[
@@ -233,17 +150,17 @@ def add(
config_dict["mode"] = ValidationMode.INFORMATIONAL.value
validation = Validation.from_config(config_dict)
service = _get_tool_registry_service()
service = get_tool_registry_service()
if update:
existing = service.get_tool(validation.name)
if existing is not None:
registered = service.update_tool(validation)
_print_validation(registered, title="Validation Updated", fmt=fmt)
print_validation(registered, title="Validation Updated", fmt=fmt)
return
registered = service.register_tool(validation)
_print_validation(registered, title="Validation Registered", fmt=fmt)
print_validation(registered, title="Validation Registered", fmt=fmt)
except FileNotFoundError as exc:
console.print(f"[red]Config file error:[/red] {exc}")
@@ -262,6 +179,92 @@ def add(
raise typer.Abort() from exc
@app.command("list")
def list_validations(
namespace: Annotated[
str | None,
typer.Option("--namespace", "-n", help="Filter by namespace"),
] = None,
source: Annotated[
str | None,
typer.Option("--source", "-s", help="Filter by source type"),
] = None,
pattern: Annotated[
str | None,
typer.Option(
"--pattern",
"-p",
help="Filter validation names by regex pattern",
),
] = None,
fmt: Annotated[
str,
typer.Option("--format", "-f", help=_FORMAT_HELP),
] = "rich",
) -> None:
"""List validations with optional filters.
Examples:
agents validation list
agents validation list --namespace local
agents validation list --source custom
agents validation list --pattern "coverage-.*"
agents validation list --format json
"""
try:
service = get_tool_registry_service()
validations = service.list_tools(
namespace=namespace,
tool_type="validation",
source=source,
)
# Apply regex filter if provided
if pattern:
compiled_pattern = compile_pattern(pattern)
validations = [
v
for v in validations
if compiled_pattern.search(get_validation_name(v))
]
if not validations:
console.print("[yellow]No validations found.[/yellow]")
console.print("Register one with 'agents validation add --config <file>'")
return
# Non-rich formats use the formatting helper
if fmt != OutputFormat.RICH.value:
data = [dict(validation_spec_dict(v)) for v in validations]
console.print(format_output(data, fmt))
return
# Rich table
table = Table(title=f"Validations ({len(validations)} total)")
table.add_column("Name", style="cyan")
table.add_column("Mode", style="blue")
table.add_column("Source", style="magenta")
table.add_column("Description", style="dim")
for validation in validations:
spec = validation_spec_dict(validation)
desc = str(spec.get("description", ""))
if len(desc) > 40:
desc = desc[:37] + "..."
table.add_row(
str(spec.get("name", "")),
str(spec.get("mode", "required")),
str(spec.get("source", "")),
desc,
)
console.print(table)
except CleverAgentsError as exc:
console.print(f"[red]Error:[/red] {exc.message}")
raise typer.Abort() from exc
@app.command(
"attach",
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
@@ -324,7 +327,7 @@ def attach(
raise typer.Abort()
key = token[2:].replace(
"-", "_"
) # --coverage-threshold coverage_threshold
) # --coverage-threshold -> coverage_threshold
if i + 1 >= len(raw_extra) or raw_extra[i + 1].startswith("--"):
console.print(
f"[red]Missing value for option:[/red] {token} "
@@ -335,7 +338,7 @@ def attach(
extra_args[key] = val
i += 2
service = _get_tool_registry_service()
service = get_tool_registry_service()
attachment = service.attach_validation(
validation_name=validation_name,
resource_id=resource,
@@ -344,7 +347,7 @@ def attach(
args=extra_args,
)
att_data = _attachment_dict(attachment)
att_data = attachment_dict(attachment)
if fmt != OutputFormat.RICH.value:
console.print(format_output(att_data, fmt))
@@ -398,7 +401,7 @@ def detach(
console.print("[yellow]Aborted.[/yellow]")
raise typer.Abort()
service = _get_tool_registry_service()
service = get_tool_registry_service()
removed = service.detach_validation(attachment_id)
if not removed:
@@ -0,0 +1,125 @@
"""Helper functions for validation CLI commands.
This module contains utility functions for validation command processing,
including formatting, filtering, and data transformation.
"""
from __future__ import annotations
import re
from typing import Any
from rich.console import Console
from rich.panel import Panel
from cleveragents.cli.bootstrap import ensure_cli_database_bootstrapped
from cleveragents.cli.formatting import OutputFormat, format_output
console = Console()
def get_tool_registry_service() -> Any:
"""Get the ToolRegistryService from the DI container.
Delegates to get_container().tool_registry_service() so that
service wiring is managed exclusively by the container (Forgejo #3006).
"""
from cleveragents.application.container import get_container
ensure_cli_database_bootstrapped()
return get_container().tool_registry_service()
def validation_spec_dict(tool: Any) -> dict[str, Any]:
"""Return validation data as a dict for CLI rendering."""
if hasattr(tool, "as_cli_dict"):
return dict(tool.as_cli_dict())
if isinstance(tool, dict):
result: dict[str, Any] = {
"name": tool.get("name", ""),
"description": tool.get("description", ""),
"source": tool.get("source", ""),
"tool_type": tool.get("tool_type", "validation"),
"mode": tool.get("mode", "required"),
}
if tool.get("wraps"):
result["wraps"] = tool["wraps"]
if tool.get("transform"):
result["transform"] = tool["transform"]
return result
return {"name": str(tool)}
def get_validation_name(v: Any) -> str:
"""Return the name string for a validation object or dict."""
if isinstance(v, dict):
return str(v.get("name", ""))
return str(getattr(v, "name", ""))
def attachment_dict(attachment: Any) -> dict[str, Any]:
"""Convert an attachment to a plain dict for output formatting."""
if isinstance(attachment, dict):
return {
"attachment_id": attachment.get("attachment_id", ""),
"validation_name": attachment.get("validation_name", ""),
"resource_id": attachment.get("resource_id", ""),
"mode": attachment.get("mode", "required"),
"project_name": attachment.get("project_name"),
"plan_id": attachment.get("plan_id"),
"created_at": attachment.get("created_at", ""),
}
return {
"attachment_id": getattr(attachment, "attachment_id", ""),
"validation_name": getattr(attachment, "validation_name", ""),
"resource_id": getattr(attachment, "resource_id", ""),
"mode": getattr(attachment, "mode", "required"),
"project_name": getattr(attachment, "project_name", None),
"plan_id": getattr(attachment, "plan_id", None),
"created_at": str(getattr(attachment, "created_at", "")),
}
def print_validation(
tool: Any,
title: str = "Validation",
fmt: str = OutputFormat.RICH.value,
) -> None:
"""Print validation details in the requested format."""
data = validation_spec_dict(tool)
if fmt != OutputFormat.RICH.value:
console.print(format_output(data, fmt))
return
name = data.get("name", "")
desc = data.get("description", "")
source = data.get("source", "")
mode = data.get("mode", "required")
details = (
f"[bold]Name:[/bold] {name}\n"
f"[bold]Description:[/bold] {desc}\n"
f"[bold]Source:[/bold] {source}\n"
f"[bold]Mode:[/bold] {mode}"
)
wraps = data.get("wraps")
if wraps:
details += f"\n[bold]Wraps:[/bold] {wraps}"
transform = data.get("transform")
if transform:
details += f"\n[bold]Transform:[/bold] {transform}"
console.print(Panel(details, title=title, expand=False))
def compile_pattern(pattern: str) -> re.Pattern[str]:
"""Compile a regex pattern, raising an error if invalid."""
try:
return re.compile(pattern)
except re.error as exc:
console.print(f"[red]Invalid regex pattern:[/red] {pattern}")
raise ValueError(f"Invalid regex pattern: {pattern}") from exc