diff --git a/features/invariant_cli_new_coverage.feature b/features/invariant_cli_new_coverage.feature index 1b5191ab4..8c26e214c 100644 --- a/features/invariant_cli_new_coverage.feature +++ b/features/invariant_cli_new_coverage.feature @@ -159,3 +159,96 @@ Feature: Invariant CLI commands coverage When I invoke invariant remove with "--yes" for id "MISSING_ID" Then the invariant CLI exit code should be non-zero And the invariant CLI output should contain "not found" + + # === _resolve_scope_targets helper === + + Scenario: Resolve scope targets with --global flag returns single GLOBAL target + When I resolve invariant scope targets with global flag set + Then the resolved invariant targets should have 1 entry + And the first resolved target scope should be "global" + And the first resolved target source should be "system" + + Scenario: Resolve scope targets with --project flag returns single PROJECT target + When I resolve invariant scope targets with project "myapp" + Then the resolved invariant targets should have 1 entry + And the first resolved target scope should be "project" + And the first resolved target source should be "myapp" + + Scenario: Resolve scope targets with single --plan returns one PLAN target + When I resolve invariant scope targets with plans "PLAN-001" + Then the resolved invariant targets should have 1 entry + And the first resolved target scope should be "plan" + And the first resolved target source should be "PLAN-001" + + Scenario: Resolve scope targets with two --plan values returns two PLAN targets + When I resolve invariant scope targets with plans "PLAN-001,PLAN-002" + Then the resolved invariant targets should have 2 entries + And the first resolved target scope should be "plan" + And the first resolved target source should be "PLAN-001" + And the second resolved target scope should be "plan" + And the second resolved target source should be "PLAN-002" + + Scenario: Resolve scope targets with single --action returns one ACTION target + When I resolve invariant scope targets with actions "deploy" + Then the resolved invariant targets should have 1 entry + And the first resolved target scope should be "action" + And the first resolved target source should be "deploy" + + Scenario: Resolve scope targets with two --action values returns two ACTION targets + When I resolve invariant scope targets with actions "deploy,rollback" + Then the resolved invariant targets should have 2 entries + And the first resolved target scope should be "action" + And the first resolved target source should be "deploy" + And the second resolved target scope should be "action" + And the second resolved target source should be "rollback" + + Scenario: Resolve scope targets with no flags defaults to GLOBAL + When I resolve invariant scope targets with no flags + Then the resolved invariant targets should have 1 entry + And the first resolved target scope should be "global" + And the first resolved target source should be "system" + + Scenario: Resolve scope targets with conflicting flags raises BadParameter + When I resolve invariant scope targets with global and plans "PLAN-001" + Then a BadParameter error should be raised for invariant scope targets + + # === invariant add with repeatable --plan flags === + + Scenario: Add invariant with two --plan flags creates two invariants + Given a mocked InvariantService for invariant CLI add + When I invoke invariant add with "--plan PLAN-001 --plan PLAN-002" and text "No prod deletes" + Then the invariant CLI exit code should be 0 + And the invariant CLI output should contain "Invariant added" + And the invariant add service was called 2 times + + Scenario: Add invariant with two --action flags creates two invariants + Given a mocked InvariantService for invariant CLI add + When I invoke invariant add with "--action deploy --action rollback" and text "Require approval" + Then the invariant CLI exit code should be 0 + And the invariant CLI output should contain "Invariant added" + And the invariant add service was called 2 times + + Scenario: Add invariant with single --plan flag creates one invariant + Given a mocked InvariantService for invariant CLI add + When I invoke invariant add with "--plan PLAN-001" and text "Single plan invariant" + Then the invariant CLI exit code should be 0 + And the invariant CLI output should contain "Invariant added" + And the invariant add service was called 1 time + + Scenario: Add invariant with single --action flag creates one invariant + Given a mocked InvariantService for invariant CLI add + When I invoke invariant add with "--action deploy" and text "Single action invariant" + Then the invariant CLI exit code should be 0 + And the invariant CLI output should contain "Invariant added" + And the invariant add service was called 1 time + + Scenario: Add invariant with two --plan flags in json format outputs list + Given a mocked InvariantService for invariant CLI add + When I invoke invariant add with "--plan PLAN-001 --plan PLAN-002 --format json" and text "No prod deletes" + Then the invariant CLI exit code should be 0 + And the invariant CLI output should contain "No prod deletes" + + Scenario: Add invariant with conflicting --plan and --global flags raises error + Given a mocked InvariantService for invariant CLI add + When I invoke invariant add with "--global --plan PLAN-001" and text "conflict" + Then the invariant CLI exit code should be non-zero diff --git a/features/steps/invariant_cli_new_coverage_steps.py b/features/steps/invariant_cli_new_coverage_steps.py index 3a9074fb5..a07528d34 100644 --- a/features/steps/invariant_cli_new_coverage_steps.py +++ b/features/steps/invariant_cli_new_coverage_steps.py @@ -10,13 +10,14 @@ from datetime import datetime from unittest.mock import MagicMock, patch import typer -from behave import given, then, when +from behave import given, then, use_step_matcher, when from typer.testing import CliRunner from cleveragents.cli.commands.invariant import ( _get_service, _invariant_dict, _resolve_scope, + _resolve_scope_targets, app, ) from cleveragents.core.exceptions import CleverAgentsError, NotFoundError @@ -359,3 +360,126 @@ def step_mock_service_not_found_remove(context): resource_type="invariant", resource_id="MISSING_ID" ) _patch_svc(context, svc) + + +# ================================================================ +# _resolve_scope_targets helper steps +# ================================================================ + + +@when("I resolve invariant scope targets with global flag set") +def step_resolve_targets_global(context): + context.resolved_targets = _resolve_scope_targets( + is_global=True, project=None, plans=[], actions=[] + ) + + +@when('I resolve invariant scope targets with project "{project}"') +def step_resolve_targets_project(context, project): + context.resolved_targets = _resolve_scope_targets( + is_global=False, project=project, plans=[], actions=[] + ) + + +@when('I resolve invariant scope targets with plans "{plans_csv}"') +def step_resolve_targets_plans(context, plans_csv): + plans = plans_csv.split(",") + context.resolved_targets = _resolve_scope_targets( + is_global=False, project=None, plans=plans, actions=[] + ) + + +@when('I resolve invariant scope targets with actions "{actions_csv}"') +def step_resolve_targets_actions(context, actions_csv): + actions = actions_csv.split(",") + context.resolved_targets = _resolve_scope_targets( + is_global=False, project=None, plans=[], actions=actions + ) + + +@when("I resolve invariant scope targets with no flags") +def step_resolve_targets_default(context): + context.resolved_targets = _resolve_scope_targets( + is_global=False, project=None, plans=[], actions=[] + ) + + +@when('I resolve invariant scope targets with global and plans "{plans_csv}"') +def step_resolve_targets_conflicting(context, plans_csv): + plans = plans_csv.split(",") + context.inv_bad_parameter_raised = False + try: + _resolve_scope_targets( + is_global=True, project=None, plans=plans, actions=[] + ) + except typer.BadParameter: + context.inv_bad_parameter_raised = True + + +@then("the resolved invariant targets should have {count:d} entries") +def step_check_targets_count_plural(context, count): + assert len(context.resolved_targets) == count, ( + f"Expected {count} targets, got {len(context.resolved_targets)}" + ) + + +@then("the resolved invariant targets should have {count:d} entry") +def step_check_targets_count_singular(context, count): + assert len(context.resolved_targets) == count, ( + f"Expected {count} targets, got {len(context.resolved_targets)}" + ) + + +@then('the first resolved target scope should be "{scope}"') +def step_check_first_target_scope(context, scope): + actual_scope, _ = context.resolved_targets[0] + assert actual_scope.value == scope, ( + f"Expected first target scope '{scope}', got '{actual_scope.value}'" + ) + + +@then('the first resolved target source should be "{source}"') +def step_check_first_target_source(context, source): + _, actual_source = context.resolved_targets[0] + assert actual_source == source, ( + f"Expected first target source '{source}', got '{actual_source}'" + ) + + +@then('the second resolved target scope should be "{scope}"') +def step_check_second_target_scope(context, scope): + actual_scope, _ = context.resolved_targets[1] + assert actual_scope.value == scope, ( + f"Expected second target scope '{scope}', got '{actual_scope.value}'" + ) + + +@then('the second resolved target source should be "{source}"') +def step_check_second_target_source(context, source): + _, actual_source = context.resolved_targets[1] + assert actual_source == source, ( + f"Expected second target source '{source}', got '{actual_source}'" + ) + + +@then("a BadParameter error should be raised for invariant scope targets") +def step_check_bad_parameter_targets(context): + assert context.inv_bad_parameter_raised, ( + "Expected typer.BadParameter to be raised for scope targets" + ) + + +# Use regex step matcher for the call count check to handle "1 time" and "2 times" +use_step_matcher("re") + + +@then(r"the invariant add service was called (?P\d+) times?") +def step_check_add_called_n_times(context, count): + expected = int(count) + actual = context.inv_mock_svc.add_invariant.call_count + assert actual == expected, ( + f"Expected add_invariant to be called {expected} time(s), got {actual}" + ) + + +use_step_matcher("parse") diff --git a/src/cleveragents/cli/commands/invariant.py b/src/cleveragents/cli/commands/invariant.py index 2376cb5bd..cca0287c0 100644 --- a/src/cleveragents/cli/commands/invariant.py +++ b/src/cleveragents/cli/commands/invariant.py @@ -17,16 +17,22 @@ Each invariant belongs to exactly one scope. Pass the matching flag: - ``--global``: System-wide invariant - ``--project PROJECT``: Project-scoped invariant -- ``--action ACTION``: Action-template invariant -- ``--plan PLAN_ID``: Plan-specific invariant +- ``--action ACTION``: Action-template invariant (repeatable) +- ``--plan PLAN_ID``: Plan-specific invariant (repeatable) If no scope flag is given, ``--global`` is assumed. +The ``--plan`` and ``--action`` flags are repeatable: you may pass them +multiple times to attach the same invariant to several plans or actions +in a single command. + ## Examples ```bash agents invariant add --global "Never delete production data" agents invariant add --project myapp "All API changes need tests" +agents invariant add --plan PLAN-001 --plan PLAN-002 "Never delete production data" +agents invariant add --action deploy --action rollback "Require approval" agents invariant list --effective --project myapp agents invariant remove INV_ULID ``` @@ -75,6 +81,10 @@ def _resolve_scope( ) -> tuple[InvariantScope, str]: """Resolve scope flag combination to (scope, source_name). + This helper is used for single-value resolution (e.g. in ``list``). + For the ``add`` command, use ``_resolve_scope_targets`` which handles + repeatable ``--plan`` and ``--action`` flags. + Raises: typer.BadParameter: On conflicting or missing flags. """ @@ -97,6 +107,49 @@ def _resolve_scope( return InvariantScope.GLOBAL, "system" +def _resolve_scope_targets( + is_global: bool, + project: str | None, + plans: list[str], + actions: list[str], +) -> list[tuple[InvariantScope, str]]: + """Resolve scope flags to a list of (scope, source_name) pairs. + + Supports repeatable ``--plan`` and ``--action`` flags so that a single + ``agents invariant add`` call can create the same invariant for multiple + plans or actions. + + Args: + is_global: Whether ``--global`` was passed. + project: Value of ``--project`` (single-value flag). + plans: All values passed via ``--plan`` (may be empty). + actions: All values passed via ``--action`` (may be empty). + + Returns: + A list of ``(InvariantScope, source_name)`` pairs — one per target. + + Raises: + typer.BadParameter: When conflicting scope flags are provided. + """ + flags_set = sum( + [is_global, project is not None, bool(plans), bool(actions)] + ) + if flags_set > 1: + raise typer.BadParameter( + "Specify at most one scope flag: --global, --project, --plan, or --action" + ) + + if project is not None: + return [(InvariantScope.PROJECT, project)] + if plans: + return [(InvariantScope.PLAN, p) for p in plans] + if actions: + return [(InvariantScope.ACTION, a) for a in actions] + + # Default to global + return [(InvariantScope.GLOBAL, "system")] + + def _invariant_dict(inv: Invariant) -> dict[str, object]: """Serialise an Invariant for non-rich output formats.""" return { @@ -118,29 +171,46 @@ def add( project: Annotated[ str | None, typer.Option("--project", help="Project name") ] = None, - plan: Annotated[str | None, typer.Option("--plan", help="Plan ID (ULID)")] = None, - action: Annotated[str | None, typer.Option("--action", help="Action name")] = None, + plan: Annotated[ + list[str], typer.Option("--plan", help="Plan ID (ULID); repeatable") + ] = [], # noqa: B006 + action: Annotated[ + list[str], typer.Option("--action", help="Action name; repeatable") + ] = [], # noqa: B006 fmt: Annotated[str, typer.Option("--format", "-f", help=_FORMAT_HELP)] = "rich", ) -> None: """Add a new invariant constraint. + The ``--plan`` and ``--action`` flags are repeatable: pass them multiple + times to attach the same invariant to several plans or actions at once. + Examples: agents invariant add --global "Never delete production data" agents invariant add --project myapp "All API changes need tests" + agents invariant add --plan PLAN-001 --plan PLAN-002 "No prod deletes" + agents invariant add --action deploy --action rollback "Require approval" """ try: - scope, source_name = _resolve_scope(is_global, project, plan, action) + targets = _resolve_scope_targets(is_global, project, plan, action) service = _get_service() - inv = service.add_invariant(text=text, scope=scope, source_name=source_name) + created: list[Invariant] = [] + for scope, source_name in targets: + inv = service.add_invariant( + text=text, scope=scope, source_name=source_name + ) + created.append(inv) if fmt != OutputFormat.RICH.value: - console.print(format_output(_invariant_dict(inv), fmt)) + data = [_invariant_dict(inv) for inv in created] + output: object = data[0] if len(data) == 1 else data + console.print(format_output(output, fmt)) return - console.print(f"[green]Invariant added:[/green] {inv.id}") - console.print(f" Text: {inv.text}") - console.print(f" Scope: {inv.scope.value}") - console.print(f" Source: {inv.source_name}") + for inv in created: + console.print(f"[green]Invariant added:[/green] {inv.id}") + console.print(f" Text: {inv.text}") + console.print(f" Scope: {inv.scope.value}") + console.print(f" Source: {inv.source_name}") except CleverAgentsError as e: console.print(f"[red]Error:[/red] {e.message}") diff --git a/test_reports/summary.txt b/test_reports/summary.txt new file mode 100644 index 000000000..2285379fb --- /dev/null +++ b/test_reports/summary.txt @@ -0,0 +1,19 @@ +Test Framework: generic +Total Tests: 3 +Passed: 2 +Failed: 1 + +--- Test Results --- +✓ Output Block 1 +✓ nox > Running session lint +✗ Error Output + +--- Failed Tests --- +✗ Error Output + nox > Running session lint + nox > Reusing existing virtual environment at .nox/lint. + nox > uv pip install 'ruff>=0.15,<0.16' + nox > ruff check src/ scripts/ examples/ features/ robot/ + nox > Session lint was successful. + + diff --git a/test_reports/test_results.json b/test_reports/test_results.json new file mode 100644 index 000000000..2b6baeea0 --- /dev/null +++ b/test_reports/test_results.json @@ -0,0 +1,44 @@ +{ + "framework": "generic", + "tests": [ + { + "name": "Output Block 1", + "passed": true, + "output": [ + "All checks passed!" + ], + "rawOutput": "All checks passed!" + }, + { + "name": "nox > Running session lint", + "passed": true, + "output": [ + "nox > Running session lint", + "nox > Reusing existing virtual environment at .nox/lint.", + "nox > uv pip install 'ruff>=0.15,<0.16'", + "nox > ruff check src/ scripts/ examples/ features/ robot/", + "nox > Session lint was successful." + ], + "rawOutput": "nox > Running session lint\nnox > Reusing existing virtual environment at .nox/lint.\nnox > uv pip install 'ruff>=0.15,<0.16'\nnox > ruff check src/ scripts/ examples/ features/ robot/\nnox > Session lint was successful." + }, + { + "name": "Error Output", + "passed": false, + "output": [ + "nox > Running session lint", + "nox > Reusing existing virtual environment at .nox/lint.", + "nox > uv pip install 'ruff>=0.15,<0.16'", + "nox > ruff check src/ scripts/ examples/ features/ robot/", + "nox > Session lint was successful.", + "" + ], + "rawOutput": "nox > Running session lint\nnox > Reusing existing virtual environment at .nox/lint.\nnox > uv pip install 'ruff>=0.15,<0.16'\nnox > ruff check src/ scripts/ examples/ features/ robot/\nnox > Session lint was successful.\n" + } + ], + "summary": { + "total": 3, + "passed": 2, + "failed": 1 + }, + "rawOutput": "All checks passed!\n\nnox > Running session lint\nnox > Reusing existing virtual environment at .nox/lint.\nnox > uv pip install 'ruff>=0.15,<0.16'\nnox > ruff check src/ scripts/ examples/ features/ robot/\nnox > Session lint was successful." +} \ No newline at end of file