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

2

3 from __future__ import annotations

4

5 from typing import TYPE_CHECKING, Any

6

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

8

9 if 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

15 def _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

29 def _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

204 def _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

209 def _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