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

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

12

13 if TYPE_CHECKING:

14 from little_loops.config import DependencyMappingConfig

15 from little_loops.issue_parser import IssueInfo

16

17

18 def 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 not report.proposals and not report.parallel_safe and not v.has_issues:

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

123 lines.append("")

124

125 return "\n".join(lines)

126

127

128 def format_text_graph(

129 issues: list[IssueInfo],

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

131) -> str:

132 """Generate an ASCII dependency graph diagram.

133

134 Shows existing dependencies as solid arrows and proposed

135 dependencies as dashed arrows.

136

137 Args:

138 issues: List of parsed issue objects

139 proposals: Optional proposed dependencies to include

140

141 Returns:

142 Text graph string readable in the terminal

143 """

144 if not issues:

145 return "(no issues)"

146

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

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

149

150 # Build adjacency: blocker -> list of blocked issues

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

152 for issue in sorted_issues:

153 for blocker_id in issue.blocked_by:

154 if blocker_id in issue_ids:

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

156

157 # Add proposed edges

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

159 if proposals:

160 for p in proposals:

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

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

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

164

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

166 blocked_ids: set[str] = set()

167 for targets in blocks.values():

168 blocked_ids.update(targets)

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

170

171 visited: set[str] = set()

172 chains: list[str] = []

173

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

175 if issue_id in visited:

176 return issue_id

177 visited.add(issue_id)

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

179 if not targets:

180 return issue_id

181 if len(targets) == 1:

182 arrow = "-.→" if (issue_id, targets[0]) in proposed_edges else "──→"

183 return f"{issue_id} {arrow} {build_chain(targets[0])}"

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

185 arrow = "-.→" if (issue_id, targets[0]) in proposed_edges else "──→"

186 result = f"{issue_id} {arrow} {build_chain(targets[0])}"

187 for other in targets[1:]:

188 if other not in visited:

189 arrow_other = "-.→" if (issue_id, other) in proposed_edges else "──→"

190 chains.append(f" {issue_id} {arrow_other} {build_chain(other)}")

191 return result

192

193 for root in roots:

194 if root not in visited:

195 chain = build_chain(root)

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

197

198 # Isolated issues (not in any chain)

199 for issue in sorted_issues:

200 if issue.issue_id not in visited:

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

202

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

204

205 if any("──→" in c for c in chains) or any("-.→" in c for c in chains):

206 lines.append("")

207 legend_parts = []

208 if any("──→" in c for c in chains):

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

210 if any("-.→" in c for c in chains):

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

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

213

214 return "\n".join(lines)