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

1"""Shared helpers for ll-sprint CLI subcommands.""" 

2 

3from __future__ import annotations 

4 

5from typing import TYPE_CHECKING, Any 

6 

7from little_loops.cli.output import PRIORITY_COLOR, TYPE_COLOR, colorize, terminal_width 

8 

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 

13 

14 

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. 

21 

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. 

27 

28 Returns: 

29 Formatted string showing wave structure 

30 """ 

31 if not waves: 

32 return "" 

33 

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) 

38 

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]) 

51 

52 total_issues = sum(len(wave) for wave in waves) 

53 num_logical = len(logical_waves) 

54 lines: list[str] = [] 

55 

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) 

62 

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 

69 

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}:") 

80 

81 # Truncate title if too long 

82 title = issue.title 

83 if len(title) > 45: 

84 title = title[:42] + "..." 

85 

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 ) 

94 

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}") 

100 

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] 

113 

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()) 

121 

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 " 

125 

126 # Truncate title if too long 

127 title = issue.title 

128 if len(title) > 45: 

129 title = title[:42] + "..." 

130 

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})") 

135 

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}") 

144 

145 return "\n".join(lines) 

146 

147 

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()} 

151 

152 

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. 

161 

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 

172 

173 logger.header("Dependency Analysis", char="-", width=60) 

174 

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) 

191 

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}") 

212 

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)") 

220 

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") 

232 

233 logger.info("Run /ll:map-dependencies to apply discovered dependencies") 

234 print() # blank line separator