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

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.dependency_mapper.models import DependencyProposal, DependencyReport 

12 

13if TYPE_CHECKING: 

14 from little_loops.config import DependencyMappingConfig 

15 from little_loops.issue_parser import IssueInfo 

16 

17 

18def format_report( 

19 report: DependencyReport, 

20 *, 

21 config: DependencyMappingConfig | None = None, 

22) -> str: 

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

24 

25 Args: 

26 report: The analysis report to format 

27 config: Optional dependency mapping config for custom thresholds. 

28 

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("") 

41 

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("") 

67 

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("") 

83 

84 # Validation section 

85 v = report.validation 

86 if v.has_issues: 

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

88 lines.append("") 

89 

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("") 

96 

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("") 

106 

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("") 

113 

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("") 

120 

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("") 

127 

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("") 

134 

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("") 

138 

139 return "\n".join(lines) 

140 

141 

142def format_text_graph( 

143 issues: list[IssueInfo], 

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

145) -> str: 

146 """Generate an ASCII dependency graph diagram. 

147 

148 Shows existing dependencies as solid arrows and proposed 

149 dependencies as dashed arrows. 

150 

151 Args: 

152 issues: List of parsed issue objects 

153 proposals: Optional proposed dependencies to include 

154 

155 Returns: 

156 Text graph string readable in the terminal 

157 """ 

158 if not issues: 

159 return "(no issues)" 

160 

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

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

163 

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) 

170 

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

178 

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

186 

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] 

192 

193 visited: set[str] = set() 

194 chains: list[str] = [] 

195 

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 "──→" 

202 

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 

218 

219 for root in roots: 

220 if root not in visited: 

221 chain = build_chain(root) 

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

223 

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}") 

228 

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

230 

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) 

234 

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)}") 

245 

246 return "\n".join(lines)