1"""Dependency report formatting functions.

2

3 Functions for formatting dependency analysis results as human-readable

4 markdown text and ASCII dependency graphs.

5"""

6

7 from __future__ import annotations

8

9 from typing import TYPE_CHECKING

10

11 from little_loops.cli.issues.clusters import EDGE_COLOR

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

13 from little_loops.dependency_mapper.models import DependencyProposal, DependencyReport

14

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

21 def 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

145 def 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

252 def 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)