Coverage for little_loops / cli / sprint / _helpers.py: 4%
134 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-03-18 16:18 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-03-18 16:18 -0500
1"""Shared helpers for ll-sprint CLI subcommands."""
3from __future__ import annotations
5from typing import TYPE_CHECKING, Any
7from little_loops.cli.output import PRIORITY_COLOR, TYPE_COLOR, colorize, terminal_width
9if TYPE_CHECKING:
10 from little_loops.config import DependencyMappingConfig
11 from little_loops.dependency_graph import DependencyGraph, WaveContentionNote
12 from little_loops.logger import Logger
15def _render_execution_plan(
16 waves: list[list[Any]],
17 dep_graph: DependencyGraph,
18 contention_notes: list[WaveContentionNote | None] | None = None,
19) -> str:
20 """Render execution plan with wave groupings.
22 Args:
23 waves: List of execution waves from get_execution_waves()
24 dep_graph: DependencyGraph for looking up blockers
25 contention_notes: Optional per-wave contention annotations from
26 refine_waves_for_contention(). Same length as waves.
28 Returns:
29 Formatted string showing wave structure
30 """
31 if not waves:
32 return ""
34 # Build logical wave groups: consecutive sub-waves from the same
35 # parent_wave_index are grouped together.
36 logical_waves: list[list[int]] = [] # each entry is list of indices into waves
37 notes = contention_notes or [None] * len(waves)
39 for idx in range(len(waves)):
40 note = notes[idx] if idx < len(notes) else None
41 if note is not None:
42 # Check if this belongs to the same parent as the previous group
43 if logical_waves and notes[logical_waves[-1][0]] is not None:
44 prev_note = notes[logical_waves[-1][0]]
45 if prev_note and prev_note.parent_wave_index == note.parent_wave_index:
46 logical_waves[-1].append(idx)
47 continue
48 logical_waves.append([idx])
49 else:
50 logical_waves.append([idx])
52 total_issues = sum(len(wave) for wave in waves)
53 num_logical = len(logical_waves)
54 lines: list[str] = []
56 width = terminal_width()
57 lines.append("")
58 lines.append("=" * width)
59 wave_word = "wave" if num_logical == 1 else "waves"
60 lines.append(f"EXECUTION PLAN ({total_issues} issues, {num_logical} {wave_word})")
61 lines.append("=" * width)
63 for logical_idx, group in enumerate(logical_waves):
64 lines.append("")
65 logical_num = logical_idx + 1
66 group_issues = [issue for widx in group for issue in waves[widx]]
67 group_count = len(group_issues)
68 is_contention = len(group) > 1
70 if is_contention:
71 # Multiple sub-waves from overlap splitting
72 lines.append(
73 f"Wave {logical_num} ({group_count} issues, serialized \u2014 file overlap):"
74 )
75 step = 0
76 for widx in group:
77 for issue in waves[widx]:
78 step += 1
79 lines.append(f" Step {step}/{group_count}:")
81 # Truncate title if too long
82 title = issue.title
83 if len(title) > 45:
84 title = title[:42] + "..."
86 issue_type = issue.issue_id.split("-", 1)[0]
87 colored_id = colorize(issue.issue_id, TYPE_COLOR.get(issue_type, "0"))
88 colored_priority = colorize(
89 issue.priority, PRIORITY_COLOR.get(issue.priority, "0")
90 )
91 lines.append(
92 f" \u2514\u2500\u2500 {colored_id}: {title} ({colored_priority})"
93 )
95 # Show blockers for this issue
96 blockers = dep_graph.blocked_by.get(issue.issue_id, set())
97 if blockers:
98 blockers_str = ", ".join(sorted(blockers))
99 lines.append(f" blocked by: {blockers_str}")
101 # Show contended files once at the end of the group
102 first_note = notes[group[0]]
103 if first_note:
104 paths_str = ", ".join(first_note.contended_paths[:2])
105 extra = len(first_note.contended_paths) - 2
106 if extra > 0:
107 paths_str += f" +{extra} more"
108 lines.append(f" Contended files: {paths_str}")
109 else:
110 # Single wave (no overlap splitting)
111 widx = group[0]
112 wave = waves[widx]
114 if logical_num == 1:
115 parallel_note = "(parallel)" if len(wave) > 1 else ""
116 else:
117 parallel_note = f"(after Wave {logical_num - 1})"
118 if len(wave) > 1:
119 parallel_note += " parallel"
120 lines.append(f"Wave {logical_num} {parallel_note}:".strip())
122 for i, issue in enumerate(wave):
123 is_last = i == len(wave) - 1
124 prefix = " \u2514\u2500\u2500 " if is_last else " \u251c\u2500\u2500 "
126 # Truncate title if too long
127 title = issue.title
128 if len(title) > 45:
129 title = title[:42] + "..."
131 issue_type = issue.issue_id.split("-", 1)[0]
132 colored_id = colorize(issue.issue_id, TYPE_COLOR.get(issue_type, "0"))
133 colored_priority = colorize(issue.priority, PRIORITY_COLOR.get(issue.priority, "0"))
134 lines.append(f"{prefix}{colored_id}: {title} ({colored_priority})")
136 # Show blockers for this issue
137 blockers = dep_graph.blocked_by.get(issue.issue_id, set())
138 if blockers:
139 blocker_prefix = (
140 " \u2514\u2500\u2500 " if is_last else " \u2502 \u2514\u2500\u2500 "
141 )
142 blockers_str = ", ".join(sorted(blockers))
143 lines.append(f"{blocker_prefix}blocked by: {blockers_str}")
145 return "\n".join(lines)
148def _build_issue_contents(issue_infos: list) -> dict[str, str]:
149 """Build issue_id -> file content mapping for dependency analysis."""
150 return {info.issue_id: info.path.read_text() for info in issue_infos if info.path.exists()}
153def _render_dependency_analysis(
154 report: Any,
155 logger: Logger,
156 issue_to_wave: dict[str, int] | None = None,
157 *,
158 config: DependencyMappingConfig | None = None,
159) -> None:
160 """Display dependency analysis results in CLI format.
162 Args:
163 report: DependencyReport from analyze_dependencies()
164 logger: Logger instance
165 issue_to_wave: Optional mapping of issue_id -> wave index. When
166 provided, proposals where the target already runs before the
167 source in wave ordering are counted as "already handled".
168 config: Optional dependency mapping config for custom thresholds.
169 """
170 if not report.proposals and not report.validation.has_issues:
171 return
173 logger.header("Dependency Analysis", char="-", width=60)
175 if report.proposals:
176 # Partition proposals into novel vs already-satisfied
177 novel: list[Any] = []
178 satisfied_count = 0
179 for p in report.proposals:
180 if issue_to_wave is not None:
181 target_wave = issue_to_wave.get(p.target_id)
182 source_wave = issue_to_wave.get(p.source_id)
183 if (
184 target_wave is not None
185 and source_wave is not None
186 and target_wave < source_wave
187 ):
188 satisfied_count += 1
189 continue
190 novel.append(p)
192 if novel:
193 logger.warning(f"Found {len(novel)} potential missing dependency(ies):")
194 high_threshold = config.high_conflict_threshold if config else 0.7
195 conflict_threshold = config.conflict_threshold if config else 0.4
196 for p in novel:
197 if p.conflict_score >= high_threshold:
198 conflict = "HIGH"
199 elif p.conflict_score >= conflict_threshold:
200 conflict = "MEDIUM"
201 else:
202 conflict = "LOW"
203 logger.warning(
204 f" {p.source_id} may depend on {p.target_id} "
205 f"({conflict} conflict, {p.confidence:.0%} confidence)"
206 )
207 if p.overlapping_files:
208 files = ", ".join(p.overlapping_files[:3])
209 if len(p.overlapping_files) > 3:
210 files += " and more"
211 logger.info(f" Shared files: {files}")
213 if satisfied_count > 0:
214 total = len(report.proposals)
215 if not novel:
216 dep_word = "dependency" if total == 1 else "dependencies"
217 logger.info(f"All {total} potential {dep_word} already handled by wave ordering.")
218 else:
219 logger.info(f"({satisfied_count} additional already handled by wave ordering)")
221 if report.validation.has_issues:
222 v = report.validation
223 if v.broken_refs:
224 for issue_id, ref_id in v.broken_refs:
225 logger.warning(f" {issue_id}: references nonexistent {ref_id}")
226 if v.stale_completed_refs:
227 for issue_id, ref_id in v.stale_completed_refs:
228 logger.warning(f" {issue_id}: blocked by {ref_id} (completed)")
229 if v.missing_backlinks:
230 for issue_id, ref_id in v.missing_backlinks:
231 logger.warning(f" {issue_id} blocked by {ref_id}, but {ref_id} missing backlink")
233 logger.info("Run /ll:map-dependencies to apply discovered dependencies")
234 print() # blank line separator