Coverage for little_loops / dependency_mapper / formatting.py: 0%

185 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-16 13:08 -0500

1"""Dependency report formatting functions. 

2 

3Functions for formatting dependency analysis results as human-readable 

4markdown text and ASCII dependency graphs. 

5""" 

6 

7from __future__ import annotations 

8 

9from typing import TYPE_CHECKING 

10 

11from little_loops.cli.issues.clusters import EDGE_COLOR 

12from little_loops.cli.output import BOX_BL, BOX_ML, BOX_V, colorize 

13from little_loops.dependency_mapper.models import DependencyProposal, DependencyReport 

14 

15if TYPE_CHECKING: 

16 from little_loops.config import DependencyMappingConfig 

17 from little_loops.dependency_graph import DependencyGraph 

18 from little_loops.issue_parser import IssueInfo 

19 

20 

21def format_report( 

22 report: DependencyReport, 

23 *, 

24 config: DependencyMappingConfig | None = None, 

25) -> str: 

26 """Format a dependency report as human-readable markdown. 

27 

28 Args: 

29 report: The analysis report to format 

30 config: Optional dependency mapping config for custom thresholds. 

31 

32 Returns: 

33 Markdown-formatted report string 

34 """ 

35 lines: list[str] = [] 

36 lines.append("# Dependency Analysis Report") 

37 lines.append("") 

38 lines.append(f"- **Issues analyzed**: {report.issue_count}") 

39 lines.append(f"- **Existing dependencies**: {report.existing_dep_count}") 

40 lines.append(f"- **Proposed new dependencies**: {len(report.proposals)}") 

41 lines.append(f"- **Parallel-safe pairs**: {len(report.parallel_safe)}") 

42 lines.append(f"- **Validation issues**: {'Yes' if report.validation.has_issues else 'None'}") 

43 lines.append("") 

44 

45 # Proposals section 

46 if report.proposals: 

47 lines.append("## Proposed Dependencies") 

48 lines.append("") 

49 lines.append( 

50 "| # | Source (blocked) | Target (blocker) | Reason " 

51 "| Conflict | Confidence | Rationale |" 

52 ) 

53 lines.append( 

54 "|---|-----------------|-----------------|--------|----------|------------|-----------|" 

55 ) 

56 high_threshold = config.high_conflict_threshold if config else 0.7 

57 conflict_threshold = config.conflict_threshold if config else 0.4 

58 for i, p in enumerate(report.proposals, 1): 

59 if p.conflict_score >= high_threshold: 

60 conflict_level = "HIGH" 

61 elif p.conflict_score >= conflict_threshold: 

62 conflict_level = "MEDIUM" 

63 else: 

64 conflict_level = "LOW" 

65 lines.append( 

66 f"| {i} | {p.source_id} | {p.target_id} | " 

67 f"{p.reason} | {conflict_level} | {p.confidence:.0%} | {p.rationale} |" 

68 ) 

69 lines.append("") 

70 

71 # Parallel-safe section 

72 if report.parallel_safe: 

73 lines.append("## Parallel Execution Safe") 

74 lines.append("") 

75 lines.append("| Issue A | Issue B | Shared Files | Conflict Score | Reason |") 

76 lines.append("|---------|---------|--------------|---------------|--------|") 

77 for pair in report.parallel_safe: 

78 files_str = ", ".join(pair.shared_files[:3]) 

79 if len(pair.shared_files) > 3: 

80 files_str += " and more" 

81 lines.append( 

82 f"| {pair.issue_a} | {pair.issue_b} | " 

83 f"{files_str} | {pair.conflict_score:.0%} | {pair.reason} |" 

84 ) 

85 lines.append("") 

86 

87 # Validation section 

88 v = report.validation 

89 if v.has_issues: 

90 lines.append("## Validation Issues") 

91 lines.append("") 

92 

93 if v.broken_refs: 

94 lines.append("### Broken References") 

95 lines.append("") 

96 for issue_id, ref_id in v.broken_refs: 

97 lines.append(f"- {issue_id}: references nonexistent {ref_id}") 

98 lines.append("") 

99 

100 if v.missing_backlinks: 

101 lines.append("### Missing Backlinks") 

102 lines.append("") 

103 for issue_id, ref_id in v.missing_backlinks: 

104 lines.append( 

105 f"- {issue_id} is blocked by {ref_id}, " 

106 f"but {ref_id} does not list {issue_id} in Blocks" 

107 ) 

108 lines.append("") 

109 

110 if v.cycles: 

111 lines.append("### Dependency Cycles") 

112 lines.append("") 

113 for cycle in v.cycles: 

114 lines.append(f"- {' -> '.join(cycle)}") 

115 lines.append("") 

116 

117 if v.stale_completed_refs: 

118 lines.append("### Stale References (to completed issues)") 

119 lines.append("") 

120 for issue_id, ref_id in v.stale_completed_refs: 

121 lines.append(f"- {issue_id}: blocked by {ref_id} (completed)") 

122 lines.append("") 

123 

124 if v.broken_depends_on_refs: 

125 lines.append("### Broken Depends-On References") 

126 lines.append("") 

127 for issue_id, ref_id in v.broken_depends_on_refs: 

128 lines.append(f"- {issue_id}: depends_on references nonexistent {ref_id}") 

129 lines.append("") 

130 

131 if v.broken_relates_to_refs: 

132 lines.append("### Broken Relates-To References") 

133 lines.append("") 

134 for issue_id, ref_id in v.broken_relates_to_refs: 

135 lines.append(f"- {issue_id}: relates_to references nonexistent {ref_id}") 

136 lines.append("") 

137 

138 if not report.proposals and not report.parallel_safe and not v.has_issues: 

139 lines.append("No dependency proposals or validation issues found.") 

140 lines.append("") 

141 

142 return "\n".join(lines) 

143 

144 

145def format_text_graph( 

146 issues: list[IssueInfo], 

147 proposals: list[DependencyProposal] | None = None, 

148) -> str: 

149 """Generate an ASCII dependency graph diagram. 

150 

151 Shows existing dependencies as solid arrows and proposed 

152 dependencies as dashed arrows. 

153 

154 Args: 

155 issues: List of parsed issue objects 

156 proposals: Optional proposed dependencies to include 

157 

158 Returns: 

159 Text graph string readable in the terminal 

160 """ 

161 if not issues: 

162 return "(no issues)" 

163 

164 issue_ids = {i.issue_id for i in issues} 

165 sorted_issues = sorted(issues, key=lambda i: (i.priority_int, i.issue_id)) 

166 

167 # Build adjacency: blocker -> list of blocked issues (blocked_by edges) 

168 blocks: dict[str, list[str]] = {} 

169 for issue in sorted_issues: 

170 for blocker_id in issue.blocked_by: 

171 if blocker_id in issue_ids: 

172 blocks.setdefault(blocker_id, []).append(issue.issue_id) 

173 

174 # Build adjacency for depends_on edges (prerequisite -> dependent) 

175 depends_on_edges: set[tuple[str, str]] = set() 

176 for issue in sorted_issues: 

177 for dep_id in issue.depends_on: 

178 if dep_id in issue_ids: 

179 blocks.setdefault(dep_id, []).append(issue.issue_id) 

180 depends_on_edges.add((dep_id, issue.issue_id)) 

181 

182 # Add proposed edges 

183 proposed_edges: set[tuple[str, str]] = set() 

184 if proposals: 

185 for p in proposals: 

186 if p.target_id in issue_ids and p.source_id in issue_ids: 

187 blocks.setdefault(p.target_id, []).append(p.source_id) 

188 proposed_edges.add((p.target_id, p.source_id)) 

189 

190 # Build chains from roots (issues not blocked by anything in the set) 

191 blocked_ids: set[str] = set() 

192 for targets in blocks.values(): 

193 blocked_ids.update(targets) 

194 roots = [i.issue_id for i in sorted_issues if i.issue_id not in blocked_ids] 

195 

196 visited: set[str] = set() 

197 chains: list[str] = [] 

198 

199 def _arrow(src: str, tgt: str) -> str: 

200 if (src, tgt) in proposed_edges: 

201 return "-.→" 

202 if (src, tgt) in depends_on_edges: 

203 return "-->" 

204 return "──→" 

205 

206 def build_chain(issue_id: str) -> str: 

207 if issue_id in visited: 

208 return issue_id 

209 visited.add(issue_id) 

210 targets = sorted(blocks.get(issue_id, [])) 

211 if not targets: 

212 return issue_id 

213 if len(targets) == 1: 

214 return f"{issue_id} {_arrow(issue_id, targets[0])} {build_chain(targets[0])}" 

215 # Multiple branches: first inline, rest as separate chains 

216 result = f"{issue_id} {_arrow(issue_id, targets[0])} {build_chain(targets[0])}" 

217 for other in targets[1:]: 

218 if other not in visited: 

219 chains.append(f" {issue_id} {_arrow(issue_id, other)} {build_chain(other)}") 

220 return result 

221 

222 for root in roots: 

223 if root not in visited: 

224 chain = build_chain(root) 

225 chains.append(f" {chain}") 

226 

227 # Isolated issues (not in any chain) 

228 for issue in sorted_issues: 

229 if issue.issue_id not in visited: 

230 chains.append(f" {issue.issue_id}") 

231 

232 lines: list[str] = list(chains) 

233 

234 has_blocks = any("──→" in c for c in chains) 

235 has_depends = any("-->" in c for c in chains) 

236 has_proposed = any("-.→" in c for c in chains) 

237 

238 if has_blocks or has_depends or has_proposed: 

239 lines.append("") 

240 legend_parts = [] 

241 if has_blocks: 

242 legend_parts.append("──→ blocks") 

243 if has_depends: 

244 legend_parts.append("--> depends on") 

245 if has_proposed: 

246 legend_parts.append("-.→ proposed") 

247 lines.append(f"Legend: {', '.join(legend_parts)}") 

248 

249 return "\n".join(lines) 

250 

251 

252def format_epic_tree( 

253 root_id: str, 

254 root_info: IssueInfo, 

255 child_map: dict[str, IssueInfo], 

256 graph: DependencyGraph, 

257 use_color: bool = True, 

258) -> str: 

259 """Render an EPIC's child hierarchy as a Unicode box-drawing tree string.""" 

260 if not child_map: 

261 return f"{root_id}: (no children)" 

262 

263 done_count = sum(1 for info in child_map.values() if info.status in {"done", "deferred"}) 

264 total_count = len(child_map) 

265 lines: list[str] = [f"{root_id}: {root_info.title} {done_count}/{total_count} done"] 

266 

267 ordered = [issue for issue in graph.topological_sort() if issue.issue_id in child_map] 

268 seen = {issue.issue_id for issue in ordered} 

269 for cid, cinfo in child_map.items(): 

270 if cid not in seen: 

271 ordered.append(cinfo) 

272 

273 def _c(text: str, code: str) -> str: 

274 return colorize(text, code) if use_color else text 

275 

276 for idx, child in enumerate(ordered): 

277 is_last = idx == len(ordered) - 1 

278 connector = BOX_BL + "── " if is_last else BOX_ML + "── " 

279 extension = " " if is_last else BOX_V + " " 

280 child_id = child.issue_id 

281 

282 badge = "" 

283 if child.status == "done": 

284 badge = " [done]" 

285 elif child.status == "blocked": 

286 badge = " [blocked]" 

287 

288 lines.append(f"{connector}{child_id}{badge}") 

289 

290 blocked_ids = sorted(graph.blocks.get(child_id, set())) 

291 for blocked_id in blocked_ids: 

292 if blocked_id in child_map: 

293 annotation = "⮡ blocks " + blocked_id 

294 lines.append(f"{extension} {_c(annotation, EDGE_COLOR['blocks'])}") 

295 

296 return "\n".join(lines)