Coverage for little_loops / dependency_mapper / formatting.py: 94%
153 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-22 16:19 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-22 16:19 -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.dependency_mapper.models import DependencyProposal, DependencyReport
13if TYPE_CHECKING:
14 from little_loops.config import DependencyMappingConfig
15 from little_loops.issue_parser import IssueInfo
18def format_report(
19 report: DependencyReport,
20 *,
21 config: DependencyMappingConfig | None = None,
22) -> str:
23 """Format a dependency report as human-readable markdown.
25 Args:
26 report: The analysis report to format
27 config: Optional dependency mapping config for custom thresholds.
29 Returns:
30 Markdown-formatted report string
31 """
32 lines: list[str] = []
33 lines.append("# Dependency Analysis Report")
34 lines.append("")
35 lines.append(f"- **Issues analyzed**: {report.issue_count}")
36 lines.append(f"- **Existing dependencies**: {report.existing_dep_count}")
37 lines.append(f"- **Proposed new dependencies**: {len(report.proposals)}")
38 lines.append(f"- **Parallel-safe pairs**: {len(report.parallel_safe)}")
39 lines.append(f"- **Validation issues**: {'Yes' if report.validation.has_issues else 'None'}")
40 lines.append("")
42 # Proposals section
43 if report.proposals:
44 lines.append("## Proposed Dependencies")
45 lines.append("")
46 lines.append(
47 "| # | Source (blocked) | Target (blocker) | Reason "
48 "| Conflict | Confidence | Rationale |"
49 )
50 lines.append(
51 "|---|-----------------|-----------------|--------|----------|------------|-----------|"
52 )
53 high_threshold = config.high_conflict_threshold if config else 0.7
54 conflict_threshold = config.conflict_threshold if config else 0.4
55 for i, p in enumerate(report.proposals, 1):
56 if p.conflict_score >= high_threshold:
57 conflict_level = "HIGH"
58 elif p.conflict_score >= conflict_threshold:
59 conflict_level = "MEDIUM"
60 else:
61 conflict_level = "LOW"
62 lines.append(
63 f"| {i} | {p.source_id} | {p.target_id} | "
64 f"{p.reason} | {conflict_level} | {p.confidence:.0%} | {p.rationale} |"
65 )
66 lines.append("")
68 # Parallel-safe section
69 if report.parallel_safe:
70 lines.append("## Parallel Execution Safe")
71 lines.append("")
72 lines.append("| Issue A | Issue B | Shared Files | Conflict Score | Reason |")
73 lines.append("|---------|---------|--------------|---------------|--------|")
74 for pair in report.parallel_safe:
75 files_str = ", ".join(pair.shared_files[:3])
76 if len(pair.shared_files) > 3:
77 files_str += " and more"
78 lines.append(
79 f"| {pair.issue_a} | {pair.issue_b} | "
80 f"{files_str} | {pair.conflict_score:.0%} | {pair.reason} |"
81 )
82 lines.append("")
84 # Validation section
85 v = report.validation
86 if v.has_issues:
87 lines.append("## Validation Issues")
88 lines.append("")
90 if v.broken_refs:
91 lines.append("### Broken References")
92 lines.append("")
93 for issue_id, ref_id in v.broken_refs:
94 lines.append(f"- {issue_id}: references nonexistent {ref_id}")
95 lines.append("")
97 if v.missing_backlinks:
98 lines.append("### Missing Backlinks")
99 lines.append("")
100 for issue_id, ref_id in v.missing_backlinks:
101 lines.append(
102 f"- {issue_id} is blocked by {ref_id}, "
103 f"but {ref_id} does not list {issue_id} in Blocks"
104 )
105 lines.append("")
107 if v.cycles:
108 lines.append("### Dependency Cycles")
109 lines.append("")
110 for cycle in v.cycles:
111 lines.append(f"- {' -> '.join(cycle)}")
112 lines.append("")
114 if v.stale_completed_refs:
115 lines.append("### Stale References (to completed issues)")
116 lines.append("")
117 for issue_id, ref_id in v.stale_completed_refs:
118 lines.append(f"- {issue_id}: blocked by {ref_id} (completed)")
119 lines.append("")
121 if v.broken_depends_on_refs:
122 lines.append("### Broken Depends-On References")
123 lines.append("")
124 for issue_id, ref_id in v.broken_depends_on_refs:
125 lines.append(f"- {issue_id}: depends_on references nonexistent {ref_id}")
126 lines.append("")
128 if v.broken_relates_to_refs:
129 lines.append("### Broken Relates-To References")
130 lines.append("")
131 for issue_id, ref_id in v.broken_relates_to_refs:
132 lines.append(f"- {issue_id}: relates_to references nonexistent {ref_id}")
133 lines.append("")
135 if not report.proposals and not report.parallel_safe and not v.has_issues:
136 lines.append("No dependency proposals or validation issues found.")
137 lines.append("")
139 return "\n".join(lines)
142def format_text_graph(
143 issues: list[IssueInfo],
144 proposals: list[DependencyProposal] | None = None,
145) -> str:
146 """Generate an ASCII dependency graph diagram.
148 Shows existing dependencies as solid arrows and proposed
149 dependencies as dashed arrows.
151 Args:
152 issues: List of parsed issue objects
153 proposals: Optional proposed dependencies to include
155 Returns:
156 Text graph string readable in the terminal
157 """
158 if not issues:
159 return "(no issues)"
161 issue_ids = {i.issue_id for i in issues}
162 sorted_issues = sorted(issues, key=lambda i: (i.priority_int, i.issue_id))
164 # Build adjacency: blocker -> list of blocked issues (blocked_by edges)
165 blocks: dict[str, list[str]] = {}
166 for issue in sorted_issues:
167 for blocker_id in issue.blocked_by:
168 if blocker_id in issue_ids:
169 blocks.setdefault(blocker_id, []).append(issue.issue_id)
171 # Build adjacency for depends_on edges (prerequisite -> dependent)
172 depends_on_edges: set[tuple[str, str]] = set()
173 for issue in sorted_issues:
174 for dep_id in issue.depends_on:
175 if dep_id in issue_ids:
176 blocks.setdefault(dep_id, []).append(issue.issue_id)
177 depends_on_edges.add((dep_id, issue.issue_id))
179 # Add proposed edges
180 proposed_edges: set[tuple[str, str]] = set()
181 if proposals:
182 for p in proposals:
183 if p.target_id in issue_ids and p.source_id in issue_ids:
184 blocks.setdefault(p.target_id, []).append(p.source_id)
185 proposed_edges.add((p.target_id, p.source_id))
187 # Build chains from roots (issues not blocked by anything in the set)
188 blocked_ids: set[str] = set()
189 for targets in blocks.values():
190 blocked_ids.update(targets)
191 roots = [i.issue_id for i in sorted_issues if i.issue_id not in blocked_ids]
193 visited: set[str] = set()
194 chains: list[str] = []
196 def _arrow(src: str, tgt: str) -> str:
197 if (src, tgt) in proposed_edges:
198 return "-.→"
199 if (src, tgt) in depends_on_edges:
200 return "-->"
201 return "──→"
203 def build_chain(issue_id: str) -> str:
204 if issue_id in visited:
205 return issue_id
206 visited.add(issue_id)
207 targets = sorted(blocks.get(issue_id, []))
208 if not targets:
209 return issue_id
210 if len(targets) == 1:
211 return f"{issue_id} {_arrow(issue_id, targets[0])} {build_chain(targets[0])}"
212 # Multiple branches: first inline, rest as separate chains
213 result = f"{issue_id} {_arrow(issue_id, targets[0])} {build_chain(targets[0])}"
214 for other in targets[1:]:
215 if other not in visited:
216 chains.append(f" {issue_id} {_arrow(issue_id, other)} {build_chain(other)}")
217 return result
219 for root in roots:
220 if root not in visited:
221 chain = build_chain(root)
222 chains.append(f" {chain}")
224 # Isolated issues (not in any chain)
225 for issue in sorted_issues:
226 if issue.issue_id not in visited:
227 chains.append(f" {issue.issue_id}")
229 lines: list[str] = list(chains)
231 has_blocks = any("──→" in c for c in chains)
232 has_depends = any("-->" in c for c in chains)
233 has_proposed = any("-.→" in c for c in chains)
235 if has_blocks or has_depends or has_proposed:
236 lines.append("")
237 legend_parts = []
238 if has_blocks:
239 legend_parts.append("──→ blocks")
240 if has_depends:
241 legend_parts.append("--> depends on")
242 if has_proposed:
243 legend_parts.append("-.→ proposed")
244 lines.append(f"Legend: {', '.join(legend_parts)}")
246 return "\n".join(lines)