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

2

3 from __future__ import annotations

4

5 import argparse

6 import sys

7 from pathlib import Path

8

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

10 from little_loops.cli_args import add_intent_arg, add_intent_limit_arg, add_json_arg

11 from little_loops.logger import Logger

12 from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context

13

14

15 def _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

66 def 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="""

92 Examples:

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