Files
temp/features/steps/tdd_validation_add_required_flag_steps.py
brent.edwards a5cc81354d fix: add --required/--informational flags to validation add CLI
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
2026-03-31 07:42:06 +00:00

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}'"
)