Coverage for little_loops / cli / deps.py: 3%
288 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-15 17:30 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-15 17:30 -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_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
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 add_intent_arg(parser)
241 add_intent_limit_arg(parser)
243 args = parser.parse_args()
245 configure_output()
246 logger = Logger(use_color=use_color_enabled())
248 if not args.command:
249 parser.print_help()
250 return 1
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
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
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()
268 all_statuses = {"open", "in_progress", "blocked", "deferred", "done", "cancelled"}
269 all_issues = _find_issues(tree_cfg, status_filter=all_statuses)
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]
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
281 if not all_child_ids:
282 print(f"{epic_id}: (no children)")
283 return 0
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}
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 )
318 return 0
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
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
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
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
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
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
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)
376 # Load dependency mapping config
377 dep_config = _dm_config.dependency_mapping if _dm_config else None
379 if args.command == "analyze":
380 report = analyze_dependencies(
381 issues, issue_contents, completed_ids, all_known_ids, config=dep_config
382 )
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))
429 return 0
431 if args.command == "validate":
432 result = validate_dependencies(issues, completed_ids, all_known_ids)
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
454 if not result.has_issues:
455 logger.info("No validation issues found.")
456 return 0
458 lines: list[str] = []
459 lines.append("# Dependency Validation Report")
460 lines.append("")
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("")
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("")
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("")
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("")
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("")
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("")
507 print("\n".join(lines))
508 return 0
510 if args.command == "fix":
511 fix_result = fix_dependencies(
512 issues, completed_ids, all_known_ids, dry_run=args.dry_run
513 )
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
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.")
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}")
535 if fix_result.skipped_cycles:
536 print()
537 print(f"({fix_result.skipped_cycles} cycle(s) detected — resolve manually)")
539 return 0
541 if args.command == "apply":
542 from little_loops.dependency_mapper.operations import _add_to_section
544 prefix = "[DRY RUN] " if args.dry_run else ""
545 issue_files = {i.issue_id: i.path for i in issues}
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
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
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
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
576 print(f"# {prefix}Dependency Apply Report")
577 print()
578 print(f" {prefix}{blocked_id} blocked by {blocker_id}")
579 print()
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.")
590 return 0
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]
598 if not filtered:
599 logger.info(
600 f"No proposals at or above confidence threshold ({args.min_confidence})."
601 )
602 return 0
604 print(f"# {prefix}Dependency Apply Report")
605 print()
607 modified: set[str] = set()
608 applied = 0
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
621 if not args.dry_run:
622 _add_to_section(source_path, "Blocked By", proposal.target_id)
623 modified.add(str(source_path))
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}")
636 return 0
638 return 1