Coverage for little_loops / cli / deps.py: 81%
241 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-22 16:19 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-22 16:19 -0500
1"""ll-deps: Cross-issue dependency discovery and validation."""
3from __future__ import annotations
5import argparse
6from pathlib import Path
8from little_loops.cli.output import configure_output, use_color_enabled
9from little_loops.logger import Logger
12def _load_issues(
13 issues_dir: Path,
14 only_ids: set[str] | None = None,
15) -> tuple[list, dict[str, str], set[str]]:
16 """Load issues from directory for CLI use.
18 Args:
19 issues_dir: Path to the issues base directory (e.g., .issues)
20 only_ids: If provided, only include issues with these IDs
22 Returns:
23 Tuple of (active issues, issue contents map, completed issue IDs)
24 """
25 from little_loops.config import BRConfig
26 from little_loops.issue_parser import find_issues
28 # Find project root by walking up from issues_dir
29 project_root = issues_dir.resolve().parent
30 if issues_dir.name != ".issues":
31 # If issues_dir is already absolute, try to find config relative to it
32 project_root = issues_dir.parent
34 config = BRConfig(project_root)
35 issues = find_issues(config, only_ids=only_ids)
37 # Build contents map
38 issue_contents: dict[str, str] = {}
39 for info in issues:
40 if info.path.exists():
41 issue_contents[info.issue_id] = info.path.read_text(encoding="utf-8")
43 # Gather completed and deferred issue IDs via status-field scan of type dirs
44 from little_loops.issue_parser import IssueParser
46 parser = IssueParser(config)
47 completed_ids: set[str] = set()
48 for category in config.issue_categories:
49 cat_dir = config.get_issue_dir(category)
50 if not cat_dir.exists():
51 continue
52 for f in cat_dir.glob("*.md"):
53 try:
54 info = parser.parse_file(f)
55 if info.status in ("done", "deferred"):
56 completed_ids.add(info.issue_id)
57 except Exception:
58 continue
60 return issues, issue_contents, completed_ids
63def main_deps() -> int:
64 """Entry point for ll-deps command.
66 Analyze cross-issue dependencies and validate existing references.
68 Returns:
69 Exit code (0 = success, 1 = failure)
70 """
71 import json as _json
73 from little_loops.dependency_mapper import (
74 analyze_dependencies,
75 fix_dependencies,
76 format_report,
77 format_text_graph,
78 gather_all_issue_ids,
79 validate_dependencies,
80 )
82 parser = argparse.ArgumentParser(
83 prog="ll-deps",
84 description="Cross-issue dependency discovery and validation",
85 formatter_class=argparse.RawDescriptionHelpFormatter,
86 epilog="""
87Examples:
88 %(prog)s analyze # Full analysis with markdown output
89 %(prog)s analyze --format json # JSON output for programmatic use
90 %(prog)s analyze --graph # Include ASCII dependency graph
91 %(prog)s analyze --sprint my-sprint # Analyze only issues in a sprint
92 %(prog)s validate # Validation only (broken refs, cycles)
93 %(prog)s validate --sprint my-sprint # Validate only sprint issue deps
94 %(prog)s fix # Auto-fix broken refs, stale refs, backlinks
95 %(prog)s fix --dry-run # Preview fixes without modifying files
96 %(prog)s apply # Apply proposals >= 0.7 confidence
97 %(prog)s apply --min-confidence 0.5 # Lower threshold
98 %(prog)s apply --dry-run # Preview only (no writes)
99 %(prog)s apply --sprint my-sprint # Sprint-scoped apply
100 %(prog)s apply FEAT-001 blocks FEAT-002 # Manual explicit pair
101 %(prog)s apply FEAT-001 blocked-by FEAT-002 # Manual explicit pair (inverse)
102""",
103 )
105 parser.add_argument(
106 "-d",
107 "--issues-dir",
108 type=Path,
109 default=None,
110 help="Path to issues directory (default: .issues)",
111 )
113 subparsers = parser.add_subparsers(dest="command", help="Available commands")
115 # analyze subcommand
116 analyze_parser = subparsers.add_parser(
117 "analyze",
118 help="Full dependency analysis (file overlaps + validation)",
119 )
120 analyze_parser.add_argument(
121 "-f",
122 "--format",
123 type=str,
124 choices=["text", "json"],
125 default="text",
126 help="Output format (default: text/markdown)",
127 )
128 analyze_parser.add_argument(
129 "--graph",
130 action="store_true",
131 help="Include ASCII dependency graph in output",
132 )
133 analyze_parser.add_argument(
134 "--sprint",
135 type=str,
136 default=None,
137 help="Restrict analysis to issues in the named sprint",
138 )
140 # validate subcommand
141 validate_parser = subparsers.add_parser(
142 "validate",
143 help="Validate existing dependency references only",
144 )
145 validate_parser.add_argument(
146 "--sprint",
147 type=str,
148 default=None,
149 help="Restrict validation to issues in the named sprint",
150 )
152 # fix subcommand
153 fix_parser = subparsers.add_parser(
154 "fix",
155 help="Auto-fix broken refs, stale refs, and missing backlinks",
156 )
157 fix_parser.add_argument(
158 "--dry-run",
159 "-n",
160 action="store_true",
161 help="Show what would be fixed without making changes",
162 )
163 fix_parser.add_argument(
164 "--sprint",
165 type=str,
166 default=None,
167 help="Restrict fixes to issues in the named sprint",
168 )
170 # apply subcommand
171 apply_parser = subparsers.add_parser(
172 "apply",
173 help="Write dependency relationships to issue files",
174 )
175 apply_parser.add_argument(
176 "source",
177 nargs="?",
178 default=None,
179 help="Source issue ID for explicit pair (e.g. FEAT-001)",
180 )
181 apply_parser.add_argument(
182 "relation",
183 nargs="?",
184 default=None,
185 choices=["blocks", "blocked-by"],
186 help="Relationship direction: 'blocks' or 'blocked-by'",
187 )
188 apply_parser.add_argument(
189 "target",
190 nargs="?",
191 default=None,
192 help="Target issue ID for explicit pair (e.g. FEAT-002)",
193 )
194 apply_parser.add_argument(
195 "--min-confidence",
196 type=float,
197 default=0.7,
198 help="Minimum confidence threshold for implicit apply (default: 0.7)",
199 )
200 apply_parser.add_argument(
201 "--dry-run",
202 "-n",
203 action="store_true",
204 help="Preview without writing",
205 )
206 apply_parser.add_argument(
207 "--sprint",
208 type=str,
209 default=None,
210 help="Restrict to issues in named sprint",
211 )
213 args = parser.parse_args()
215 configure_output()
216 logger = Logger(use_color=use_color_enabled())
218 if not args.command:
219 parser.print_help()
220 return 1
222 issues_dir = args.issues_dir or Path.cwd() / ".issues"
223 if not issues_dir.exists():
224 logger.error(f"Issues directory not found: {issues_dir}")
225 return 1
227 # Sprint-scoped filtering
228 only_ids: set[str] | None = None
229 if getattr(args, "sprint", None):
230 from little_loops.config import BRConfig as _BRConfig
231 from little_loops.sprint import Sprint
233 project_root = issues_dir.resolve().parent
234 if issues_dir.name != ".issues":
235 project_root = issues_dir.parent
236 _config = _BRConfig(project_root)
237 sprints_dir = Path(_config.sprints.sprints_dir)
238 if not sprints_dir.is_absolute():
239 sprints_dir = project_root / sprints_dir
241 sprint = Sprint.load(sprints_dir, args.sprint)
242 if sprint is None:
243 logger.error(f"Sprint not found: {args.sprint}")
244 return 1
245 only_ids = set(sprint.issues)
246 if not only_ids:
247 logger.warning(f"Sprint '{args.sprint}' has no issues.")
248 return 0
250 try:
251 issues, issue_contents, completed_ids = _load_issues(issues_dir, only_ids=only_ids)
252 except Exception as e:
253 logger.error(f"Error loading issues: {e}")
254 return 1
256 if not issues:
257 logger.warning("No active issues found.")
258 return 0
260 # Gather all issue IDs on disk to avoid false "nonexistent" warnings
261 # when sprint-scoped analysis references issues outside the sprint
262 try:
263 from little_loops.config import BRConfig as _BRConfig
265 _dm_config = _BRConfig(issues_dir.resolve().parent)
266 except Exception:
267 _dm_config = None
268 all_known_ids = gather_all_issue_ids(issues_dir, config=_dm_config)
270 # Load dependency mapping config
271 dep_config = _dm_config.dependency_mapping if _dm_config else None
273 if args.command == "analyze":
274 report = analyze_dependencies(
275 issues, issue_contents, completed_ids, all_known_ids, config=dep_config
276 )
278 if args.format == "json":
279 data = {
280 "issue_count": report.issue_count,
281 "existing_dep_count": report.existing_dep_count,
282 "proposals": [
283 {
284 "source_id": p.source_id,
285 "target_id": p.target_id,
286 "reason": p.reason,
287 "confidence": p.confidence,
288 "rationale": p.rationale,
289 "overlapping_files": p.overlapping_files,
290 "conflict_score": p.conflict_score,
291 }
292 for p in report.proposals
293 ],
294 "parallel_safe": [
295 {
296 "issue_a": ps.issue_a,
297 "issue_b": ps.issue_b,
298 "shared_files": ps.shared_files,
299 "conflict_score": ps.conflict_score,
300 "reason": ps.reason,
301 }
302 for ps in report.parallel_safe
303 ],
304 "validation": {
305 "broken_refs": report.validation.broken_refs,
306 "missing_backlinks": report.validation.missing_backlinks,
307 "cycles": report.validation.cycles,
308 "stale_completed_refs": report.validation.stale_completed_refs,
309 "broken_depends_on_refs": report.validation.broken_depends_on_refs,
310 "broken_relates_to_refs": report.validation.broken_relates_to_refs,
311 "has_issues": report.validation.has_issues,
312 },
313 }
314 print(_json.dumps(data, indent=2))
315 else:
316 print(format_report(report, config=dep_config))
317 if args.graph:
318 print()
319 print("## Dependency Graph")
320 print()
321 print(format_text_graph(issues, report.proposals))
323 return 0
325 if args.command == "validate":
326 result = validate_dependencies(issues, completed_ids, all_known_ids)
328 if not result.has_issues:
329 logger.info("No validation issues found.")
330 return 0
332 lines: list[str] = []
333 lines.append("# Dependency Validation Report")
334 lines.append("")
336 if result.broken_refs:
337 lines.append("## Broken References")
338 lines.append("")
339 for issue_id, ref_id in result.broken_refs:
340 lines.append(f"- {issue_id}: references nonexistent {ref_id}")
341 lines.append("")
343 if result.missing_backlinks:
344 lines.append("## Missing Backlinks")
345 lines.append("")
346 for issue_id, ref_id in result.missing_backlinks:
347 lines.append(
348 f"- {issue_id} is blocked by {ref_id}, "
349 f"but {ref_id} does not list {issue_id} in Blocks"
350 )
351 lines.append("")
353 if result.cycles:
354 lines.append("## Dependency Cycles")
355 lines.append("")
356 for cycle in result.cycles:
357 lines.append(f"- {' -> '.join(cycle)}")
358 lines.append("")
360 if result.stale_completed_refs:
361 lines.append("## Stale References (to completed issues)")
362 lines.append("")
363 for issue_id, ref_id in result.stale_completed_refs:
364 lines.append(f"- {issue_id}: blocked by {ref_id} (completed)")
365 lines.append("")
367 if result.broken_depends_on_refs:
368 lines.append("## Broken Depends-On References")
369 lines.append("")
370 for issue_id, ref_id in result.broken_depends_on_refs:
371 lines.append(f"- {issue_id}: depends_on references nonexistent {ref_id}")
372 lines.append("")
374 if result.broken_relates_to_refs:
375 lines.append("## Broken Relates-To References")
376 lines.append("")
377 for issue_id, ref_id in result.broken_relates_to_refs:
378 lines.append(f"- {issue_id}: relates_to references nonexistent {ref_id}")
379 lines.append("")
381 print("\n".join(lines))
382 return 0
384 if args.command == "fix":
385 fix_result = fix_dependencies(issues, completed_ids, all_known_ids, dry_run=args.dry_run)
387 if not fix_result.changes:
388 print("No fixable issues found.")
389 if fix_result.skipped_cycles:
390 print(f"({fix_result.skipped_cycles} cycle(s) detected — resolve manually)")
391 return 0
393 prefix = "[DRY RUN] " if args.dry_run else ""
394 print(f"# {prefix}Dependency Fix Report")
395 print()
396 for change in fix_result.changes:
397 print(f" {prefix}{change}")
398 print()
399 print(f"{prefix}{len(fix_result.changes)} fix(es) applied.")
401 if fix_result.modified_files:
402 print()
403 print("Modified files:")
404 for fpath in sorted(fix_result.modified_files):
405 print(f" {fpath}")
407 if fix_result.skipped_cycles:
408 print()
409 print(f"({fix_result.skipped_cycles} cycle(s) detected — resolve manually)")
411 return 0
413 if args.command == "apply":
414 from little_loops.dependency_mapper.operations import _add_to_section
416 prefix = "[DRY RUN] " if args.dry_run else ""
417 issue_files = {i.issue_id: i.path for i in issues}
419 # Explicit-pair mode: all three positional args must be provided together
420 if args.source or args.relation or args.target:
421 if not (args.source and args.relation and args.target):
422 logger.error(
423 "explicit pair requires all three arguments: <source> <relation> <target>"
424 )
425 return 1
427 all_ids = {i.issue_id for i in issues} | all_known_ids
428 for id_to_check, label in [(args.source, "source"), (args.target, "target")]:
429 if id_to_check not in all_ids:
430 logger.error(f"{label} issue {id_to_check!r} not found")
431 return 1
433 # Determine which issue receives the "Blocked By" entry
434 if args.relation == "blocks":
435 # source blocks target → target is blocked by source
436 blocked_id, blocker_id = args.target, args.source
437 else: # blocked-by
438 # source blocked-by target → source is blocked by target
439 blocked_id, blocker_id = args.source, args.target
441 blocked_path = issue_files.get(blocked_id)
442 if blocked_path is None:
443 logger.error(f"issue {blocked_id!r} is not in active issues (cannot write to it)")
444 return 1
446 print(f"# {prefix}Dependency Apply Report")
447 print()
448 print(f" {prefix}{blocked_id} blocked by {blocker_id}")
449 print()
451 if not args.dry_run:
452 _add_to_section(blocked_path, "Blocked By", blocker_id)
453 print("1 relationship(s) applied.")
454 print()
455 print("Modified files:")
456 print(f" {blocked_path}")
457 else:
458 print("[DRY RUN] 1 relationship(s) would be applied.")
460 return 0
462 # Implicit mode: run analysis and apply proposals above confidence threshold
463 report = analyze_dependencies(
464 issues, issue_contents, completed_ids, all_known_ids, config=dep_config
465 )
466 filtered = [p for p in report.proposals if p.confidence >= args.min_confidence]
468 if not filtered:
469 logger.info(f"No proposals at or above confidence threshold ({args.min_confidence}).")
470 return 0
472 print(f"# {prefix}Dependency Apply Report")
473 print()
475 modified: set[str] = set()
476 applied = 0
478 for proposal in filtered:
479 source_path = issue_files.get(proposal.source_id)
480 if source_path is None or not source_path.exists():
481 continue
482 desc = (
483 f"{proposal.source_id} blocked by {proposal.target_id}"
484 f" (confidence: {proposal.confidence:.2f})"
485 )
486 print(f" {prefix}{desc}")
487 applied += 1
489 if not args.dry_run:
490 _add_to_section(source_path, "Blocked By", proposal.target_id)
491 modified.add(str(source_path))
493 print()
494 if args.dry_run:
495 print(f"[DRY RUN] {applied} relationship(s) would be applied.")
496 else:
497 print(f"{applied} relationship(s) applied.")
498 if modified:
499 print()
500 print("Modified files:")
501 for fpath in sorted(modified):
502 print(f" {fpath}")
504 return 0
506 return 1