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

288 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-26 17:38 -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_intent_arg, add_intent_limit_arg, 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 add_intent_arg(parser) 

241 add_intent_limit_arg(parser) 

242 

243 args = parser.parse_args() 

244 

245 configure_output() 

246 logger = Logger(use_color=use_color_enabled()) 

247 

248 if not args.command: 

249 parser.print_help() 

250 return 1 

251 

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

253 if not issues_dir.exists(): 

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

255 return 1 

256 

257 if args.command == "tree": 

258 from little_loops.config import BRConfig as _BRConfig 

259 from little_loops.dependency_graph import DependencyGraph as _DG 

260 from little_loops.issue_parser import find_issues as _find_issues 

261 

262 project_root = issues_dir.resolve().parent 

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

264 project_root = issues_dir.parent 

265 tree_cfg = _BRConfig(project_root) 

266 epic_id = args.epic.upper() 

267 

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

269 all_issues = _find_issues(tree_cfg, status_filter=all_statuses) 

270 

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

272 if not epic_matches: 

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

274 return 1 

275 epic_info = epic_matches[0] 

276 

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

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

279 all_child_ids = forward_ids | backward_ids 

280 

281 if not all_child_ids: 

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

283 return 0 

284 

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

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

287 graph = _DG.from_issues( 

288 child_issues, completed_ids=done_ids, all_known_ids=all_child_ids 

289 ) 

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

291 

292 if args.format == "json": 

293 data = { 

294 "root": epic_id, 

295 "nodes": [ 

296 { 

297 "id": i.issue_id, 

298 "title": i.title, 

299 "status": i.status, 

300 "parent": i.parent, 

301 } 

302 for i in child_issues 

303 ], 

304 "edges": [ 

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

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

307 for tgt in sorted(targets) 

308 ], 

309 } 

310 print_json(data) 

311 else: 

312 print( 

313 format_epic_tree( 

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

315 ) 

316 ) 

317 

318 return 0 

319 

320 # Sprint-scoped filtering 

321 only_ids: set[str] | None = None 

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

323 from little_loops.config import BRConfig as _BRConfig 

324 from little_loops.sprint import Sprint 

325 

326 project_root = issues_dir.resolve().parent 

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

328 project_root = issues_dir.parent 

329 _config = _BRConfig(project_root) 

330 sprints_dir = Path(_config.sprints.sprints_dir) 

331 if not sprints_dir.is_absolute(): 

332 sprints_dir = project_root / sprints_dir 

333 

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

335 if sprint is None: 

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

337 return 1 

338 only_ids = set(sprint.issues) 

339 if not only_ids: 

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

341 return 0 

342 

343 try: 

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

345 except Exception as e: 

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

347 return 1 

348 

349 if not issues: 

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

351 print_json( 

352 { 

353 "has_issues": False, 

354 "broken_refs": [], 

355 "missing_backlinks": [], 

356 "cycles": [], 

357 "stale_completed_refs": [], 

358 "broken_depends_on_refs": [], 

359 "broken_relates_to_refs": [], 

360 } 

361 ) 

362 return 0 

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

364 return 0 

365 

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

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

368 try: 

369 from little_loops.config import BRConfig as _BRConfig 

370 

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

372 except Exception: 

373 _dm_config = None 

374 all_known_ids = gather_all_issue_ids(issues_dir, config=_dm_config) 

375 

376 # Load dependency mapping config 

377 dep_config = _dm_config.dependency_mapping if _dm_config else None 

378 

379 if args.command == "analyze": 

380 report = analyze_dependencies( 

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

382 ) 

383 

384 if args.format == "json": 

385 data = { 

386 "issue_count": report.issue_count, 

387 "existing_dep_count": report.existing_dep_count, 

388 "proposals": [ 

389 { 

390 "source_id": p.source_id, 

391 "target_id": p.target_id, 

392 "reason": p.reason, 

393 "confidence": p.confidence, 

394 "rationale": p.rationale, 

395 "overlapping_files": p.overlapping_files, 

396 "conflict_score": p.conflict_score, 

397 } 

398 for p in report.proposals 

399 ], 

400 "parallel_safe": [ 

401 { 

402 "issue_a": ps.issue_a, 

403 "issue_b": ps.issue_b, 

404 "shared_files": ps.shared_files, 

405 "conflict_score": ps.conflict_score, 

406 "reason": ps.reason, 

407 } 

408 for ps in report.parallel_safe 

409 ], 

410 "validation": { 

411 "broken_refs": report.validation.broken_refs, 

412 "missing_backlinks": report.validation.missing_backlinks, 

413 "cycles": report.validation.cycles, 

414 "stale_completed_refs": report.validation.stale_completed_refs, 

415 "broken_depends_on_refs": report.validation.broken_depends_on_refs, 

416 "broken_relates_to_refs": report.validation.broken_relates_to_refs, 

417 "has_issues": report.validation.has_issues, 

418 }, 

419 } 

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

421 else: 

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

423 if args.graph: 

424 print() 

425 print("## Dependency Graph") 

426 print() 

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

428 

429 return 0 

430 

431 if args.command == "validate": 

432 result = validate_dependencies(issues, completed_ids, all_known_ids) 

433 

434 if args.json: 

435 print_json( 

436 { 

437 "has_issues": result.has_issues, 

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

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

440 "cycles": result.cycles, 

441 "stale_completed_refs": [ 

442 list(pair) for pair in result.stale_completed_refs 

443 ], 

444 "broken_depends_on_refs": [ 

445 list(pair) for pair in result.broken_depends_on_refs 

446 ], 

447 "broken_relates_to_refs": [ 

448 list(pair) for pair in result.broken_relates_to_refs 

449 ], 

450 } 

451 ) 

452 return 0 

453 

454 if not result.has_issues: 

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

456 return 0 

457 

458 lines: list[str] = [] 

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

460 lines.append("") 

461 

462 if result.broken_refs: 

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

464 lines.append("") 

465 for issue_id, ref_id in result.broken_refs: 

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

467 lines.append("") 

468 

469 if result.missing_backlinks: 

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

471 lines.append("") 

472 for issue_id, ref_id in result.missing_backlinks: 

473 lines.append( 

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

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

476 ) 

477 lines.append("") 

478 

479 if result.cycles: 

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

481 lines.append("") 

482 for cycle in result.cycles: 

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

484 lines.append("") 

485 

486 if result.stale_completed_refs: 

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

488 lines.append("") 

489 for issue_id, ref_id in result.stale_completed_refs: 

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

491 lines.append("") 

492 

493 if result.broken_depends_on_refs: 

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

495 lines.append("") 

496 for issue_id, ref_id in result.broken_depends_on_refs: 

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

498 lines.append("") 

499 

500 if result.broken_relates_to_refs: 

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

502 lines.append("") 

503 for issue_id, ref_id in result.broken_relates_to_refs: 

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

505 lines.append("") 

506 

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

508 return 0 

509 

510 if args.command == "fix": 

511 fix_result = fix_dependencies( 

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

513 ) 

514 

515 if not fix_result.changes: 

516 print("No fixable issues found.") 

517 if fix_result.skipped_cycles: 

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

519 return 0 

520 

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

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

523 print() 

524 for change in fix_result.changes: 

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

526 print() 

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

528 

529 if fix_result.modified_files: 

530 print() 

531 print("Modified files:") 

532 for fpath in sorted(fix_result.modified_files): 

533 print(f" {fpath}") 

534 

535 if fix_result.skipped_cycles: 

536 print() 

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

538 

539 return 0 

540 

541 if args.command == "apply": 

542 from little_loops.dependency_mapper.operations import _add_to_section 

543 

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

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

546 

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

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

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

550 logger.error( 

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

552 ) 

553 return 1 

554 

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

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

557 if id_to_check not in all_ids: 

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

559 return 1 

560 

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

562 if args.relation == "blocks": 

563 # source blocks target → target is blocked by source 

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

565 else: # blocked-by 

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

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

568 

569 blocked_path = issue_files.get(blocked_id) 

570 if blocked_path is None: 

571 logger.error( 

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

573 ) 

574 return 1 

575 

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

577 print() 

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

579 print() 

580 

581 if not args.dry_run: 

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

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

584 print() 

585 print("Modified files:") 

586 print(f" {blocked_path}") 

587 else: 

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

589 

590 return 0 

591 

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

593 report = analyze_dependencies( 

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

595 ) 

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

597 

598 if not filtered: 

599 logger.info( 

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

601 ) 

602 return 0 

603 

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

605 print() 

606 

607 modified: set[str] = set() 

608 applied = 0 

609 

610 for proposal in filtered: 

611 source_path = issue_files.get(proposal.source_id) 

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

613 continue 

614 desc = ( 

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

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

617 ) 

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

619 applied += 1 

620 

621 if not args.dry_run: 

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

623 modified.add(str(source_path)) 

624 

625 print() 

626 if args.dry_run: 

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

628 else: 

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

630 if modified: 

631 print() 

632 print("Modified files:") 

633 for fpath in sorted(modified): 

634 print(f" {fpath}") 

635 

636 return 0 

637 

638 return 1