diff --git a/features/invariant_cli_new_coverage.feature b/features/invariant_cli_new_coverage.feature index 991f48519..3e4728257 100644 --- a/features/invariant_cli_new_coverage.feature +++ b/features/invariant_cli_new_coverage.feature @@ -25,9 +25,10 @@ Feature: Invariant CLI commands coverage Then the resolved invariant scope should be "action" And the resolved invariant source name should be "deploy-service" - Scenario: Resolve scope with no flags raises BadParameter + Scenario: Resolve scope with no flags defaults to GLOBAL scope When I resolve invariant scope with no flags - Then a BadParameter error should be raised for invariant scope + Then the resolved invariant scope should be "global" + And the resolved invariant source name should be "system" Scenario: Resolve scope with conflicting flags raises BadParameter When I resolve invariant scope with global and project flags @@ -67,8 +68,8 @@ Feature: Invariant CLI commands coverage Scenario: Add invariant without scope flag via CLI Given a mocked InvariantService for invariant CLI add When I invoke invariant add without scope flags and text "Missing scope flag" - Then the invariant CLI exit code should be non-zero - And the invariant CLI output should contain "Exactly one scope flag is required" + Then the invariant CLI exit code should be 0 + And the invariant CLI output should contain "Invariant added" Scenario: Add invariant with --format json via CLI Given a mocked InvariantService for invariant CLI add @@ -145,6 +146,17 @@ Feature: Invariant CLI commands coverage Then the invariant CLI exit code should be 0 And the invariant CLI output should contain "Never delete prod" + Scenario: List invariants with no flags passes no scope filter + Given a mocked InvariantService that returns two invariants for list + When I invoke invariant list with no filters + Then the invariant CLI exit code should be 0 + And the invariant service list was called with no scope filter + + Scenario: List invariants with conflicting scope flags rejected + Given a mocked InvariantService that returns two invariants for list + When I invoke invariant list with flags "--global --project myapp" + Then the invariant CLI exit code should be non-zero + # === invariant remove command === Scenario: Remove invariant with --yes flag diff --git a/features/steps/invariant_cli_new_coverage_steps.py b/features/steps/invariant_cli_new_coverage_steps.py index 9bb3afa18..1fe6d06b1 100644 --- a/features/steps/invariant_cli_new_coverage_steps.py +++ b/features/steps/invariant_cli_new_coverage_steps.py @@ -91,11 +91,9 @@ def step_resolve_scope_action(context, action): @when("I resolve invariant scope with no flags") def step_resolve_scope_default(context): - context.inv_bad_parameter_raised = False - try: - _resolve_scope(is_global=False, project=None, plan=None, action=None) - except typer.BadParameter: - context.inv_bad_parameter_raised = True + context.resolved_scope, context.resolved_source = _resolve_scope( + is_global=False, project=None, plan=None, action=None + ) @when("I resolve invariant scope with global and project flags") @@ -334,6 +332,19 @@ def step_check_list_effective(context): assert kwargs.get("effective") is True +@then("the invariant service list was called with no scope filter") +def step_check_list_no_scope(context): + call_kwargs = context.inv_mock_svc.list_invariants.call_args + assert call_kwargs is not None, "list_invariants was never called" + kwargs = call_kwargs[1] if call_kwargs[1] else {} + assert kwargs.get("scope") is None, ( + f"Expected scope=None (all scopes), got {kwargs.get('scope')!r}" + ) + assert kwargs.get("source_name") is None, ( + f"Expected source_name=None, got {kwargs.get('source_name')!r}" + ) + + # ================================================================ # invariant remove command steps # ================================================================ diff --git a/robot/helper_invariant_cli.py b/robot/helper_invariant_cli.py index e564b06de..4b730834b 100644 --- a/robot/helper_invariant_cli.py +++ b/robot/helper_invariant_cli.py @@ -144,11 +144,9 @@ def add_no_scope() -> None: svc = _fresh_service() with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc): result = runner.invoke(invariant_app, ["add", "Missing scope invariant"]) - output = result.stdout or "" - if result.stderr: - output += result.stderr - if result.exit_code != 0 and "Exactly one scope flag is required" in output: - print("invariant-add-no-scope-ok") + output = result.output or "" + if result.exit_code == 0 and "Invariant added" in output: + print("invariant-add-no-scope-global-ok") else: print(f"FAIL: exit={result.exit_code} out={result.stdout}") sys.exit(1) diff --git a/robot/invariant_cli.robot b/robot/invariant_cli.robot index 2ef61fc10..a53e0c1b0 100644 --- a/robot/invariant_cli.robot +++ b/robot/invariant_cli.robot @@ -58,11 +58,11 @@ Invariant Scope Conflict Rejected Should Be Equal As Integers ${result.rc} 0 Should Contain ${result.stdout} invariant-scope-conflict-ok -Invariant Add Missing Scope Rejected - [Documentation] Verify that ``invariant add`` fails when no scope flag is provided +Invariant Add Missing Scope Defaults Global + [Documentation] Verify that ``invariant add`` defaults to global when no scope flag is provided ${result}= Run Process ${PYTHON} ${HELPER} add-no-scope cwd=${WORKSPACE} Should Be Equal As Integers ${result.rc} 0 - Should Contain ${result.stdout} invariant-add-no-scope-ok + Should Contain ${result.stdout} invariant-add-no-scope-global-ok Invariant List JSON Format [Documentation] Verify that ``invariant list --format json`` outputs JSON diff --git a/src/cleveragents/cli/commands/invariant.py b/src/cleveragents/cli/commands/invariant.py index f0c2541be..39203ced2 100644 --- a/src/cleveragents/cli/commands/invariant.py +++ b/src/cleveragents/cli/commands/invariant.py @@ -20,7 +20,7 @@ Each invariant belongs to exactly one scope. Pass the matching flag: - ``--action ACTION``: Action-template invariant - ``--plan PLAN_ID``: Plan-specific invariant -Exactly one scope flag must be provided; commands error when omitted. +If no scope flag is given, ``--global`` is assumed. ## Examples @@ -81,18 +81,15 @@ def _resolve_scope( flags_set = sum( [is_global, project is not None, plan is not None, action is not None] ) - if flags_set == 0: - raise typer.BadParameter( - "Exactly one scope flag is required: " - "--global, --project, --plan, or --action" - ) if flags_set > 1: raise typer.BadParameter( - "Specify only one scope flag: --global, --project, --plan, or --action" + "Specify at most one scope flag: --global, --project, --plan, or --action" ) + # Explicit global check (handles the case where --global is set) if is_global: return InvariantScope.GLOBAL, "system" + if project is not None: return InvariantScope.PROJECT, project if plan is not None: @@ -100,10 +97,8 @@ def _resolve_scope( if action is not None: return InvariantScope.ACTION, action - # This line is unreachable because flags_set == 0 already raises BadParameter. - raise typer.BadParameter( - "Exactly one scope flag is required: --global, --project, --plan, or --action" - ) + # Default to global when no scope flag is provided + return InvariantScope.GLOBAL, "system" def _invariant_dict(inv: Invariant) -> dict[str, object]: @@ -190,9 +185,20 @@ def list_invariants( try: service = _get_service() + # Mutual-exclusion validation (shared semantics with _resolve_scope). + # Unlike `add`, omitting all flags means "list ALL invariants" + # (scope=None, source_name=None) rather than defaulting to GLOBAL. + flags_set = sum( + [is_global, project is not None, plan is not None, action is not None] + ) + if flags_set > 1: + raise typer.BadParameter( + "Specify at most one scope flag: " + "--global, --project, --plan, or --action" + ) + scope: InvariantScope | None = None source_name: str | None = None - if is_global: scope = InvariantScope.GLOBAL elif project is not None: