"""Step implementations for large_project_decomposition.feature. Tests hierarchical decomposition, clustering heuristics, dependency closure, DAG ordering, cycle detection, fallback, threshold guard, config validation, metrics, decision recording, and deterministic ordering. """ from __future__ import annotations import tempfile from typing import Any from unittest.mock import MagicMock from behave import given, then, when # type: ignore[import-untyped] from cleveragents.application.services.decomposition_graph import ( DependencyClosureComputer, ) from cleveragents.application.services.decomposition_models import ( DecompositionConfig, ) from cleveragents.application.services.decomposition_service import ( DecompositionService, ) from features.steps.decomposition_test_helpers import ( chain_graph, cycle_graph, diamond_graph, make_dir_files, make_files, make_lang_files, make_same_dir_files, ) # --------------------------------------------------------------------------- # Background # --------------------------------------------------------------------------- @given("a decomposition service") def step_given_decomposition_service(context: Any) -> None: context.svc = DecompositionService() context.tmpdir = tempfile.mkdtemp(prefix="decompose-") context.result = None context.error = None context.graph = None context.closure = None context.topo_order = None context.cycles = None context.files = None # --------------------------------------------------------------------------- # Projects # --------------------------------------------------------------------------- @given("a project with {count:d} files across {levels:d} directory levels") def step_given_multi_level_project(context: Any, count: int, levels: int) -> None: context.files = make_files(context.tmpdir, count, levels) @given("a project with {count:d} files in a single directory") def step_given_single_dir_project(context: Any, count: int) -> None: context.files = make_same_dir_files(context.tmpdir, count) @given("a project with files totalling {tokens:d} estimated tokens") def step_given_token_project(context: Any, tokens: int) -> None: file_count = tokens // 50 context.files = make_files(context.tmpdir, file_count, 3) @given("a project with {py:d} Python files and {ts:d} TypeScript files") def step_given_lang_project(context: Any, py: int, ts: int) -> None: context.files = make_lang_files(context.tmpdir, py, ts) @given("a project with files in src/api and src/web directories") def step_given_dir_project(context: Any) -> None: context.files = make_dir_files(context.tmpdir) @given("a project with {count:d} files all in the same directory with same extension") def step_given_same_dir_same_ext(context: Any, count: int) -> None: context.files = make_same_dir_files(context.tmpdir, count) @given("a project with {count:d} files") def step_given_small_project(context: Any, count: int) -> None: context.files = make_files(context.tmpdir, count, 1) # --------------------------------------------------------------------------- # Dependency graphs # --------------------------------------------------------------------------- @given("a dependency graph with chain A -> B -> C -> D") def step_given_chain_graph(context: Any) -> None: context.graph = chain_graph("A", "B", "C", "D") @given("a dependency graph with edges A->B, A->C, B->D, C->D") def step_given_diamond_graph(context: Any) -> None: context.graph = diamond_graph() @given("a dependency graph with cycle A -> B -> C -> A") def step_given_cycle_graph(context: Any) -> None: context.graph = cycle_graph() # --------------------------------------------------------------------------- # Config # --------------------------------------------------------------------------- @given("default decomposition config") def step_given_default_config(context: Any) -> None: context.decomp_config = DecompositionConfig() @given("a decomposition service with a mock decision service") def step_given_svc_with_mock_ds(context: Any) -> None: context.mock_ds = MagicMock() context.svc = DecompositionService(decision_service=context.mock_ds) context.tmpdir = tempfile.mkdtemp(prefix="decompose-") @given("two identical file sets presented in different order") def step_given_two_file_sets(context: Any) -> None: context.files = make_files(context.tmpdir, 200, 3) context.files_reversed = list(reversed(context.files)) # --------------------------------------------------------------------------- # When — decompose # --------------------------------------------------------------------------- @when("I decompose with max_depth {depth:d}") def step_when_decompose_max_depth(context: Any, depth: int) -> None: cfg = DecompositionConfig(max_depth=depth, max_files_per_subplan=50) context.result = context.svc.decompose(context.files, cfg) @when("I decompose with max_files_per_subplan {n:d}") def step_when_decompose_max_files(context: Any, n: int) -> None: cfg = DecompositionConfig(max_files_per_subplan=n) context.result = context.svc.decompose(context.files, cfg) @when("I decompose with max_tokens_per_subplan {n:d}") def step_when_decompose_max_tokens(context: Any, n: int) -> None: cfg = DecompositionConfig(max_tokens_per_subplan=n) context.result = context.svc.decompose(context.files, cfg) @when("I decompose with language clustering") def step_when_decompose_language(context: Any) -> None: cfg = DecompositionConfig(max_files_per_subplan=80) context.result = context.svc.decompose(context.files, cfg) @when("I decompose with directory clustering") def step_when_decompose_directory(context: Any) -> None: cfg = DecompositionConfig(max_files_per_subplan=30) context.result = context.svc.decompose(context.files, cfg) @when("I decompose with min_files_per_subplan {n:d}") def step_when_decompose_min_files(context: Any, n: int) -> None: cfg = DecompositionConfig(min_files_per_subplan=n) context.result = context.svc.decompose(context.files, cfg) @when("I decompose with default config") def step_when_decompose_default(context: Any) -> None: context.result = context.svc.decompose(context.files) @when('I decompose and record decisions for plan "{plan_id}"') def step_when_decompose_and_record(context: Any, plan_id: str) -> None: context.result = context.svc.decompose(context.files) context.svc.record_decisions(plan_id, context.result) @when("I decompose both") def step_when_decompose_both(context: Any) -> None: svc = DecompositionService() context.result_a = svc.decompose(context.files) svc2 = DecompositionService() context.result_b = svc2.decompose(context.files_reversed) # --------------------------------------------------------------------------- # When — dependency ops # --------------------------------------------------------------------------- @when("I compute closure from A with no cutoff") def step_when_closure_no_cutoff(context: Any) -> None: computer = DependencyClosureComputer() context.closure = computer.compute_closure(context.graph, ["A"]) @when("I compute closure from A with cutoff {n:d}") def step_when_closure_cutoff(context: Any, n: int) -> None: computer = DependencyClosureComputer() context.closure = computer.compute_closure(context.graph, ["A"], cutoff=n) @when("I compute the topological sort") def step_when_topo_sort(context: Any) -> None: computer = DependencyClosureComputer() context.topo_order = computer.topological_sort(context.graph) @when("I check for cycles") def step_when_detect_cycles(context: Any) -> None: computer = DependencyClosureComputer() context.cycles = computer.detect_cycles(context.graph) @when("I compute closure from A twice") def step_when_closure_twice(context: Any) -> None: computer = DependencyClosureComputer() context.closure_1 = computer.compute_closure(context.graph, ["A"]) context.closure_2 = computer.compute_closure(context.graph, ["A"]) # --------------------------------------------------------------------------- # When — config # --------------------------------------------------------------------------- @when("I create a config with max_depth {n:d}") def step_when_create_bad_config(context: Any, n: int) -> None: try: DecompositionConfig(max_depth=n) except ValueError as exc: context.error = exc # --------------------------------------------------------------------------- # Then — decomposition assertions # --------------------------------------------------------------------------- @then("the decomposition result should have max_depth_reached >= {n:d}") def step_then_max_depth_ge(context: Any, n: int) -> None: assert context.result is not None assert context.result.max_depth_reached >= n, ( f"expected >= {n}, got {context.result.max_depth_reached}" ) @then("the decomposition result should have max_depth_reached <= {n:d}") def step_then_max_depth_le(context: Any, n: int) -> None: assert context.result is not None assert context.result.max_depth_reached <= n @then("every leaf node should have at most {n:d} files") def step_then_leaf_files_le(context: Any, n: int) -> None: assert context.result is not None for node in context.result.nodes: if node.is_leaf: assert len(node.file_paths) <= n, ( f"leaf {node.node_id} has {len(node.file_paths)} files" ) @then("every leaf node should have estimated tokens at most {n:d}") def step_then_leaf_tokens_le(context: Any, n: int) -> None: assert context.result is not None for node in context.result.nodes: if node.is_leaf: assert node.estimated_tokens <= n, ( f"leaf {node.node_id} has {node.estimated_tokens} tokens" ) @then("at least two clusters should have different dominant languages") def step_then_diff_languages(context: Any) -> None: assert context.result is not None langs = {n.language for n in context.result.nodes if n.is_leaf} assert len(langs) >= 2, f"only {langs}" @then("at least two clusters should have different directory prefixes") def step_then_diff_dirs(context: Any) -> None: assert context.result is not None prefixes = {n.directory_prefix for n in context.result.nodes if n.is_leaf} assert len(prefixes) >= 2, f"only {prefixes}" @then("the decomposition should produce a single root node") def step_then_single_root(context: Any) -> None: assert context.result is not None roots = [n for n in context.result.nodes if n.parent_id is None] assert len(roots) >= 1 @then("the metrics should contain skipped equal to {n:d}") def step_then_metrics_skipped(context: Any, n: int) -> None: assert context.result is not None assert context.result.metrics.get("skipped") == n # --------------------------------------------------------------------------- # Then — closure / topo / cycle # --------------------------------------------------------------------------- @then("the closure should contain A, B, C, D") def step_then_closure_abcd(context: Any) -> None: assert context.closure is not None assert {"A", "B", "C", "D"} <= context.closure @then("the closure should contain at most {n:d} files") def step_then_closure_le(context: Any, n: int) -> None: assert context.closure is not None assert len(context.closure) <= n @then("A should appear before B and C") def step_then_a_before_bc(context: Any) -> None: order = context.topo_order assert order.index("A") < order.index("B") assert order.index("A") < order.index("C") @then("B and C should appear before D") def step_then_bc_before_d(context: Any) -> None: order = context.topo_order assert order.index("B") < order.index("D") assert order.index("C") < order.index("D") @then("at least one cycle should be detected") def step_then_cycles_detected(context: Any) -> None: assert context.cycles is not None assert len(context.cycles) >= 1 @then("no cycles should be detected") def step_then_no_cycles(context: Any) -> None: assert context.cycles is not None assert len(context.cycles) == 0 # --------------------------------------------------------------------------- # Then — config # --------------------------------------------------------------------------- @then("max_depth should be {n:d}") def step_then_cfg_max_depth(context: Any, n: int) -> None: assert context.decomp_config.max_depth == n @then("max_files_per_subplan should be {n:d}") def step_then_cfg_max_files(context: Any, n: int) -> None: assert context.decomp_config.max_files_per_subplan == n @then("max_tokens_per_subplan should be {n:d}") def step_then_cfg_max_tokens(context: Any, n: int) -> None: assert context.decomp_config.max_tokens_per_subplan == n @then("min_files_per_subplan should be {n:d}") def step_then_cfg_min_files(context: Any, n: int) -> None: assert context.decomp_config.min_files_per_subplan == n @then("a decomposition ValueError should be raised") def step_then_decomposition_value_error(context: Any) -> None: assert context.error is not None assert isinstance(context.error, ValueError) # --------------------------------------------------------------------------- # Then — metrics # --------------------------------------------------------------------------- @then("metrics should contain total_nodes") def step_then_metrics_total(context: Any) -> None: assert "total_nodes" in context.result.metrics @then("metrics should contain leaf_nodes") def step_then_metrics_leaves(context: Any) -> None: assert "leaf_nodes" in context.result.metrics @then("metrics should contain max_depth") def step_then_metrics_depth(context: Any) -> None: assert "max_depth" in context.result.metrics # --------------------------------------------------------------------------- # Then — decision recording # --------------------------------------------------------------------------- @then("at least one strategy_choice decision should be recorded") def step_then_strategy_choice(context: Any) -> None: calls = context.mock_ds.record_decision.call_args_list types = [ str(c.kwargs.get("decision_type", c.args[1] if len(c.args) > 1 else "")) for c in calls ] assert any("strategy_choice" in t for t in types) @then("at least one subplan_spawn decision should be recorded") def step_then_subplan_spawn(context: Any) -> None: calls = context.mock_ds.record_decision.call_args_list types = [ str(c.kwargs.get("decision_type", c.args[1] if len(c.args) > 1 else "")) for c in calls ] assert any("subplan_spawn" in t for t in types) # --------------------------------------------------------------------------- # Then — deterministic # --------------------------------------------------------------------------- @then("both decomposition results should have identical node ordering") def step_then_identical_ordering(context: Any) -> None: ids_a = [n.node_id for n in context.result_a.nodes] ids_b = [n.node_id for n in context.result_b.nodes] assert len(ids_a) == len(ids_b) # --------------------------------------------------------------------------- # Then — memoization # --------------------------------------------------------------------------- @then("the second computation should return the same result") def step_then_same_result(context: Any) -> None: assert context.closure_1 == context.closure_2