Coverage for little_loops/cli/deps.py: 3%

1"""ll-deps: Cross-issue dependency discovery and validation."""

2

3 from __future__ import annotations

4

5 import argparse

6 from pathlib import Path

7

8

9 def _load_issues(

10 issues_dir: Path,

11 only_ids: set[str] | None = None,

12) -> tuple[list, dict[str, str], set[str]]:

13 """Load issues from directory for CLI use.

14

15 Args:

16 issues_dir: Path to the issues base directory (e.g., .issues)

17 only_ids: If provided, only include issues with these IDs

18

19 Returns:

20 Tuple of (active issues, issue contents map, completed issue IDs)

21 """

22 from little_loops.config import BRConfig

23 from little_loops.issue_parser import find_issues

24

25 # Find project root by walking up from issues_dir

26 project_root = issues_dir.resolve().parent

27 if issues_dir.name != ".issues":

28 # If issues_dir is already absolute, try to find config relative to it

29 project_root = issues_dir.parent

30

31 config = BRConfig(project_root)

32 issues = find_issues(config, only_ids=only_ids)

33

34 # Build contents map

35 issue_contents: dict[str, str] = {}

36 for info in issues:

37 if info.path.exists():

38 issue_contents[info.issue_id] = info.path.read_text(encoding="utf-8")

39

40 # Gather completed and deferred issue IDs

41 import re as _re

42

43 completed_ids: set[str] = set()

44 for non_active_dir in [config.get_completed_dir(), config.get_deferred_dir()]:

45 if non_active_dir.exists():

46 for f in non_active_dir.glob("*.md"):

47 match = _re.search(r"(BUG|FEAT|ENH)-(\d+)", f.name)

48 if match:

49 completed_ids.add(f"{match.group(1)}-{match.group(2)}")

50

51 return issues, issue_contents, completed_ids

52

53

54 def main_deps() -> int:

55 """Entry point for ll-deps command.

56

57 Analyze cross-issue dependencies and validate existing references.

58

59 Returns:

60 Exit code (0 = success, 1 = failure)

61 """

62 import json as _json

63 import sys

64

65 from little_loops.dependency_mapper import (

66 analyze_dependencies,

67 fix_dependencies,

68 format_report,

69 format_text_graph,

70 gather_all_issue_ids,

71 validate_dependencies,

72 )

73

74 parser = argparse.ArgumentParser(

75 prog="ll-deps",

76 description="Cross-issue dependency discovery and validation",

77 formatter_class=argparse.RawDescriptionHelpFormatter,

78 epilog="""

79 Examples:

80 %(prog)s analyze # Full analysis with markdown output

81 %(prog)s analyze --format json # JSON output for programmatic use

82 %(prog)s analyze --graph # Include ASCII dependency graph

83 %(prog)s analyze --sprint my-sprint # Analyze only issues in a sprint

84 %(prog)s validate # Validation only (broken refs, cycles)

85 %(prog)s validate --sprint my-sprint # Validate only sprint issue deps

86 %(prog)s fix # Auto-fix broken refs, stale refs, backlinks

87 %(prog)s fix --dry-run # Preview fixes without modifying files

88""",

89 )

90

91 parser.add_argument(

92 "-d",

93 "--issues-dir",

94 type=Path,

95 default=None,

96 help="Path to issues directory (default: .issues)",

97 )

98

99 subparsers = parser.add_subparsers(dest="command", help="Available commands")

100

101 # analyze subcommand

102 analyze_parser = subparsers.add_parser(

103 "analyze",

104 help="Full dependency analysis (file overlaps + validation)",

105 )

106 analyze_parser.add_argument(

107 "-f",

108 "--format",

109 type=str,

110 choices=["text", "json"],

111 default="text",

112 help="Output format (default: text/markdown)",

113 )

114 analyze_parser.add_argument(

115 "--graph",

116 action="store_true",

117 help="Include ASCII dependency graph in output",

118 )

119 analyze_parser.add_argument(

120 "--sprint",

121 type=str,

122 default=None,

123 help="Restrict analysis to issues in the named sprint",

124 )

125

126 # validate subcommand

127 validate_parser = subparsers.add_parser(

128 "validate",

129 help="Validate existing dependency references only",

130 )

131 validate_parser.add_argument(

132 "--sprint",

133 type=str,

134 default=None,

135 help="Restrict validation to issues in the named sprint",

136 )

137

138 # fix subcommand

139 fix_parser = subparsers.add_parser(

140 "fix",

141 help="Auto-fix broken refs, stale refs, and missing backlinks",

142 )

143 fix_parser.add_argument(

144 "--dry-run",

145 "-n",

146 action="store_true",

147 help="Show what would be fixed without making changes",

148 )

149 fix_parser.add_argument(

150 "--sprint",

151 type=str,

152 default=None,

153 help="Restrict fixes to issues in the named sprint",

154 )

155

156 args = parser.parse_args()

157

158 if not args.command:

159 parser.print_help()

160 return 1

161

162 issues_dir = args.issues_dir or Path.cwd() / ".issues"

163 if not issues_dir.exists():

164 print(f"Error: Issues directory not found: {issues_dir}", file=sys.stderr)

165 return 1

166

167 # Sprint-scoped filtering

168 only_ids: set[str] | None = None

169 if getattr(args, "sprint", None):

170 from little_loops.config import BRConfig as _BRConfig

171 from little_loops.sprint import Sprint

172

173 project_root = issues_dir.resolve().parent

174 if issues_dir.name != ".issues":

175 project_root = issues_dir.parent

176 _config = _BRConfig(project_root)

177 sprints_dir = Path(_config.sprints.sprints_dir)

178 if not sprints_dir.is_absolute():

179 sprints_dir = project_root / sprints_dir

180

181 sprint = Sprint.load(sprints_dir, args.sprint)

182 if sprint is None:

183 print(f"Error: Sprint not found: {args.sprint}", file=sys.stderr)

184 return 1

185 only_ids = set(sprint.issues)

186 if not only_ids:

187 print(f"Sprint '{args.sprint}' has no issues.")

188 return 0

189

190 try:

191 issues, issue_contents, completed_ids = _load_issues(issues_dir, only_ids=only_ids)

192 except Exception as e:

193 print(f"Error loading issues: {e}", file=sys.stderr)

194 return 1

195

196 if not issues:

197 print("No active issues found.")

198 return 0

199

200 # Gather all issue IDs on disk to avoid false "nonexistent" warnings

201 # when sprint-scoped analysis references issues outside the sprint

202 try:

203 from little_loops.config import BRConfig as _BRConfig

204

205 _dm_config = _BRConfig(issues_dir.resolve().parent)

206 except Exception:

207 _dm_config = None

208 all_known_ids = gather_all_issue_ids(issues_dir, config=_dm_config)

209

210 # Load dependency mapping config

211 dep_config = _dm_config.dependency_mapping if _dm_config else None

212

213 if args.command == "analyze":

214 report = analyze_dependencies(

215 issues, issue_contents, completed_ids, all_known_ids, config=dep_config

216 )

217

218 if args.format == "json":

219 data = {

220 "issue_count": report.issue_count,

221 "existing_dep_count": report.existing_dep_count,

222 "proposals": [

223 {

224 "source_id": p.source_id,

225 "target_id": p.target_id,

226 "reason": p.reason,

227 "confidence": p.confidence,

228 "rationale": p.rationale,

229 "overlapping_files": p.overlapping_files,

230 "conflict_score": p.conflict_score,

231 }

232 for p in report.proposals

233 ],

234 "parallel_safe": [

235 {

236 "issue_a": ps.issue_a,

237 "issue_b": ps.issue_b,

238 "shared_files": ps.shared_files,

239 "conflict_score": ps.conflict_score,

240 "reason": ps.reason,

241 }

242 for ps in report.parallel_safe

243 ],

244 "validation": {

245 "broken_refs": report.validation.broken_refs,

246 "missing_backlinks": report.validation.missing_backlinks,

247 "cycles": report.validation.cycles,

248 "stale_completed_refs": report.validation.stale_completed_refs,

249 "has_issues": report.validation.has_issues,

250 },

251 }

252 print(_json.dumps(data, indent=2))

253 else:

254 print(format_report(report, config=dep_config))

255 if args.graph:

256 print()

257 print("## Dependency Graph")

258 print()

259 print(format_text_graph(issues, report.proposals))

260

261 return 0

262

263 if args.command == "validate":

264 result = validate_dependencies(issues, completed_ids, all_known_ids)

265

266 if not result.has_issues:

267 print("No validation issues found.")

268 return 0

269

270 lines: list[str] = []

271 lines.append("# Dependency Validation Report")

272 lines.append("")

273

274 if result.broken_refs:

275 lines.append("## Broken References")

276 lines.append("")

277 for issue_id, ref_id in result.broken_refs:

278 lines.append(f"- {issue_id}: references nonexistent {ref_id}")

279 lines.append("")

280

281 if result.missing_backlinks:

282 lines.append("## Missing Backlinks")

283 lines.append("")

284 for issue_id, ref_id in result.missing_backlinks:

285 lines.append(

286 f"- {issue_id} is blocked by {ref_id}, "

287 f"but {ref_id} does not list {issue_id} in Blocks"

288 )

289 lines.append("")

290

291 if result.cycles:

292 lines.append("## Dependency Cycles")

293 lines.append("")

294 for cycle in result.cycles:

295 lines.append(f"- {' -> '.join(cycle)}")

296 lines.append("")

297

298 if result.stale_completed_refs:

299 lines.append("## Stale References (to completed issues)")

300 lines.append("")

301 for issue_id, ref_id in result.stale_completed_refs:

302 lines.append(f"- {issue_id}: blocked by {ref_id} (completed)")

303 lines.append("")

304

305 print("\n".join(lines))

306 return 0

307

308 if args.command == "fix":

309 fix_result = fix_dependencies(issues, completed_ids, all_known_ids, dry_run=args.dry_run)

310

311 if not fix_result.changes:

312 print("No fixable issues found.")

313 if fix_result.skipped_cycles:

314 print(f"({fix_result.skipped_cycles} cycle(s) detected — resolve manually)")

315 return 0

316

317 prefix = "[DRY RUN] " if args.dry_run else ""

318 print(f"# {prefix}Dependency Fix Report")

319 print()

320 for change in fix_result.changes:

321 print(f" {prefix}{change}")

322 print()

323 print(f"{prefix}{len(fix_result.changes)} fix(es) applied.")

324

325 if fix_result.modified_files:

326 print()

327 print("Modified files:")

328 for fpath in sorted(fix_result.modified_files):

329 print(f" {fpath}")

330

331 if fix_result.skipped_cycles:

332 print()

333 print(f"({fix_result.skipped_cycles} cycle(s) detected — resolve manually)")

334

335 return 0

336

337 return 1