Coverage for little_loops / dependency_mapper / formatting.py: 0%
185 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-26 17:38 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-26 17:38 -0500
1"""Dependency report formatting functions.
3Functions for formatting dependency analysis results as human-readable
4markdown text and ASCII dependency graphs.
5"""
7from __future__ import annotations
9from typing import TYPE_CHECKING
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
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
21def format_report(
22 report: DependencyReport,
23 *,
24 config: DependencyMappingConfig | None = None,
25) -> str:
26 """Format a dependency report as human-readable markdown.
28 Args:
29 report: The analysis report to format
30 config: Optional dependency mapping config for custom thresholds.
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("")
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("")
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("")
87 # Validation section
88 v = report.validation
89 if v.has_issues:
90 lines.append("## Validation Issues")
91 lines.append("")
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("")
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("")
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("")
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("")
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("")
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("")
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("")
142 return "\n".join(lines)
145def format_text_graph(
146 issues: list[IssueInfo],
147 proposals: list[DependencyProposal] | None = None,
148) -> str:
149 """Generate an ASCII dependency graph diagram.
151 Shows existing dependencies as solid arrows and proposed
152 dependencies as dashed arrows.
154 Args:
155 issues: List of parsed issue objects
156 proposals: Optional proposed dependencies to include
158 Returns:
159 Text graph string readable in the terminal
160 """
161 if not issues:
162 return "(no issues)"
164 issue_ids = {i.issue_id for i in issues}
165 sorted_issues = sorted(issues, key=lambda i: (i.priority_int, i.issue_id))
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)
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))
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))
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]
196 visited: set[str] = set()
197 chains: list[str] = []
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 "──→"
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
222 for root in roots:
223 if root not in visited:
224 chain = build_chain(root)
225 chains.append(f" {chain}")
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}")
232 lines: list[str] = list(chains)
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)
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)}")
249 return "\n".join(lines)
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)"
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"]
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)
273 def _c(text: str, code: str) -> str:
274 return colorize(text, code) if use_color else text
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
282 badge = ""
283 if child.status == "done":
284 badge = " [done]"
285 elif child.status == "blocked":
286 badge = " [blocked]"
288 lines.append(f"{connector}{child_id}{badge}")
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'])}")
296 return "\n".join(lines)