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

286 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-04 12:21 -0500

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

2 

3from __future__ import annotations 

4 

5import argparse 

6import sys 

7from pathlib import Path 

8 

9from little_loops.cli.output import configure_output, print_json, use_color_enabled 

10from little_loops.cli_args import add_json_arg 

11from little_loops.logger import Logger 

12from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context 

13 

14 

15def _load_issues( 

16 issues_dir: Path, 

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

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

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

20 

21 Args: 

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

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

24 

25 Returns: 

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

27 """ 

28 from little_loops.config import BRConfig 

29 from little_loops.issue_parser import find_issues 

30 

31 # Find project root by walking up from issues_dir 

32 project_root = issues_dir.resolve().parent 

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

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

35 project_root = issues_dir.parent 

36 

37 config = BRConfig(project_root) 

38 issues = find_issues(config, only_ids=only_ids) 

39 

40 # Build contents map 

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

42 for info in issues: 

43 if info.path.exists(): 

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

45 

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

47 from little_loops.issue_parser import IssueParser 

48 

49 parser = IssueParser(config) 

50 completed_ids: set[str] = set() 

51 for category in config.issue_categories: 

52 cat_dir = config.get_issue_dir(category) 

53 if not cat_dir.exists(): 

54 continue 

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

56 try: 

57 info = parser.parse_file(f) 

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

59 completed_ids.add(info.issue_id) 

60 except Exception: 

61 continue 

62 

63 return issues, issue_contents, completed_ids 

64 

65 

66def main_deps() -> int: 

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

68 

69 Analyze cross-issue dependencies and validate existing references. 

70 

71 Returns: 

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

73 """ 

74 with cli_event_context(DEFAULT_DB_PATH, "ll-deps", sys.argv[1:]): 

75 import json as _json 

76 

77 from little_loops.dependency_mapper import ( 

78 analyze_dependencies, 

79 fix_dependencies, 

80 format_epic_tree, 

81 format_report, 

82 format_text_graph, 

83 gather_all_issue_ids, 

84 validate_dependencies, 

85 ) 

86 

87 parser = argparse.ArgumentParser( 

88 prog="ll-deps", 

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

90 formatter_class=argparse.RawDescriptionHelpFormatter, 

91 epilog=""" 

92Examples: 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

107 %(prog)s tree --epic EPIC-1773 # Render EPIC child hierarchy 

108 %(prog)s tree --epic EPIC-1773 -f json # JSON output with nodes and edges 

109""", 

110 ) 

111 

112 parser.add_argument( 

113 "-d", 

114 "--issues-dir", 

115 type=Path, 

116 default=None, 

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

118 ) 

119 

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

121 

122 # analyze subcommand 

123 analyze_parser = subparsers.add_parser( 

124 "analyze", 

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

126 ) 

127 analyze_parser.add_argument( 

128 "-f", 

129 "--format", 

130 type=str, 

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

132 default="text", 

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

134 ) 

135 analyze_parser.add_argument( 

136 "--graph", 

137 action="store_true", 

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

139 ) 

140 analyze_parser.add_argument( 

141 "--sprint", 

142 type=str, 

143 default=None, 

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

145 ) 

146 

147 # validate subcommand 

148 validate_parser = subparsers.add_parser( 

149 "validate", 

150 help="Validate existing dependency references only", 

151 ) 

152 validate_parser.add_argument( 

153 "--sprint", 

154 type=str, 

155 default=None, 

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

157 ) 

158 add_json_arg(validate_parser) 

159 

160 # fix subcommand 

161 fix_parser = subparsers.add_parser( 

162 "fix", 

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

164 ) 

165 fix_parser.add_argument( 

166 "--dry-run", 

167 "-n", 

168 action="store_true", 

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

170 ) 

171 fix_parser.add_argument( 

172 "--sprint", 

173 type=str, 

174 default=None, 

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

176 ) 

177 

178 # apply subcommand 

179 apply_parser = subparsers.add_parser( 

180 "apply", 

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

182 ) 

183 apply_parser.add_argument( 

184 "source", 

185 nargs="?", 

186 default=None, 

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

188 ) 

189 apply_parser.add_argument( 

190 "relation", 

191 nargs="?", 

192 default=None, 

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

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

195 ) 

196 apply_parser.add_argument( 

197 "target", 

198 nargs="?", 

199 default=None, 

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

201 ) 

202 apply_parser.add_argument( 

203 "--min-confidence", 

204 type=float, 

205 default=0.7, 

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

207 ) 

208 apply_parser.add_argument( 

209 "--dry-run", 

210 "-n", 

211 action="store_true", 

212 help="Preview without writing", 

213 ) 

214 apply_parser.add_argument( 

215 "--sprint", 

216 type=str, 

217 default=None, 

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

219 ) 

220 

221 # tree subcommand 

222 tree_parser = subparsers.add_parser( 

223 "tree", 

224 help="Render EPIC child hierarchy with dependency edges", 

225 ) 

226 tree_parser.add_argument( 

227 "--epic", 

228 required=True, 

229 metavar="EPIC-NNN", 

230 help="EPIC issue ID to render (e.g. EPIC-1773)", 

231 ) 

232 tree_parser.add_argument( 

233 "-f", 

234 "--format", 

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

236 default="text", 

237 help="Output format (default: text)", 

238 ) 

239 

240 args = parser.parse_args() 

241 

242 configure_output() 

243 logger = Logger(use_color=use_color_enabled()) 

244 

245 if not args.command: 

246 parser.print_help() 

247 return 1 

248 

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

250 if not issues_dir.exists(): 

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

252 return 1 

253 

254 if args.command == "tree": 

255 from little_loops.config import BRConfig as _BRConfig 

256 from little_loops.dependency_graph import DependencyGraph as _DG 

257 from little_loops.issue_parser import find_issues as _find_issues 

258 

259 project_root = issues_dir.resolve().parent 

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

261 project_root = issues_dir.parent 

262 tree_cfg = _BRConfig(project_root) 

263 epic_id = args.epic.upper() 

264 

265 all_statuses = {"open", "in_progress", "blocked", "deferred", "done", "cancelled"} 

266 all_issues = _find_issues(tree_cfg, status_filter=all_statuses) 

267 

268 epic_matches = [i for i in all_issues if i.issue_id == epic_id] 

269 if not epic_matches: 

270 logger.error(f"EPIC {epic_id!r} not found") 

271 return 1 

272 epic_info = epic_matches[0] 

273 

274 forward_ids: set[str] = set(epic_info.relates_to) 

275 backward_ids: set[str] = {i.issue_id for i in all_issues if i.parent == epic_id} 

276 all_child_ids = forward_ids | backward_ids 

277 

278 if not all_child_ids: 

279 print(f"{epic_id}: (no children)") 

280 return 0 

281 

282 child_issues = [i for i in all_issues if i.issue_id in all_child_ids] 

283 done_ids = {i.issue_id for i in child_issues if i.status in {"done", "deferred"}} 

284 graph = _DG.from_issues( 

285 child_issues, completed_ids=done_ids, all_known_ids=all_child_ids 

286 ) 

287 child_map = {i.issue_id: i for i in child_issues} 

288 

289 if args.format == "json": 

290 data = { 

291 "root": epic_id, 

292 "nodes": [ 

293 { 

294 "id": i.issue_id, 

295 "title": i.title, 

296 "status": i.status, 

297 "parent": i.parent, 

298 } 

299 for i in child_issues 

300 ], 

301 "edges": [ 

302 {"from": src, "to": tgt, "kind": "blocks"} 

303 for src, targets in graph.blocks.items() 

304 for tgt in sorted(targets) 

305 ], 

306 } 

307 print_json(data) 

308 else: 

309 print( 

310 format_epic_tree( 

311 epic_id, epic_info, child_map, graph, use_color=use_color_enabled() 

312 ) 

313 ) 

314 

315 return 0 

316 

317 # Sprint-scoped filtering 

318 only_ids: set[str] | None = None 

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

320 from little_loops.config import BRConfig as _BRConfig 

321 from little_loops.sprint import Sprint 

322 

323 project_root = issues_dir.resolve().parent 

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

325 project_root = issues_dir.parent 

326 _config = _BRConfig(project_root) 

327 sprints_dir = Path(_config.sprints.sprints_dir) 

328 if not sprints_dir.is_absolute(): 

329 sprints_dir = project_root / sprints_dir 

330 

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

332 if sprint is None: 

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

334 return 1 

335 only_ids = set(sprint.issues) 

336 if not only_ids: 

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

338 return 0 

339 

340 try: 

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

342 except Exception as e: 

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

344 return 1 

345 

346 if not issues: 

347 if getattr(args, "json", False): 

348 print_json( 

349 { 

350 "has_issues": False, 

351 "broken_refs": [], 

352 "missing_backlinks": [], 

353 "cycles": [], 

354 "stale_completed_refs": [], 

355 "broken_depends_on_refs": [], 

356 "broken_relates_to_refs": [], 

357 } 

358 ) 

359 return 0 

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

361 return 0 

362 

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

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

365 try: 

366 from little_loops.config import BRConfig as _BRConfig 

367 

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

369 except Exception: 

370 _dm_config = None 

371 all_known_ids = gather_all_issue_ids(issues_dir, config=_dm_config) 

372 

373 # Load dependency mapping config 

374 dep_config = _dm_config.dependency_mapping if _dm_config else None 

375 

376 if args.command == "analyze": 

377 report = analyze_dependencies( 

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

379 ) 

380 

381 if args.format == "json": 

382 data = { 

383 "issue_count": report.issue_count, 

384 "existing_dep_count": report.existing_dep_count, 

385 "proposals": [ 

386 { 

387 "source_id": p.source_id, 

388 "target_id": p.target_id, 

389 "reason": p.reason, 

390 "confidence": p.confidence, 

391 "rationale": p.rationale, 

392 "overlapping_files": p.overlapping_files, 

393 "conflict_score": p.conflict_score, 

394 } 

395 for p in report.proposals 

396 ], 

397 "parallel_safe": [ 

398 { 

399 "issue_a": ps.issue_a, 

400 "issue_b": ps.issue_b, 

401 "shared_files": ps.shared_files, 

402 "conflict_score": ps.conflict_score, 

403 "reason": ps.reason, 

404 } 

405 for ps in report.parallel_safe 

406 ], 

407 "validation": { 

408 "broken_refs": report.validation.broken_refs, 

409 "missing_backlinks": report.validation.missing_backlinks, 

410 "cycles": report.validation.cycles, 

411 "stale_completed_refs": report.validation.stale_completed_refs, 

412 "broken_depends_on_refs": report.validation.broken_depends_on_refs, 

413 "broken_relates_to_refs": report.validation.broken_relates_to_refs, 

414 "has_issues": report.validation.has_issues, 

415 }, 

416 } 

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

418 else: 

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

420 if args.graph: 

421 print() 

422 print("## Dependency Graph") 

423 print() 

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

425 

426 return 0 

427 

428 if args.command == "validate": 

429 result = validate_dependencies(issues, completed_ids, all_known_ids) 

430 

431 if args.json: 

432 print_json( 

433 { 

434 "has_issues": result.has_issues, 

435 "broken_refs": [list(pair) for pair in result.broken_refs], 

436 "missing_backlinks": [list(pair) for pair in result.missing_backlinks], 

437 "cycles": result.cycles, 

438 "stale_completed_refs": [ 

439 list(pair) for pair in result.stale_completed_refs 

440 ], 

441 "broken_depends_on_refs": [ 

442 list(pair) for pair in result.broken_depends_on_refs 

443 ], 

444 "broken_relates_to_refs": [ 

445 list(pair) for pair in result.broken_relates_to_refs 

446 ], 

447 } 

448 ) 

449 return 0 

450 

451 if not result.has_issues: 

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

453 return 0 

454 

455 lines: list[str] = [] 

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

457 lines.append("") 

458 

459 if result.broken_refs: 

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

461 lines.append("") 

462 for issue_id, ref_id in result.broken_refs: 

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

464 lines.append("") 

465 

466 if result.missing_backlinks: 

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

468 lines.append("") 

469 for issue_id, ref_id in result.missing_backlinks: 

470 lines.append( 

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

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

473 ) 

474 lines.append("") 

475 

476 if result.cycles: 

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

478 lines.append("") 

479 for cycle in result.cycles: 

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

481 lines.append("") 

482 

483 if result.stale_completed_refs: 

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

485 lines.append("") 

486 for issue_id, ref_id in result.stale_completed_refs: 

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

488 lines.append("") 

489 

490 if result.broken_depends_on_refs: 

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

492 lines.append("") 

493 for issue_id, ref_id in result.broken_depends_on_refs: 

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

495 lines.append("") 

496 

497 if result.broken_relates_to_refs: 

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

499 lines.append("") 

500 for issue_id, ref_id in result.broken_relates_to_refs: 

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

502 lines.append("") 

503 

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

505 return 0 

506 

507 if args.command == "fix": 

508 fix_result = fix_dependencies( 

509 issues, completed_ids, all_known_ids, dry_run=args.dry_run 

510 ) 

511 

512 if not fix_result.changes: 

513 print("No fixable issues found.") 

514 if fix_result.skipped_cycles: 

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

516 return 0 

517 

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

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

520 print() 

521 for change in fix_result.changes: 

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

523 print() 

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

525 

526 if fix_result.modified_files: 

527 print() 

528 print("Modified files:") 

529 for fpath in sorted(fix_result.modified_files): 

530 print(f" {fpath}") 

531 

532 if fix_result.skipped_cycles: 

533 print() 

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

535 

536 return 0 

537 

538 if args.command == "apply": 

539 from little_loops.dependency_mapper.operations import _add_to_section 

540 

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

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

543 

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

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

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

547 logger.error( 

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

549 ) 

550 return 1 

551 

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

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

554 if id_to_check not in all_ids: 

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

556 return 1 

557 

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

559 if args.relation == "blocks": 

560 # source blocks target → target is blocked by source 

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

562 else: # blocked-by 

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

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

565 

566 blocked_path = issue_files.get(blocked_id) 

567 if blocked_path is None: 

568 logger.error( 

569 f"issue {blocked_id!r} is not in active issues (cannot write to it)" 

570 ) 

571 return 1 

572 

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

574 print() 

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

576 print() 

577 

578 if not args.dry_run: 

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

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

581 print() 

582 print("Modified files:") 

583 print(f" {blocked_path}") 

584 else: 

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

586 

587 return 0 

588 

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

590 report = analyze_dependencies( 

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

592 ) 

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

594 

595 if not filtered: 

596 logger.info( 

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

598 ) 

599 return 0 

600 

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

602 print() 

603 

604 modified: set[str] = set() 

605 applied = 0 

606 

607 for proposal in filtered: 

608 source_path = issue_files.get(proposal.source_id) 

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

610 continue 

611 desc = ( 

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

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

614 ) 

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

616 applied += 1 

617 

618 if not args.dry_run: 

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

620 modified.add(str(source_path)) 

621 

622 print() 

623 if args.dry_run: 

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

625 else: 

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

627 if modified: 

628 print() 

629 print("Modified files:") 

630 for fpath in sorted(modified): 

631 print(f" {fpath}") 

632 

633 return 0 

634 

635 return 1