"""Helper script for correction subtree isolation Robot Framework tests. Validates that CorrectionService correctly isolates correction scope: - excluded_decisions populated after impact analysis - rollback_tier_depth computation - dry-run report enhancements - subtree isolation validation """ from __future__ import annotations import sys from collections.abc import Callable from unittest.mock import patch from cleveragents.application.services.correction_service import CorrectionService from cleveragents.core.exceptions import ValidationError from cleveragents.domain.models.core.correction import CorrectionMode, CorrectionStatus # --------------------------------------------------------------------------- # Shared fixtures # --------------------------------------------------------------------------- _PLAN_ID = "01ROBOTSUBTREEPLAN0000000001" def _sample_tree() -> dict[str, list[str]]: return { "root": ["A", "B"], "A": ["C", "D"], "B": ["E"], } def _deep_tree(depth: int) -> dict[str, list[str]]: tree: dict[str, list[str]] = {} for i in range(depth): parent = f"depth_{i}" if i > 0 else "root" child = f"depth_{i + 1}" tree[parent] = [child] return tree # --------------------------------------------------------------------------- # Command handlers # --------------------------------------------------------------------------- def _leaf_isolation() -> None: """Target a leaf node and verify only it is affected.""" svc = CorrectionService() tree = _sample_tree() req = svc.request_correction(_PLAN_ID, "D", CorrectionMode.REVERT) impact = svc.analyze_impact(req.correction_id, tree) assert impact.affected_decisions == ["D"], ( f"Expected ['D'], got {impact.affected_decisions}" ) for excluded in ["root", "A", "B", "C", "E"]: assert excluded in impact.excluded_decisions, ( f"Expected '{excluded}' in excluded={impact.excluded_decisions}" ) print("leaf-isolation-ok") def _middle_node_isolation() -> None: """Target a middle node and verify subtree is affected.""" svc = CorrectionService() tree = _sample_tree() req = svc.request_correction(_PLAN_ID, "A", CorrectionMode.REVERT) impact = svc.analyze_impact(req.correction_id, tree) for affected in ["A", "C", "D"]: assert affected in impact.affected_decisions, ( f"Expected '{affected}' in affected={impact.affected_decisions}" ) for excluded in ["root", "B", "E"]: assert excluded in impact.excluded_decisions, ( f"Expected '{excluded}' in excluded={impact.excluded_decisions}" ) print("middle-node-isolation-ok") def _root_targets_all() -> None: """Target root and verify all decisions are affected.""" svc = CorrectionService() tree = _sample_tree() req = svc.request_correction(_PLAN_ID, "root", CorrectionMode.REVERT) impact = svc.analyze_impact(req.correction_id, tree) for node in ["root", "A", "B", "C", "D", "E"]: assert node in impact.affected_decisions, ( f"Expected '{node}' in affected={impact.affected_decisions}" ) assert len(impact.excluded_decisions) == 0, ( f"Expected empty excluded, got {impact.excluded_decisions}" ) assert impact.rollback_tier_depth == 0 print("root-targets-all-ok") def _rollback_tier_computation() -> None: """Verify rollback tier at various depths.""" svc = CorrectionService() tree = _sample_tree() assert svc.compute_rollback_tier("root", _PLAN_ID, tree) == 0 assert svc.compute_rollback_tier("A", _PLAN_ID, tree) == 1 assert svc.compute_rollback_tier("B", _PLAN_ID, tree) == 1 assert svc.compute_rollback_tier("C", _PLAN_ID, tree) == 2 assert svc.compute_rollback_tier("D", _PLAN_ID, tree) == 2 assert svc.compute_rollback_tier("E", _PLAN_ID, tree) == 2 # Deep tree test deep = _deep_tree(4) assert svc.compute_rollback_tier("depth_3", _PLAN_ID, deep) == 3 print("rollback-tier-computation-ok") def _dry_run_report_enhanced() -> None: """Verify dry-run report includes excluded decisions and tier depth via impact.""" svc = CorrectionService() tree = _sample_tree() req = svc.request_correction(_PLAN_ID, "A", CorrectionMode.REVERT) report = svc.generate_dry_run_report(req.correction_id, tree) assert report.impact.rollback_tier_depth == 1, ( f"Expected tier_depth=1, got {report.impact.rollback_tier_depth}" ) assert len(report.impact.excluded_decisions) > 0, ( "Expected non-empty excluded_decisions" ) for excluded in ["root", "B", "E"]: assert excluded in report.impact.excluded_decisions, ( f"Expected '{excluded}' in report " f"excluded={report.impact.excluded_decisions}" ) print("dry-run-report-enhanced-ok") def _dry_run_tier_zero_warning() -> None: """Verify tier-0 warning when root is targeted.""" svc = CorrectionService() tree = _sample_tree() req = svc.request_correction(_PLAN_ID, "root", CorrectionMode.REVERT) report = svc.generate_dry_run_report(req.correction_id, tree) all_warnings = " ".join(report.warnings) assert "Tier 0" in all_warnings, f"Expected 'Tier 0' in warnings: {report.warnings}" assert report.impact.rollback_tier_depth == 0 print("dry-run-tier-zero-warning-ok") def _subtree_isolation_validation() -> None: """Verify subtree isolation validation passes for non-root targets.""" svc = CorrectionService() tree = _sample_tree() assert svc.validate_subtree_isolation("A", tree) is True assert svc.validate_subtree_isolation("D", tree) is True assert svc.validate_subtree_isolation("E", tree) is True print("subtree-isolation-validation-ok") def _edge_case_empty_tree() -> None: """Verify edge cases with empty tree.""" svc = CorrectionService() # validate_subtree_isolation with empty tree assert svc.validate_subtree_isolation("X", {}) is True # _find_root with empty tree assert CorrectionService._find_root({}) is None # _find_parent for root node (not a child of anything) tree = _sample_tree() assert CorrectionService._find_parent("root", tree) is None # compute_rollback_tier for unknown node assert svc.compute_rollback_tier("UNKNOWN", _PLAN_ID, tree) == 0 print("edge-case-empty-tree-ok") def _influence_dag_traversal() -> None: """Verify influence edges pull extra decisions into affected set.""" svc = CorrectionService() tree = _sample_tree() # D -> E via influence DAG (E is a sibling-subtree node) req = svc.request_correction(_PLAN_ID, "D", CorrectionMode.REVERT) influence = {"D": ["E"]} impact = svc.analyze_impact(req.correction_id, tree, influence_edges=influence) assert "D" in impact.affected_decisions, ( f"Expected 'D' in affected={impact.affected_decisions}" ) assert "E" in impact.affected_decisions, ( f"Expected 'E' in affected (via influence)={impact.affected_decisions}" ) assert "root" in impact.excluded_decisions assert "A" in impact.excluded_decisions # Cycle detection: C -> D -> C via influence DAG svc2 = CorrectionService() req2 = svc2.request_correction(_PLAN_ID, "C", CorrectionMode.REVERT) cycle_influence = {"C": ["D"], "D": ["C"]} impact2 = svc2.analyze_impact( req2.correction_id, tree, influence_edges=cycle_influence ) assert "C" in impact2.affected_decisions assert "D" in impact2.affected_decisions # Isolation validation passes even when influence edges reach sibling assert svc.validate_subtree_isolation("D", tree, influence_edges=influence) is True print("influence-dag-traversal-ok") def _append_mode_correction() -> None: """Verify append mode spawns a child plan.""" svc = CorrectionService() req = svc.request_correction(_PLAN_ID, "A", CorrectionMode.APPEND) result = svc.execute_append(req.correction_id) assert result.status.value == "applied", ( f"Expected status='applied', got '{result.status.value}'" ) assert result.spawned_child_plan_id is not None, ( "Expected spawned_child_plan_id, got None" ) assert len(result.new_decisions) > 0, ( f"Expected new_decisions, got {result.new_decisions}" ) print("append-mode-correction-ok") def _dry_run_enforcement() -> None: """Verify that executing a dry-run correction raises ValidationError.""" svc = CorrectionService() # Revert dry-run should not be executable req_revert = svc.request_correction( _PLAN_ID, "A", CorrectionMode.REVERT, dry_run=True ) try: svc.execute_correction(req_revert.correction_id, _sample_tree()) raise AssertionError("Expected ValidationError for dry-run revert execution") except ValidationError as exc: assert "dry-run" in str(exc).lower(), ( f"Expected 'dry-run' in error message, got: {exc}" ) # Append dry-run should not be executable svc2 = CorrectionService() req_append = svc2.request_correction( _PLAN_ID, "A", CorrectionMode.APPEND, dry_run=True ) try: svc2.execute_correction(req_append.correction_id) raise AssertionError("Expected ValidationError for dry-run append execution") except ValidationError as exc: assert "dry-run" in str(exc).lower(), ( f"Expected 'dry-run' in error message, got: {exc}" ) print("dry-run-enforcement-ok") def _execute_revert_non_dry_run() -> None: """Verify execute_revert works end-to-end for a non-dry-run correction.""" svc = CorrectionService() tree = _sample_tree() req = svc.request_correction(_PLAN_ID, "A", CorrectionMode.REVERT) result = svc.execute_revert(req.correction_id, tree) assert result.status.value == "applied", ( f"Expected status='applied', got '{result.status.value}'" ) assert sorted(result.reverted_decisions) == sorted(["A", "C", "D"]), ( f"Expected reverted=['A','C','D'], got {result.reverted_decisions}" ) assert len(result.archived_artifacts) > 0, ( f"Expected non-empty archived_artifacts, got {result.archived_artifacts}" ) print("execute-revert-non-dry-run-ok") def _status_guard_enforcement() -> None: """Verify status guards prevent re-execution and cancelled execution.""" svc = CorrectionService() tree = _sample_tree() # Re-execution of applied correction req = svc.request_correction(_PLAN_ID, "A", CorrectionMode.REVERT) svc.execute_revert(req.correction_id, tree) try: svc.execute_correction(req.correction_id, tree) raise AssertionError("Expected ValidationError for re-execution") except ValidationError: pass # Execution of cancelled correction svc2 = CorrectionService() req2 = svc2.request_correction(_PLAN_ID, "B", CorrectionMode.REVERT) svc2.cancel_correction(req2.correction_id) try: svc2.execute_correction(req2.correction_id, tree) raise AssertionError("Expected ValidationError for cancelled execution") except ValidationError: pass print("status-guard-enforcement-ok") def _mode_mismatch_enforcement() -> None: """Verify mode mismatch raises ValidationError.""" svc = CorrectionService() # execute_revert on an append correction req_a = svc.request_correction(_PLAN_ID, "A", CorrectionMode.APPEND) try: svc.execute_revert(req_a.correction_id, _sample_tree()) raise AssertionError("Expected ValidationError for mode mismatch") except ValidationError as exc: assert "REVERT" in str(exc), f"Expected 'REVERT' in error, got: {exc}" # execute_append on a revert correction svc2 = CorrectionService() req_r = svc2.request_correction(_PLAN_ID, "A", CorrectionMode.REVERT) try: svc2.execute_append(req_r.correction_id) raise AssertionError("Expected ValidationError for mode mismatch") except ValidationError as exc: assert "APPEND" in str(exc), f"Expected 'APPEND' in error, got: {exc}" print("mode-mismatch-enforcement-ok") def _diamond_dag_topology() -> None: """Verify convergent (diamond) DAG topology does not duplicate visits.""" svc = CorrectionService() tree = _sample_tree() # Both C and D (children of A) influence E via DAG — diamond pattern req = svc.request_correction(_PLAN_ID, "A", CorrectionMode.REVERT) influence = {"C": ["E"], "D": ["E"]} impact = svc.analyze_impact(req.correction_id, tree, influence_edges=influence) assert "E" in impact.affected_decisions, ( f"Expected 'E' in affected={impact.affected_decisions}" ) count = impact.affected_decisions.count("E") assert count == 1, ( f"Expected 'E' exactly once, found {count} times: {impact.affected_decisions}" ) print("diamond-dag-topology-ok") def _dry_run_exception_recovery() -> None: """Verify dry-run report restores status when analyze_impact fails.""" svc = CorrectionService() tree = _sample_tree() req = svc.request_correction(_PLAN_ID, "A", CorrectionMode.REVERT) assert req.status == CorrectionStatus.PENDING, ( f"Expected PENDING before dry-run, got {req.status}" ) try: with patch.object( CorrectionService, "_compute_affected_subtree", side_effect=RuntimeError("simulated BFS failure"), ): svc.generate_dry_run_report(req.correction_id, tree) except RuntimeError: pass # Expected — exception propagates from analyze_impact assert req.status == CorrectionStatus.PENDING, ( f"Expected PENDING after failed dry-run, got {req.status}" ) print("dry-run-exception-recovery-ok") def _execute_revert_with_influence() -> None: """Verify execute_revert works with influence edges.""" svc = CorrectionService() tree = _sample_tree() # D influences E via DAG — revert of A should include E influence = {"D": ["E"]} req = svc.request_correction(_PLAN_ID, "A", CorrectionMode.REVERT) result = svc.execute_revert(req.correction_id, tree, influence_edges=influence) assert result.status.value == "applied", ( f"Expected status='applied', got '{result.status.value}'" ) assert "E" in result.reverted_decisions, ( f"Expected 'E' in reverted={result.reverted_decisions}" ) print("execute-revert-with-influence-ok") def _dag_only_nodes_excluded() -> None: """Verify nodes in the DAG (not in tree) appear in excluded set.""" svc = CorrectionService() tree = _sample_tree() # X_EXT and Y_EXT exist only in the influence DAG, not in the tree influence = {"X_EXT": ["Y_EXT"]} req = svc.request_correction(_PLAN_ID, "D", CorrectionMode.REVERT) impact = svc.analyze_impact(req.correction_id, tree, influence_edges=influence) assert "X_EXT" in impact.excluded_decisions, ( f"Expected 'X_EXT' in excluded={impact.excluded_decisions}" ) assert "Y_EXT" in impact.excluded_decisions, ( f"Expected 'Y_EXT' in excluded={impact.excluded_decisions}" ) print("dag-only-nodes-excluded-ok") # --------------------------------------------------------------------------- # Dispatch # --------------------------------------------------------------------------- COMMANDS: dict[str, Callable[[], None]] = { "leaf-isolation": _leaf_isolation, "middle-node-isolation": _middle_node_isolation, "root-targets-all": _root_targets_all, "rollback-tier-computation": _rollback_tier_computation, "dry-run-report-enhanced": _dry_run_report_enhanced, "dry-run-tier-zero-warning": _dry_run_tier_zero_warning, "subtree-isolation-validation": _subtree_isolation_validation, "edge-case-empty-tree": _edge_case_empty_tree, "influence-dag-traversal": _influence_dag_traversal, "append-mode-correction": _append_mode_correction, "dry-run-enforcement": _dry_run_enforcement, "execute-revert-non-dry-run": _execute_revert_non_dry_run, "status-guard-enforcement": _status_guard_enforcement, "mode-mismatch-enforcement": _mode_mismatch_enforcement, "diamond-dag-topology": _diamond_dag_topology, "dry-run-exception-recovery": _dry_run_exception_recovery, "execute-revert-with-influence": _execute_revert_with_influence, "dag-only-nodes-excluded": _dag_only_nodes_excluded, } def main() -> None: """Dispatch command from sys.argv.""" if len(sys.argv) < 2: raise SystemExit("Expected command argument") command = sys.argv[1] if command not in COMMANDS: raise SystemExit(f"Unknown command: {command}") func = COMMANDS[command] func() if __name__ == "__main__": main()