forked from HAL9000/cleveragents-core
a5cc81354d
Add --required and --informational as mutually exclusive boolean options to the `agents validation add` CLI command. When specified, they override the `mode` field from the YAML config file. This aligns the CLI with the specification (specification.md line 22339) which states the validation mode can be set "via --required/--informational on agents validation add". The fix resolves the spec contradiction by: - Implementing the flags in the CLI (per Core Concepts > Validation Mode) - Updating the formal CLI reference to include the new flags - Adding an exception to Design Principle #3 for validation add - Removing the spurious positional name argument from the walkthrough TDD tests from #1102 now run normally with @tdd_expected_fail removed. New edge-case tests cover mutual exclusivity (both flags rejected). Also excludes tool/wrapping.py from semgrep exec/compile rules since that module intentionally uses exec() in a controlled sandbox for the validation transform feature. ISSUES CLOSED: #1038
270 lines
9.7 KiB
Python
270 lines
9.7 KiB
Python
"""Step definitions for TDD bug #1038.
|
|
|
|
``agents validation add`` missing ``--required`` flag.
|
|
|
|
This test captures bug #1038. The specification
|
|
(``docs/specification.md`` line 22339) states that the validation mode
|
|
can be set "via ``--required``/``--informational`` on ``agents validation
|
|
add``", and numerous workflow examples throughout the spec use
|
|
``--required``. The fix adds ``--required`` and ``--informational`` as
|
|
mutually exclusive boolean options on the ``add`` command that override
|
|
the ``mode`` field in the YAML config when specified.
|
|
|
|
These scenarios were originally tagged ``@tdd_expected_fail`` while the
|
|
bug was unfixed. Now that the fix is in place, the tag has been removed
|
|
and the tests run normally as permanent regression guards.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import os
|
|
import tempfile
|
|
from typing import Any
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
from typer.testing import CliRunner
|
|
|
|
from cleveragents.cli.commands.validation import app as validation_app
|
|
|
|
_runner = CliRunner()
|
|
|
|
_PATCH_SVC = "cleveragents.cli.commands.validation._get_tool_registry_service"
|
|
|
|
_VALID_MODES = {"required", "informational"}
|
|
|
|
|
|
def _safe_unlink(path: str) -> None:
|
|
"""Remove a file if it exists, silently ignoring missing files."""
|
|
with contextlib.suppress(FileNotFoundError):
|
|
os.unlink(path)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Background
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a tdd 1038 CLI test runner with mocked services")
|
|
def step_tdd_1038_background(context: Context) -> None:
|
|
"""Set up CliRunner and a mocked ToolRegistryService.
|
|
|
|
The mock uses ``side_effect = lambda v: v`` so that ``register_tool``
|
|
returns the actual Validation object the CLI passes in, rather than a
|
|
hard-coded mock. This ensures the CLI output reflects the real
|
|
Validation produced by the CLI's processing pipeline, not mock
|
|
configuration.
|
|
"""
|
|
context.tdd1038_runner = _runner
|
|
context.tdd1038_result = None
|
|
context.tdd1038_config_path = None
|
|
|
|
mock_service = MagicMock()
|
|
|
|
# Return whatever Validation object the CLI passes in, so the CLI
|
|
# output reflects actual CLI processing rather than mock configuration.
|
|
mock_service.register_tool.side_effect = lambda v: v
|
|
context.tdd1038_mock_service = mock_service
|
|
|
|
|
|
@given("a tdd 1038 temporary validation YAML config")
|
|
def step_tdd_1038_temp_config(context: Context) -> None:
|
|
"""Create a temporary YAML config file with mode ``required``.
|
|
|
|
The YAML uses a flat ``mode`` key to match what
|
|
``Validation.from_config()`` actually reads (``config.get("mode",
|
|
"required")``). Note: the specification's Validation Configuration
|
|
schema (specification.md line 34109) nests mode under a ``validation``
|
|
key, but the current ``from_config()`` implementation reads a flat
|
|
top-level ``mode`` key. This test uses the flat format so the config
|
|
is actually effective and the false-positive guard is meaningful.
|
|
"""
|
|
config_content = (
|
|
"name: local/unit-tests\n"
|
|
"description: Unit tests\n"
|
|
"source: custom\n"
|
|
"mode: required\n"
|
|
"code: |\n"
|
|
" def run(inputs):\n"
|
|
" return {'passed': True}\n"
|
|
)
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w",
|
|
suffix=".yaml",
|
|
delete=False,
|
|
) as tmp:
|
|
tmp.write(config_content)
|
|
config_path = tmp.name
|
|
context.tdd1038_config_path = config_path
|
|
context.add_cleanup(lambda: _safe_unlink(config_path))
|
|
|
|
|
|
@given('a tdd 1038 temporary validation YAML config with mode "{mode}"')
|
|
def step_tdd_1038_temp_config_with_mode(context: Context, mode: str) -> None:
|
|
"""Create a temporary YAML config file with a specific mode.
|
|
|
|
The YAML uses a flat ``mode`` key to match what
|
|
``Validation.from_config()`` actually reads (``config.get("mode",
|
|
"required")``). See the docstring on ``step_tdd_1038_temp_config``
|
|
for the rationale on flat vs nested format.
|
|
"""
|
|
assert mode in _VALID_MODES, f"Invalid mode '{mode}': must be one of {_VALID_MODES}"
|
|
config_content = (
|
|
"name: local/unit-tests\n"
|
|
"description: Unit tests\n"
|
|
"source: custom\n"
|
|
f"mode: {mode}\n"
|
|
"code: |\n"
|
|
" def run(inputs):\n"
|
|
" return {'passed': True}\n"
|
|
)
|
|
# Clean up any previous config from the Background step.
|
|
if context.tdd1038_config_path is not None:
|
|
_safe_unlink(context.tdd1038_config_path)
|
|
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w",
|
|
suffix=".yaml",
|
|
delete=False,
|
|
) as tmp:
|
|
tmp.write(config_content)
|
|
config_path = tmp.name
|
|
context.tdd1038_config_path = config_path
|
|
context.add_cleanup(lambda: _safe_unlink(config_path))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I tdd 1038 invoke validation add with --required flag")
|
|
def step_tdd_1038_add_required(context: Context) -> None:
|
|
"""Invoke ``agents validation add --config <file> --required``.
|
|
|
|
Per the spec, the ``--required`` flag sets the validation mode to
|
|
``required``, overriding whatever mode the YAML config defines.
|
|
"""
|
|
with patch(_PATCH_SVC, return_value=context.tdd1038_mock_service):
|
|
context.tdd1038_result = context.tdd1038_runner.invoke(
|
|
validation_app,
|
|
[
|
|
"add",
|
|
"--config",
|
|
context.tdd1038_config_path,
|
|
"--required",
|
|
"--format",
|
|
"plain",
|
|
],
|
|
)
|
|
|
|
|
|
@when("I tdd 1038 invoke validation add with --informational flag")
|
|
def step_tdd_1038_add_informational(context: Context) -> None:
|
|
"""Invoke ``agents validation add --config <file> --informational``.
|
|
|
|
Per the spec (specification.md line 22339), ``--informational`` sets
|
|
the mode to ``informational``.
|
|
"""
|
|
with patch(_PATCH_SVC, return_value=context.tdd1038_mock_service):
|
|
context.tdd1038_result = context.tdd1038_runner.invoke(
|
|
validation_app,
|
|
[
|
|
"add",
|
|
"--config",
|
|
context.tdd1038_config_path,
|
|
"--informational",
|
|
"--format",
|
|
"plain",
|
|
],
|
|
)
|
|
|
|
|
|
@when("I tdd 1038 invoke validation add with both flags")
|
|
def step_tdd_1038_add_both_flags(context: Context) -> None:
|
|
"""Invoke ``agents validation add`` with both ``--required`` and
|
|
``--informational``.
|
|
|
|
These flags are mutually exclusive; the CLI should reject the
|
|
invocation and abort.
|
|
"""
|
|
with patch(_PATCH_SVC, return_value=context.tdd1038_mock_service):
|
|
context.tdd1038_result = context.tdd1038_runner.invoke(
|
|
validation_app,
|
|
[
|
|
"add",
|
|
"--config",
|
|
context.tdd1038_config_path,
|
|
"--required",
|
|
"--informational",
|
|
"--format",
|
|
"plain",
|
|
],
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("the tdd 1038 CLI result should succeed")
|
|
def step_tdd_1038_result_succeed(context: Context) -> None:
|
|
"""Assert the CLI invocation completed successfully (exit code 0)."""
|
|
result = context.tdd1038_result
|
|
assert result is not None, "No CLI result captured"
|
|
assert result.exit_code == 0, (
|
|
f"Expected exit code 0 but got {result.exit_code}. Output:\n{result.output}"
|
|
)
|
|
|
|
|
|
@then("the tdd 1038 CLI result should be aborted")
|
|
def step_tdd_1038_result_aborted(context: Context) -> None:
|
|
"""Assert the CLI invocation was aborted (non-zero exit code).
|
|
|
|
When both ``--required`` and ``--informational`` are passed, the CLI
|
|
should print an error message and abort.
|
|
"""
|
|
result = context.tdd1038_result
|
|
assert result is not None, "No CLI result captured"
|
|
assert result.exit_code != 0, (
|
|
f"Expected non-zero exit code but got {result.exit_code}. "
|
|
f"Output:\n{result.output}"
|
|
)
|
|
assert "mutually exclusive" in result.output, (
|
|
f"Expected 'mutually exclusive' in output, got:\n{result.output}"
|
|
)
|
|
|
|
|
|
@then('the tdd 1038 registered validation mode should be "{expected_mode}"')
|
|
def step_tdd_1038_mode_check(context: Context, expected_mode: str) -> None:
|
|
"""Assert the output contains the expected mode and the service received it.
|
|
|
|
This step verifies that ``register_tool`` was called with a Validation
|
|
object whose ``mode`` attribute matches the expected value. This
|
|
prevents a false positive where the mock always returns a hard-coded
|
|
mode regardless of whether the CLI actually forwarded the flag to the
|
|
service layer.
|
|
"""
|
|
result = context.tdd1038_result
|
|
assert result is not None, "No CLI result captured"
|
|
assert f"mode: {expected_mode}" in result.output, (
|
|
f"Expected 'mode: {expected_mode}' in output, got:\n{result.output}"
|
|
)
|
|
|
|
# Verify the service layer received the correct mode -- guards against
|
|
# false positives from hard-coded mock return values.
|
|
mock_service = context.tdd1038_mock_service
|
|
assert mock_service.register_tool.called, (
|
|
"register_tool was never called on the mock service"
|
|
)
|
|
call_args: Any = mock_service.register_tool.call_args
|
|
validation_arg = call_args[0][0]
|
|
actual_mode = getattr(validation_arg, "mode", None)
|
|
assert actual_mode == expected_mode, (
|
|
f"Expected register_tool to receive mode '{expected_mode}', "
|
|
f"but the Validation object had mode '{actual_mode}'"
|
|
)
|