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
« 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."""
3from __future__ import annotations
5import argparse
6import sys
7from pathlib import Path
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
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.
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
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
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
37 config = BRConfig(project_root)
38 issues = find_issues(config, only_ids=only_ids)
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")
46 # Gather completed and deferred issue IDs via status-field scan of type dirs
47 from little_loops.issue_parser import IssueParser
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
63 return issues, issue_contents, completed_ids
66def main_deps() -> int:
67 """Entry point for ll-deps command.
69 Analyze cross-issue dependencies and validate existing references.
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
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 )
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 )
112 parser.add_argument(
113 "-d",
114 "--issues-dir",
115 type=Path,
116 default=None,
117 help="Path to issues directory (default: .issues)",
118 )
120 subparsers = parser.add_subparsers(dest="command", help="Available commands")
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 )
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)
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 )
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 )
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 )
240 args = parser.parse_args()
242 configure_output()
243 logger = Logger(use_color=use_color_enabled())
245 if not args.command:
246 parser.print_help()
247 return 1
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
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
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()
265 all_statuses = {"open", "in_progress", "blocked", "deferred", "done", "cancelled"}
266 all_issues = _find_issues(tree_cfg, status_filter=all_statuses)
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]
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
278 if not all_child_ids:
279 print(f"{epic_id}: (no children)")
280 return 0
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}
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 )
315 return 0
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
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
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
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
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
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
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)
373 # Load dependency mapping config
374 dep_config = _dm_config.dependency_mapping if _dm_config else None
376 if args.command == "analyze":
377 report = analyze_dependencies(
378 issues, issue_contents, completed_ids, all_known_ids, config=dep_config
379 )
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))
426 return 0
428 if args.command == "validate":
429 result = validate_dependencies(issues, completed_ids, all_known_ids)
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
451 if not result.has_issues:
452 logger.info("No validation issues found.")
453 return 0
455 lines: list[str] = []
456 lines.append("# Dependency Validation Report")
457 lines.append("")
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("")
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("")
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("")
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("")
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("")
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("")
504 print("\n".join(lines))
505 return 0
507 if args.command == "fix":
508 fix_result = fix_dependencies(
509 issues, completed_ids, all_known_ids, dry_run=args.dry_run
510 )
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
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.")
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}")
532 if fix_result.skipped_cycles:
533 print()
534 print(f"({fix_result.skipped_cycles} cycle(s) detected — resolve manually)")
536 return 0
538 if args.command == "apply":
539 from little_loops.dependency_mapper.operations import _add_to_section
541 prefix = "[DRY RUN] " if args.dry_run else ""
542 issue_files = {i.issue_id: i.path for i in issues}
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
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
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
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
573 print(f"# {prefix}Dependency Apply Report")
574 print()
575 print(f" {prefix}{blocked_id} blocked by {blocker_id}")
576 print()
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.")
587 return 0
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]
595 if not filtered:
596 logger.info(
597 f"No proposals at or above confidence threshold ({args.min_confidence})."
598 )
599 return 0
601 print(f"# {prefix}Dependency Apply Report")
602 print()
604 modified: set[str] = set()
605 applied = 0
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
618 if not args.dry_run:
619 _add_to_section(source_path, "Blocked By", proposal.target_id)
620 modified.add(str(source_path))
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}")
633 return 0
635 return 1