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

241 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-05-22 16:19 -0500

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

2 

3from __future__ import annotations 

4 

5import argparse 

6from pathlib import Path 

7 

8from little_loops.cli.output import configure_output, use_color_enabled 

9from little_loops.logger import Logger 

10 

11 

12def _load_issues( 

13 issues_dir: Path, 

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

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

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

17 

18 Args: 

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

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

21 

22 Returns: 

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

24 """ 

25 from little_loops.config import BRConfig 

26 from little_loops.issue_parser import find_issues 

27 

28 # Find project root by walking up from issues_dir 

29 project_root = issues_dir.resolve().parent 

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

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

32 project_root = issues_dir.parent 

33 

34 config = BRConfig(project_root) 

35 issues = find_issues(config, only_ids=only_ids) 

36 

37 # Build contents map 

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

39 for info in issues: 

40 if info.path.exists(): 

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

42 

43 # Gather completed and deferred issue IDs via status-field scan of type dirs 

44 from little_loops.issue_parser import IssueParser 

45 

46 parser = IssueParser(config) 

47 completed_ids: set[str] = set() 

48 for category in config.issue_categories: 

49 cat_dir = config.get_issue_dir(category) 

50 if not cat_dir.exists(): 

51 continue 

52 for f in cat_dir.glob("*.md"): 

53 try: 

54 info = parser.parse_file(f) 

55 if info.status in ("done", "deferred"): 

56 completed_ids.add(info.issue_id) 

57 except Exception: 

58 continue 

59 

60 return issues, issue_contents, completed_ids 

61 

62 

63def main_deps() -> int: 

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

65 

66 Analyze cross-issue dependencies and validate existing references. 

67 

68 Returns: 

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

70 """ 

71 import json as _json 

72 

73 from little_loops.dependency_mapper import ( 

74 analyze_dependencies, 

75 fix_dependencies, 

76 format_report, 

77 format_text_graph, 

78 gather_all_issue_ids, 

79 validate_dependencies, 

80 ) 

81 

82 parser = argparse.ArgumentParser( 

83 prog="ll-deps", 

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

85 formatter_class=argparse.RawDescriptionHelpFormatter, 

86 epilog=""" 

87Examples: 

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

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

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

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

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

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

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

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

96 %(prog)s apply # Apply proposals >= 0.7 confidence 

97 %(prog)s apply --min-confidence 0.5 # Lower threshold 

98 %(prog)s apply --dry-run # Preview only (no writes) 

99 %(prog)s apply --sprint my-sprint # Sprint-scoped apply 

100 %(prog)s apply FEAT-001 blocks FEAT-002 # Manual explicit pair 

101 %(prog)s apply FEAT-001 blocked-by FEAT-002 # Manual explicit pair (inverse) 

102""", 

103 ) 

104 

105 parser.add_argument( 

106 "-d", 

107 "--issues-dir", 

108 type=Path, 

109 default=None, 

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

111 ) 

112 

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

114 

115 # analyze subcommand 

116 analyze_parser = subparsers.add_parser( 

117 "analyze", 

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

119 ) 

120 analyze_parser.add_argument( 

121 "-f", 

122 "--format", 

123 type=str, 

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

125 default="text", 

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

127 ) 

128 analyze_parser.add_argument( 

129 "--graph", 

130 action="store_true", 

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

132 ) 

133 analyze_parser.add_argument( 

134 "--sprint", 

135 type=str, 

136 default=None, 

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

138 ) 

139 

140 # validate subcommand 

141 validate_parser = subparsers.add_parser( 

142 "validate", 

143 help="Validate existing dependency references only", 

144 ) 

145 validate_parser.add_argument( 

146 "--sprint", 

147 type=str, 

148 default=None, 

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

150 ) 

151 

152 # fix subcommand 

153 fix_parser = subparsers.add_parser( 

154 "fix", 

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

156 ) 

157 fix_parser.add_argument( 

158 "--dry-run", 

159 "-n", 

160 action="store_true", 

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

162 ) 

163 fix_parser.add_argument( 

164 "--sprint", 

165 type=str, 

166 default=None, 

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

168 ) 

169 

170 # apply subcommand 

171 apply_parser = subparsers.add_parser( 

172 "apply", 

173 help="Write dependency relationships to issue files", 

174 ) 

175 apply_parser.add_argument( 

176 "source", 

177 nargs="?", 

178 default=None, 

179 help="Source issue ID for explicit pair (e.g. FEAT-001)", 

180 ) 

181 apply_parser.add_argument( 

182 "relation", 

183 nargs="?", 

184 default=None, 

185 choices=["blocks", "blocked-by"], 

186 help="Relationship direction: 'blocks' or 'blocked-by'", 

187 ) 

188 apply_parser.add_argument( 

189 "target", 

190 nargs="?", 

191 default=None, 

192 help="Target issue ID for explicit pair (e.g. FEAT-002)", 

193 ) 

194 apply_parser.add_argument( 

195 "--min-confidence", 

196 type=float, 

197 default=0.7, 

198 help="Minimum confidence threshold for implicit apply (default: 0.7)", 

199 ) 

200 apply_parser.add_argument( 

201 "--dry-run", 

202 "-n", 

203 action="store_true", 

204 help="Preview without writing", 

205 ) 

206 apply_parser.add_argument( 

207 "--sprint", 

208 type=str, 

209 default=None, 

210 help="Restrict to issues in named sprint", 

211 ) 

212 

213 args = parser.parse_args() 

214 

215 configure_output() 

216 logger = Logger(use_color=use_color_enabled()) 

217 

218 if not args.command: 

219 parser.print_help() 

220 return 1 

221 

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

223 if not issues_dir.exists(): 

224 logger.error(f"Issues directory not found: {issues_dir}") 

225 return 1 

226 

227 # Sprint-scoped filtering 

228 only_ids: set[str] | None = None 

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

230 from little_loops.config import BRConfig as _BRConfig 

231 from little_loops.sprint import Sprint 

232 

233 project_root = issues_dir.resolve().parent 

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

235 project_root = issues_dir.parent 

236 _config = _BRConfig(project_root) 

237 sprints_dir = Path(_config.sprints.sprints_dir) 

238 if not sprints_dir.is_absolute(): 

239 sprints_dir = project_root / sprints_dir 

240 

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

242 if sprint is None: 

243 logger.error(f"Sprint not found: {args.sprint}") 

244 return 1 

245 only_ids = set(sprint.issues) 

246 if not only_ids: 

247 logger.warning(f"Sprint '{args.sprint}' has no issues.") 

248 return 0 

249 

250 try: 

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

252 except Exception as e: 

253 logger.error(f"Error loading issues: {e}") 

254 return 1 

255 

256 if not issues: 

257 logger.warning("No active issues found.") 

258 return 0 

259 

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

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

262 try: 

263 from little_loops.config import BRConfig as _BRConfig 

264 

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

266 except Exception: 

267 _dm_config = None 

268 all_known_ids = gather_all_issue_ids(issues_dir, config=_dm_config) 

269 

270 # Load dependency mapping config 

271 dep_config = _dm_config.dependency_mapping if _dm_config else None 

272 

273 if args.command == "analyze": 

274 report = analyze_dependencies( 

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

276 ) 

277 

278 if args.format == "json": 

279 data = { 

280 "issue_count": report.issue_count, 

281 "existing_dep_count": report.existing_dep_count, 

282 "proposals": [ 

283 { 

284 "source_id": p.source_id, 

285 "target_id": p.target_id, 

286 "reason": p.reason, 

287 "confidence": p.confidence, 

288 "rationale": p.rationale, 

289 "overlapping_files": p.overlapping_files, 

290 "conflict_score": p.conflict_score, 

291 } 

292 for p in report.proposals 

293 ], 

294 "parallel_safe": [ 

295 { 

296 "issue_a": ps.issue_a, 

297 "issue_b": ps.issue_b, 

298 "shared_files": ps.shared_files, 

299 "conflict_score": ps.conflict_score, 

300 "reason": ps.reason, 

301 } 

302 for ps in report.parallel_safe 

303 ], 

304 "validation": { 

305 "broken_refs": report.validation.broken_refs, 

306 "missing_backlinks": report.validation.missing_backlinks, 

307 "cycles": report.validation.cycles, 

308 "stale_completed_refs": report.validation.stale_completed_refs, 

309 "broken_depends_on_refs": report.validation.broken_depends_on_refs, 

310 "broken_relates_to_refs": report.validation.broken_relates_to_refs, 

311 "has_issues": report.validation.has_issues, 

312 }, 

313 } 

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

315 else: 

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

317 if args.graph: 

318 print() 

319 print("## Dependency Graph") 

320 print() 

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

322 

323 return 0 

324 

325 if args.command == "validate": 

326 result = validate_dependencies(issues, completed_ids, all_known_ids) 

327 

328 if not result.has_issues: 

329 logger.info("No validation issues found.") 

330 return 0 

331 

332 lines: list[str] = [] 

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

334 lines.append("") 

335 

336 if result.broken_refs: 

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

338 lines.append("") 

339 for issue_id, ref_id in result.broken_refs: 

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

341 lines.append("") 

342 

343 if result.missing_backlinks: 

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

345 lines.append("") 

346 for issue_id, ref_id in result.missing_backlinks: 

347 lines.append( 

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

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

350 ) 

351 lines.append("") 

352 

353 if result.cycles: 

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

355 lines.append("") 

356 for cycle in result.cycles: 

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

358 lines.append("") 

359 

360 if result.stale_completed_refs: 

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

362 lines.append("") 

363 for issue_id, ref_id in result.stale_completed_refs: 

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

365 lines.append("") 

366 

367 if result.broken_depends_on_refs: 

368 lines.append("## Broken Depends-On References") 

369 lines.append("") 

370 for issue_id, ref_id in result.broken_depends_on_refs: 

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

372 lines.append("") 

373 

374 if result.broken_relates_to_refs: 

375 lines.append("## Broken Relates-To References") 

376 lines.append("") 

377 for issue_id, ref_id in result.broken_relates_to_refs: 

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

379 lines.append("") 

380 

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

382 return 0 

383 

384 if args.command == "fix": 

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

386 

387 if not fix_result.changes: 

388 print("No fixable issues found.") 

389 if fix_result.skipped_cycles: 

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

391 return 0 

392 

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

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

395 print() 

396 for change in fix_result.changes: 

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

398 print() 

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

400 

401 if fix_result.modified_files: 

402 print() 

403 print("Modified files:") 

404 for fpath in sorted(fix_result.modified_files): 

405 print(f" {fpath}") 

406 

407 if fix_result.skipped_cycles: 

408 print() 

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

410 

411 return 0 

412 

413 if args.command == "apply": 

414 from little_loops.dependency_mapper.operations import _add_to_section 

415 

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

417 issue_files = {i.issue_id: i.path for i in issues} 

418 

419 # Explicit-pair mode: all three positional args must be provided together 

420 if args.source or args.relation or args.target: 

421 if not (args.source and args.relation and args.target): 

422 logger.error( 

423 "explicit pair requires all three arguments: <source> <relation> <target>" 

424 ) 

425 return 1 

426 

427 all_ids = {i.issue_id for i in issues} | all_known_ids 

428 for id_to_check, label in [(args.source, "source"), (args.target, "target")]: 

429 if id_to_check not in all_ids: 

430 logger.error(f"{label} issue {id_to_check!r} not found") 

431 return 1 

432 

433 # Determine which issue receives the "Blocked By" entry 

434 if args.relation == "blocks": 

435 # source blocks target → target is blocked by source 

436 blocked_id, blocker_id = args.target, args.source 

437 else: # blocked-by 

438 # source blocked-by target → source is blocked by target 

439 blocked_id, blocker_id = args.source, args.target 

440 

441 blocked_path = issue_files.get(blocked_id) 

442 if blocked_path is None: 

443 logger.error(f"issue {blocked_id!r} is not in active issues (cannot write to it)") 

444 return 1 

445 

446 print(f"# {prefix}Dependency Apply Report") 

447 print() 

448 print(f" {prefix}{blocked_id} blocked by {blocker_id}") 

449 print() 

450 

451 if not args.dry_run: 

452 _add_to_section(blocked_path, "Blocked By", blocker_id) 

453 print("1 relationship(s) applied.") 

454 print() 

455 print("Modified files:") 

456 print(f" {blocked_path}") 

457 else: 

458 print("[DRY RUN] 1 relationship(s) would be applied.") 

459 

460 return 0 

461 

462 # Implicit mode: run analysis and apply proposals above confidence threshold 

463 report = analyze_dependencies( 

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

465 ) 

466 filtered = [p for p in report.proposals if p.confidence >= args.min_confidence] 

467 

468 if not filtered: 

469 logger.info(f"No proposals at or above confidence threshold ({args.min_confidence}).") 

470 return 0 

471 

472 print(f"# {prefix}Dependency Apply Report") 

473 print() 

474 

475 modified: set[str] = set() 

476 applied = 0 

477 

478 for proposal in filtered: 

479 source_path = issue_files.get(proposal.source_id) 

480 if source_path is None or not source_path.exists(): 

481 continue 

482 desc = ( 

483 f"{proposal.source_id} blocked by {proposal.target_id}" 

484 f" (confidence: {proposal.confidence:.2f})" 

485 ) 

486 print(f" {prefix}{desc}") 

487 applied += 1 

488 

489 if not args.dry_run: 

490 _add_to_section(source_path, "Blocked By", proposal.target_id) 

491 modified.add(str(source_path)) 

492 

493 print() 

494 if args.dry_run: 

495 print(f"[DRY RUN] {applied} relationship(s) would be applied.") 

496 else: 

497 print(f"{applied} relationship(s) applied.") 

498 if modified: 

499 print() 

500 print("Modified files:") 

501 for fpath in sorted(modified): 

502 print(f" {fpath}") 

503 

504 return 0 

505 

506 return 1