Coverage for little_loops / cli / sprint / _helpers.py: 4%
166 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-15 17:30 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-15 17:30 -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 _score_suffix(issue: Any) -> str:
16 """Build an inline score suffix like ' [ready: 85, conf: 72]'."""
17 cs = getattr(issue, "confidence_score", None)
18 oc = getattr(issue, "outcome_confidence", None)
19 if cs is None and oc is None:
20 return ""
21 parts: list[str] = []
22 if cs is not None:
23 parts.append(f"ready: {cs}")
24 if oc is not None:
25 parts.append(f"conf: {oc}")
26 return f" [{', '.join(parts)}]"
29def _render_execution_plan(
30 waves: list[list[Any]],
31 dep_graph: DependencyGraph,
32 contention_notes: list[WaveContentionNote | None] | None = None,
33 *,
34 config: DependencyMappingConfig | None = None,
35) -> str:
36 """Render execution plan with wave groupings.
38 Args:
39 waves: List of execution waves from get_execution_waves()
40 dep_graph: DependencyGraph for looking up blockers
41 contention_notes: Optional per-wave contention annotations from
42 refine_waves_for_contention(). Same length as waves.
43 config: Optional dependency mapping config for surfacing effective thresholds.
45 Returns:
46 Formatted string showing wave structure
47 """
48 if not waves:
49 return ""
51 # Build logical wave groups: consecutive sub-waves from the same
52 # parent_wave_index are grouped together.
53 logical_waves: list[list[int]] = [] # each entry is list of indices into waves
54 notes = contention_notes or [None] * len(waves)
56 for idx in range(len(waves)):
57 note = notes[idx] if idx < len(notes) else None
58 if note is not None:
59 # Check if this belongs to the same parent as the previous group
60 if logical_waves and notes[logical_waves[-1][0]] is not None:
61 prev_note = notes[logical_waves[-1][0]]
62 if prev_note and prev_note.parent_wave_index == note.parent_wave_index:
63 logical_waves[-1].append(idx)
64 continue
65 logical_waves.append([idx])
66 else:
67 logical_waves.append([idx])
69 total_issues = sum(len(wave) for wave in waves)
70 num_logical = len(logical_waves)
71 lines: list[str] = []
73 width = terminal_width()
74 wave_word = "wave" if num_logical == 1 else "waves"
75 header_text = f"Execution Plan ({total_issues} issues, {num_logical} {wave_word})"
76 fill = "\u2500" * max(0, width - len(header_text) - 4)
77 lines.append("")
78 lines.append(f"\u2500\u2500 {header_text} {fill}")
80 max_title = max(45, width - 30)
82 for logical_idx, group in enumerate(logical_waves):
83 lines.append("")
84 logical_num = logical_idx + 1
85 group_issues = [issue for widx in group for issue in waves[widx]]
86 group_count = len(group_issues)
87 is_contention = len(group) > 1
89 if is_contention:
90 # Multiple sub-waves from overlap splitting
91 first_note = notes[group[0]]
92 has_unknown = first_note is not None and getattr(first_note, "has_unknown_hints", False)
93 has_file_overlap = first_note is not None and bool(first_note.contended_paths)
95 if has_unknown and not has_file_overlap:
96 split_label = "unknown file hints"
97 elif has_unknown:
98 threshold_suffix = (
99 f" [min_files={config.overlap_min_files}, ratio={config.overlap_min_ratio}]"
100 if config
101 else ""
102 )
103 split_label = f"file overlap + unknown hints{threshold_suffix}"
104 else:
105 threshold_suffix = (
106 f" [min_files={config.overlap_min_files}, ratio={config.overlap_min_ratio}]"
107 if config
108 else ""
109 )
110 split_label = f"file overlap{threshold_suffix}"
112 lines.append(
113 f"Wave {logical_num} ({group_count} issues, serialized \u2014 {split_label}):"
114 )
115 step = 0
116 for widx in group:
117 for issue in waves[widx]:
118 step += 1
119 lines.append(f" Step {step}/{group_count}:")
121 # Truncate title if too long
122 title = issue.title
123 if len(title) > max_title:
124 title = title[: max_title - 3] + "..."
126 issue_type = issue.issue_id.split("-", 1)[0]
127 colored_id = colorize(issue.issue_id, TYPE_COLOR.get(issue_type, "0"))
128 colored_priority = colorize(
129 issue.priority, PRIORITY_COLOR.get(issue.priority, "0")
130 )
131 score_suffix = _score_suffix(issue)
132 lines.append(
133 f" \u2514\u2500\u2500 {colored_id}: {title} ({colored_priority}){score_suffix}"
134 )
135 if hasattr(issue, "path") and issue.path:
136 lines.append(f" {issue.path}")
138 # Show blockers for this issue
139 blockers = dep_graph.blocked_by.get(issue.issue_id, set())
140 if blockers:
141 blockers_str = ", ".join(sorted(blockers))
142 lines.append(f" blocked by: {blockers_str}")
144 # Show contended files or actionable hint at the end of the group
145 if first_note and first_note.contended_paths:
146 paths_str = ", ".join(first_note.contended_paths[:2])
147 extra = len(first_note.contended_paths) - 2
148 if extra > 0:
149 paths_str += f" +{extra} more"
150 lines.append(f" Contended files: {paths_str}")
151 if config is not None:
152 lines.append(
153 " Tune: dependency_mapping.overlap_min_files / overlap_min_ratio in ll-config.json"
154 )
155 if has_unknown:
156 lines.append(
157 " Tip: add '### Files to Modify' sections to enable precise overlap detection"
158 )
159 else:
160 # Single wave (no overlap splitting)
161 widx = group[0]
162 wave = waves[widx]
164 if logical_num == 1:
165 parallel_note = "(parallel)" if len(wave) > 1 else ""
166 else:
167 parallel_note = f"(after Wave {logical_num - 1})"
168 if len(wave) > 1:
169 parallel_note += " parallel"
170 lines.append(f"Wave {logical_num} {parallel_note}:".strip())
172 for i, issue in enumerate(wave):
173 is_last = i == len(wave) - 1
174 prefix = " \u2514\u2500\u2500 " if is_last else " \u251c\u2500\u2500 "
176 # Truncate title if too long
177 title = issue.title
178 if len(title) > max_title:
179 title = title[: max_title - 3] + "..."
181 issue_type = issue.issue_id.split("-", 1)[0]
182 colored_id = colorize(issue.issue_id, TYPE_COLOR.get(issue_type, "0"))
183 colored_priority = colorize(issue.priority, PRIORITY_COLOR.get(issue.priority, "0"))
184 score_suffix = _score_suffix(issue)
185 lines.append(f"{prefix}{colored_id}: {title} ({colored_priority}){score_suffix}")
187 # Show file path
188 path_indent = " " if is_last else " \u2502 "
189 if hasattr(issue, "path") and issue.path:
190 lines.append(f"{path_indent}{issue.path}")
192 # Show blockers for this issue
193 blockers = dep_graph.blocked_by.get(issue.issue_id, set())
194 if blockers:
195 blocker_prefix = (
196 " \u2514\u2500\u2500 " if is_last else " \u2502 \u2514\u2500\u2500 "
197 )
198 blockers_str = ", ".join(sorted(blockers))
199 lines.append(f"{blocker_prefix}blocked by: {blockers_str}")
201 return "\n".join(lines)
204def _build_issue_contents(issue_infos: list) -> dict[str, str]:
205 """Build issue_id -> file content mapping for dependency analysis."""
206 return {info.issue_id: info.path.read_text() for info in issue_infos if info.path.exists()}
209def _render_dependency_analysis(
210 report: Any,
211 logger: Logger,
212 issue_to_wave: dict[str, int] | None = None,
213 *,
214 config: DependencyMappingConfig | None = None,
215) -> None:
216 """Display dependency analysis results in CLI format.
218 Args:
219 report: DependencyReport from analyze_dependencies()
220 logger: Logger instance
221 issue_to_wave: Optional mapping of issue_id -> wave index. When
222 provided, proposals where the target already runs before the
223 source in wave ordering are counted as "already handled".
224 config: Optional dependency mapping config for custom thresholds.
225 """
226 if not report.proposals and not report.validation.has_issues:
227 return
229 logger.header("Dependency Analysis", char="-", width=60)
231 if report.proposals:
232 # Partition proposals into novel vs already-satisfied
233 novel: list[Any] = []
234 satisfied_count = 0
235 for p in report.proposals:
236 if issue_to_wave is not None:
237 target_wave = issue_to_wave.get(p.target_id)
238 source_wave = issue_to_wave.get(p.source_id)
239 if (
240 target_wave is not None
241 and source_wave is not None
242 and target_wave < source_wave
243 ):
244 satisfied_count += 1
245 continue
246 novel.append(p)
248 if novel:
249 logger.warning(f"Found {len(novel)} potential missing dependency(ies):")
250 high_threshold = config.high_conflict_threshold if config else 0.7
251 conflict_threshold = config.conflict_threshold if config else 0.4
252 for p in novel:
253 if p.conflict_score >= high_threshold:
254 conflict = "HIGH"
255 elif p.conflict_score >= conflict_threshold:
256 conflict = "MEDIUM"
257 else:
258 conflict = "LOW"
259 logger.warning(
260 f" {p.source_id} may depend on {p.target_id} "
261 f"({conflict} conflict, {p.confidence:.0%} confidence)"
262 )
263 if p.overlapping_files:
264 files = ", ".join(p.overlapping_files[:3])
265 if len(p.overlapping_files) > 3:
266 files += " and more"
267 logger.info(f" Shared files: {files}")
269 if satisfied_count > 0:
270 total = len(report.proposals)
271 if not novel:
272 dep_word = "dependency" if total == 1 else "dependencies"
273 logger.info(f"All {total} potential {dep_word} already handled by wave ordering.")
274 else:
275 logger.info(f"({satisfied_count} additional already handled by wave ordering)")
277 if report.validation.has_issues:
278 v = report.validation
279 if v.broken_refs:
280 for issue_id, ref_id in v.broken_refs:
281 logger.warning(f" {issue_id}: references nonexistent {ref_id}")
282 if v.stale_completed_refs:
283 for issue_id, ref_id in v.stale_completed_refs:
284 logger.warning(f" {issue_id}: blocked by {ref_id} (completed)")
285 if v.missing_backlinks:
286 for issue_id, ref_id in v.missing_backlinks:
287 logger.warning(f" {issue_id} blocked by {ref_id}, but {ref_id} missing backlink")
289 logger.info("Run /ll:map-dependencies to apply discovered dependencies")
290 print() # blank line separator