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

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

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

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