Coverage for little_loops / cli / sprint / _helpers.py: 4%

166 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-29 00:55 -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 _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)}]" 

27 

28 

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. 

37 

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. 

44 

45 Returns: 

46 Formatted string showing wave structure 

47 """ 

48 if not waves: 

49 return "" 

50 

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) 

55 

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

68 

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

70 num_logical = len(logical_waves) 

71 lines: list[str] = [] 

72 

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

79 

80 max_title = max(45, width - 30) 

81 

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 

88 

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) 

94 

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

111 

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

120 

121 # Truncate title if too long 

122 title = issue.title 

123 if len(title) > max_title: 

124 title = title[: max_title - 3] + "..." 

125 

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

137 

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

143 

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] 

163 

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

171 

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 " 

175 

176 # Truncate title if too long 

177 title = issue.title 

178 if len(title) > max_title: 

179 title = title[: max_title - 3] + "..." 

180 

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

186 

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

191 

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

200 

201 return "\n".join(lines) 

202 

203 

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

207 

208 

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. 

217 

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 

228 

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

230 

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) 

247 

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

268 

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

276 

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

288 

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

290 print() # blank line separator