Coverage for little_loops/dependency_mapper.py: 88%

1"""Cross-issue dependency discovery and mapping.

2

3 Analyzes active issues to discover potential dependencies based on

4 file overlap and validates existing dependency references for integrity.

5

6 Complements dependency_graph.py:

7- dependency_graph.py = execution ordering (existing, unchanged)

8- dependency_mapper.py = discovery and proposal of new relationships (new)

9"""

10

11 from __future__ import annotations

12

13 import logging

14 import re

15 from dataclasses import dataclass, field

16 from pathlib import Path

17 from typing import TYPE_CHECKING

18

19 from little_loops.dependency_graph import DependencyGraph

20 from little_loops.text_utils import extract_file_paths

21

22 if TYPE_CHECKING:

23 from little_loops.issue_parser import IssueInfo

24

25 logger = logging.getLogger(__name__)

26

27 _CODE_FENCE = re.compile(r"```[\s\S]*?```", re.MULTILINE)

28

29# Semantic target extraction patterns

30 _PASCAL_CASE = re.compile(r"\b([A-Z][a-z]+(?:[A-Z][a-z]+)+)\b")

31 _FUNCTION_REF = re.compile(r"`(\w+)\(\)`")

32 _COMPONENT_SCOPE = re.compile(

33 r"(?:component|module|class|widget|section)[:\s]+[`\"']?([a-zA-Z0-9_./\-]{3,})[`\"']?",

34 re.IGNORECASE,

35)

36

37# UI region / section keywords mapped to canonical names

38 _SECTION_KEYWORDS: dict[str, frozenset[str]] = {

39 "header": frozenset({"header", "heading", "title bar", "top bar", "nav", "navbar", "toolbar"}),

40 "body": frozenset({"body", "content", "main", "droppable", "list", "table", "grid"}),

41 "footer": frozenset({"footer", "bottom", "status bar", "action bar"}),

42 "sidebar": frozenset({"sidebar", "side panel", "drawer", "menu"}),

43 "card": frozenset({"card", "tile", "item", "row", "entry"}),

44 "modal": frozenset({"modal", "dialog", "popup", "overlay", "sheet"}),

45 "form": frozenset({"form", "input", "field", "editor", "picker"}),

46}

47

48# Modification type classification keywords

49 _MODIFICATION_TYPES: dict[str, frozenset[str]] = {

50 "structural": frozenset(

51 {

52 "extract",

53 "split",

54 "refactor",

55 "restructure",

56 "reorganize",

57 "create new component",

58 "break out",

59 "separate",

60 "decompose",

61 }

62 ),

63 "infrastructure": frozenset(

64 {

65 "enable",

66 "hook",

67 "handler",

68 "event",

69 "listener",

70 "provider",

71 "context",

72 "store",

73 "state management",

74 "routing",

75 "middleware",

76 "dragging",

77 "drag",

78 "drop",

79 "dnd",

80 }

81 ),

82 "enhancement": frozenset(

83 {

84 "add button",

85 "add field",

86 "add column",

87 "add stats",

88 "add icon",

89 "add toggle",

90 "display",

91 "show",

92 "render",

93 "style",

94 "format",

95 "empty state",

96 "placeholder",

97 "tooltip",

98 "badge",

99 }

100 ),

101}

102

103

104@dataclass

105 class DependencyProposal:

106 """A proposed dependency relationship between two issues.

107

108 Attributes:

109 source_id: Issue that would be blocked

110 target_id: Issue that would block (the blocker)

111 reason: Category of discovery method

112 confidence: Score from 0.0 to 1.0

113 rationale: Human-readable explanation

114 overlapping_files: Files referenced by both issues

115 conflict_score: Semantic conflict score from 0.0 to 1.0

116 """

117

118 source_id: str

119 target_id: str

120 reason: str

121 confidence: float

122 rationale: str

123 overlapping_files: list[str] = field(default_factory=list)

124 conflict_score: float = 0.5

125

126

127@dataclass

128 class ParallelSafePair:

129 """A pair of issues that share files but can safely run in parallel.

130

131 Attributes:

132 issue_a: First issue ID

133 issue_b: Second issue ID

134 shared_files: Files referenced by both issues

135 conflict_score: Semantic conflict score (< 0.4)

136 reason: Why these are parallel-safe

137 """

138

139 issue_a: str

140 issue_b: str

141 shared_files: list[str] = field(default_factory=list)

142 conflict_score: float = 0.0

143 reason: str = ""

144

145

146@dataclass

147 class ValidationResult:

148 """Result of validating existing dependency references.

149

150 Attributes:

151 broken_refs: (issue_id, missing_ref_id) pairs

152 missing_backlinks: (issue_id, should_have_backlink_from) pairs

153 cycles: Cycle paths from DependencyGraph.detect_cycles()

154 stale_completed_refs: (issue_id, completed_ref_id) pairs

155 """

156

157 broken_refs: list[tuple[str, str]] = field(default_factory=list)

158 missing_backlinks: list[tuple[str, str]] = field(default_factory=list)

159 cycles: list[list[str]] = field(default_factory=list)

160 stale_completed_refs: list[tuple[str, str]] = field(default_factory=list)

161

162 @property

163 def has_issues(self) -> bool:

164 """Return True if any validation problems were found."""

165 return bool(

166 self.broken_refs or self.missing_backlinks or self.cycles or self.stale_completed_refs

167 )

168

169

170@dataclass

171 class DependencyReport:

172 """Complete dependency analysis report.

173

174 Attributes:

175 proposals: Proposed new dependency relationships

176 parallel_safe: Pairs of issues that share files but can run in parallel

177 validation: Validation results for existing dependencies

178 issue_count: Total issues analyzed

179 existing_dep_count: Number of existing dependency edges

180 """

181

182 proposals: list[DependencyProposal] = field(default_factory=list)

183 parallel_safe: list[ParallelSafePair] = field(default_factory=list)

184 validation: ValidationResult = field(default_factory=ValidationResult)

185 issue_count: int = 0

186 existing_dep_count: int = 0

187

188

189 def _extract_semantic_targets(content: str) -> set[str]:

190 """Extract component and function references from issue content.

191

192 Identifies PascalCase component names, function references,

193 and explicitly mentioned component/module scopes.

194

195 Args:

196 content: Issue file content

197

198 Returns:

199 Set of normalized semantic target names

200 """

201 if not content:

202 return set()

203

204 stripped = _CODE_FENCE.sub("", content)

205 targets: set[str] = set()

206

207 for match in _PASCAL_CASE.finditer(stripped):

208 targets.add(match.group(1).lower())

209

210 for match in _FUNCTION_REF.finditer(stripped):

211 targets.add(match.group(1).lower())

212

213 for match in _COMPONENT_SCOPE.finditer(stripped):

214 targets.add(match.group(1).lower())

215

216 return targets

217

218

219 def _extract_section_mentions(content: str) -> set[str]:

220 """Extract UI region/section references from issue content.

221

222 Maps keywords like "header", "body", "sidebar" to canonical

223 section names using word-boundary matching.

224

225 Args:

226 content: Issue file content

227

228 Returns:

229 Set of canonical section names mentioned

230 """

231 if not content:

232 return set()

233

234 content_lower = content.lower()

235 sections: set[str] = set()

236

237 for section_name, keywords in _SECTION_KEYWORDS.items():

238 for keyword in keywords:

239 # Use word boundary for single words, substring for multi-word phrases

240 if " " in keyword:

241 if keyword in content_lower:

242 sections.add(section_name)

243 break

244 else:

245 if re.search(rf"\b{re.escape(keyword)}\b", content_lower):

246 sections.add(section_name)

247 break

248

249 return sections

250

251

252 def _classify_modification_type(content: str) -> str:

253 """Classify the modification type of an issue.

254

255 Returns one of: "structural", "infrastructure", "enhancement".

256 Falls back to "enhancement" if no clear match.

257

258 Args:

259 content: Issue file content

260

261 Returns:

262 Modification type classification string

263 """

264 if not content:

265 return "enhancement"

266

267 content_lower = content.lower()

268

269 for mod_type in ("structural", "infrastructure", "enhancement"):

270 keywords = _MODIFICATION_TYPES[mod_type]

271 for keyword in keywords:

272 if keyword in content_lower:

273 return mod_type

274

275 return "enhancement"

276

277

278 def compute_conflict_score(

279 content_a: str,

280 content_b: str,

281) -> float:

282 """Compute semantic conflict score between two issues.

283

284 Combines three signals:

285 - Semantic target overlap (component/function names): weight 0.5

286 - Section mention overlap (UI regions): weight 0.3

287 - Modification type match: weight 0.2

288

289 Args:

290 content_a: First issue's file content

291 content_b: Second issue's file content

292

293 Returns:

294 Conflict score from 0.0 (parallel-safe) to 1.0 (definite conflict)

295 """

296 targets_a = _extract_semantic_targets(content_a)

297 targets_b = _extract_semantic_targets(content_b)

298

299 sections_a = _extract_section_mentions(content_a)

300 sections_b = _extract_section_mentions(content_b)

301

302 type_a = _classify_modification_type(content_a)

303 type_b = _classify_modification_type(content_b)

304

305 # Signal 1: Semantic target overlap (0.0 - 1.0)

306 if targets_a and targets_b:

307 target_union = len(targets_a | targets_b)

308 target_score = len(targets_a & targets_b) / target_union if target_union > 0 else 0.0

309 else:

310 target_score = 0.5 # Unknown — default to moderate

311

312 # Signal 2: Section overlap (0.0 or 1.0)

313 if sections_a and sections_b:

314 section_score = 1.0 if sections_a & sections_b else 0.0

315 else:

316 section_score = 0.5 # Unknown

317

318 # Signal 3: Modification type match (0.0 or 1.0)

319 type_score = 1.0 if type_a == type_b else 0.0

320

321 return round(target_score * 0.5 + section_score * 0.3 + type_score * 0.2, 2)

322

323

324 def find_file_overlaps(

325 issues: list[IssueInfo],

326 issue_contents: dict[str, str],

327) -> tuple[list[DependencyProposal], list[ParallelSafePair]]:

328 """Find issues that reference overlapping files and propose dependencies.

329

330 For each pair of issues where both reference the same file(s), computes

331 a semantic conflict score. High-conflict pairs get dependency proposals;

332 low-conflict pairs are reported as parallel-safe.

333

334 Pairs that already have a dependency relationship are skipped.

335

336 Args:

337 issues: List of parsed issue objects

338 issue_contents: Mapping from issue_id to file content

339

340 Returns:

341 Tuple of (proposed dependencies, parallel-safe pairs)

342 """

343 # Build existing dependency set for skip check

344 existing_deps: set[tuple[str, str]] = set()

345 for issue in issues:

346 for blocker_id in issue.blocked_by:

347 existing_deps.add((issue.issue_id, blocker_id))

348

349 # Extract file paths per issue

350 issue_paths: dict[str, set[str]] = {}

351 for issue in issues:

352 content = issue_contents.get(issue.issue_id, "")

353 paths = extract_file_paths(content)

354 if paths:

355 issue_paths[issue.issue_id] = paths

356

357 proposals: list[DependencyProposal] = []

358 parallel_safe: list[ParallelSafePair] = []

359 issue_ids = sorted(issue_paths.keys())

360

361 _type_order = {"structural": 0, "infrastructure": 1, "enhancement": 2}

362

363 for i, id_a in enumerate(issue_ids):

364 for id_b in issue_ids[i + 1 :]:

365 overlap = issue_paths[id_a] & issue_paths[id_b]

366 if not overlap:

367 continue

368

369 # Skip if dependency already exists (in either direction)

370 if (id_a, id_b) in existing_deps or (id_b, id_a) in existing_deps:

371 continue

372

373 content_a = issue_contents.get(id_a, "")

374 content_b = issue_contents.get(id_b, "")

375 conflict = compute_conflict_score(content_a, content_b)

376

377 overlap_list = sorted(overlap)

378

379 # Low-conflict pairs are parallel-safe

380 if conflict < 0.4:

381 sections_a = _extract_section_mentions(content_a)

382 sections_b = _extract_section_mentions(content_b)

383 if sections_a and sections_b:

384 reason = (

385 f"Different sections ({', '.join(sorted(sections_a))}"

386 f" vs {', '.join(sorted(sections_b))})"

387 )

388 else:

389 reason = "Low semantic conflict score"

390 parallel_safe.append(

391 ParallelSafePair(

392 issue_a=id_a,

393 issue_b=id_b,

394 shared_files=overlap_list,

395 conflict_score=conflict,

396 reason=reason,

397 )

398 )

399 continue

400

401 # Determine direction for high-conflict pairs

402 issue_a = next(iss for iss in issues if iss.issue_id == id_a)

403 issue_b = next(iss for iss in issues if iss.issue_id == id_b)

404

405 confidence_modifier = 1.0

406

407 if issue_a.priority_int != issue_b.priority_int:

408 # Different priorities: higher priority blocks lower

409 if issue_a.priority_int < issue_b.priority_int:

410 target_id, source_id = id_a, id_b

411 else:

412 target_id, source_id = id_b, id_a

413 else:

414 # Same priority: use modification type ordering

415 type_a = _classify_modification_type(content_a)

416 type_b = _classify_modification_type(content_b)

417 order_a = _type_order.get(type_a, 2)

418 order_b = _type_order.get(type_b, 2)

419

420 if order_a != order_b:

421 if order_a < order_b:

422 target_id, source_id = id_a, id_b

423 else:

424 target_id, source_id = id_b, id_a

425 else:

426 # Fall back to ID ordering with reduced confidence

427 if id_a < id_b:

428 target_id, source_id = id_a, id_b

429 else:

430 target_id, source_id = id_b, id_a

431 confidence_modifier = 0.5

432

433 min_paths = min(len(issue_paths[id_a]), len(issue_paths[id_b]))

434 confidence = len(overlap) / min_paths if min_paths > 0 else 0.0

435 confidence *= confidence_modifier

436

437 rationale = (

438 f"{source_id} and {target_id} both reference "

439 f"{', '.join(overlap_list[:3])}"

440 f"{' and more' if len(overlap_list) > 3 else ''}. "

441 f"{target_id} has higher priority and should be completed first."

442 )

443

444 proposals.append(

445 DependencyProposal(

446 source_id=source_id,

447 target_id=target_id,

448 reason="file_overlap",

449 confidence=round(confidence, 2),

450 rationale=rationale,

451 overlapping_files=overlap_list,

452 conflict_score=conflict,

453 )

454 )

455

456 # Sort by confidence descending

457 proposals.sort(key=lambda p: -p.confidence)

458 return proposals, parallel_safe

459

460

461 def validate_dependencies(

462 issues: list[IssueInfo],

463 completed_ids: set[str] | None = None,

464 all_known_ids: set[str] | None = None,

465) -> ValidationResult:

466 """Validate existing dependency references for integrity.

467

468 Checks:

469 - Broken refs: blocked_by entries referencing nonexistent issues

470 - Missing backlinks: A blocks B but B doesn't list A in blocked_by

471 - Cycles: circular dependency chains

472 - Stale completed refs: blocked_by entries referencing completed issues

473

474 Args:

475 issues: List of parsed issue objects

476 completed_ids: Set of completed issue IDs

477 all_known_ids: Set of all issue IDs that exist on disk (across all

478 categories and completed). When provided, references to issues

479 in this set are not flagged as broken even if they are not in

480 the working ``issues`` list.

481

482 Returns:

483 ValidationResult with all detected problems

484 """

485 completed = completed_ids or set()

486 result = ValidationResult()

487

488 active_ids = {issue.issue_id for issue in issues}

489 all_known = active_ids | completed

490 if all_known_ids:

491 all_known = all_known | all_known_ids

492

493 # Build lookup maps

494 blocked_by_map: dict[str, set[str]] = {}

495 blocks_map: dict[str, set[str]] = {}

496 for issue in issues:

497 blocked_by_map[issue.issue_id] = set(issue.blocked_by)

498 blocks_map[issue.issue_id] = set(issue.blocks)

499

500 for issue in issues:

501 for ref_id in issue.blocked_by:

502 if ref_id not in all_known:

503 result.broken_refs.append((issue.issue_id, ref_id))

504 elif ref_id in completed:

505 result.stale_completed_refs.append((issue.issue_id, ref_id))

506

507 # Check backlinks: if A.blocked_by contains B, then B.blocks should contain A

508 for ref_id in issue.blocked_by:

509 if ref_id in active_ids:

510 target_blocks = blocks_map.get(ref_id, set())

511 if issue.issue_id not in target_blocks:

512 result.missing_backlinks.append((issue.issue_id, ref_id))

513

514 # Cycle detection using DependencyGraph

515 graph = DependencyGraph.from_issues(issues, completed, all_known_ids=all_known_ids)

516 result.cycles = graph.detect_cycles()

517

518 return result

519

520

521 def analyze_dependencies(

522 issues: list[IssueInfo],

523 issue_contents: dict[str, str],

524 completed_ids: set[str] | None = None,

525 all_known_ids: set[str] | None = None,

526) -> DependencyReport:

527 """Run full dependency analysis: discovery and validation.

528

529 Args:

530 issues: List of parsed issue objects

531 issue_contents: Mapping from issue_id to file content

532 completed_ids: Set of completed issue IDs

533 all_known_ids: Set of all issue IDs that exist on disk

534

535 Returns:

536 Comprehensive dependency report

537 """

538 proposals, parallel_safe = find_file_overlaps(issues, issue_contents)

539 validation = validate_dependencies(issues, completed_ids, all_known_ids)

540

541 existing_dep_count = sum(len(issue.blocked_by) for issue in issues)

542

543 return DependencyReport(

544 proposals=proposals,

545 parallel_safe=parallel_safe,

546 validation=validation,

547 issue_count=len(issues),

548 existing_dep_count=existing_dep_count,

549 )

550

551

552 def format_report(report: DependencyReport) -> str:

553 """Format a dependency report as human-readable markdown.

554

555 Args:

556 report: The analysis report to format

557

558 Returns:

559 Markdown-formatted report string

560 """

561 lines: list[str] = []

562 lines.append("# Dependency Analysis Report")

563 lines.append("")

564 lines.append(f"- **Issues analyzed**: {report.issue_count}")

565 lines.append(f"- **Existing dependencies**: {report.existing_dep_count}")

566 lines.append(f"- **Proposed new dependencies**: {len(report.proposals)}")

567 lines.append(f"- **Parallel-safe pairs**: {len(report.parallel_safe)}")

568 lines.append(f"- **Validation issues**: {'Yes' if report.validation.has_issues else 'None'}")

569 lines.append("")

570

571 # Proposals section

572 if report.proposals:

573 lines.append("## Proposed Dependencies")

574 lines.append("")

575 lines.append(

576 "| # | Source (blocked) | Target (blocker) | Reason "

577 "| Conflict | Confidence | Rationale |"

578 )

579 lines.append(

580 "|---|-----------------|-----------------|--------|----------|------------|-----------|"

581 )

582 for i, p in enumerate(report.proposals, 1):

583 if p.conflict_score >= 0.7:

584 conflict_level = "HIGH"

585 elif p.conflict_score >= 0.4:

586 conflict_level = "MEDIUM"

587 else:

588 conflict_level = "LOW"

589 lines.append(

590 f"| {i} | {p.source_id} | {p.target_id} | "

591 f"{p.reason} | {conflict_level} | {p.confidence:.0%} | {p.rationale} |"

592 )

593 lines.append("")

594

595 # Parallel-safe section

596 if report.parallel_safe:

597 lines.append("## Parallel Execution Safe")

598 lines.append("")

599 lines.append("| Issue A | Issue B | Shared Files | Conflict Score | Reason |")

600 lines.append("|---------|---------|--------------|---------------|--------|")

601 for pair in report.parallel_safe:

602 files_str = ", ".join(pair.shared_files[:3])

603 if len(pair.shared_files) > 3:

604 files_str += " and more"

605 lines.append(

606 f"| {pair.issue_a} | {pair.issue_b} | "

607 f"{files_str} | {pair.conflict_score:.0%} | {pair.reason} |"

608 )

609 lines.append("")

610

611 # Validation section

612 v = report.validation

613 if v.has_issues:

614 lines.append("## Validation Issues")

615 lines.append("")

616

617 if v.broken_refs:

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

619 lines.append("")

620 for issue_id, ref_id in v.broken_refs:

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

622 lines.append("")

623

624 if v.missing_backlinks:

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

626 lines.append("")

627 for issue_id, ref_id in v.missing_backlinks:

628 lines.append(

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

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

631 )

632 lines.append("")

633

634 if v.cycles:

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

636 lines.append("")

637 for cycle in v.cycles:

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

639 lines.append("")

640

641 if v.stale_completed_refs:

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

643 lines.append("")

644 for issue_id, ref_id in v.stale_completed_refs:

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

646 lines.append("")

647

648 if not report.proposals and not report.parallel_safe and not v.has_issues:

649 lines.append("No dependency proposals or validation issues found.")

650 lines.append("")

651

652 return "\n".join(lines)

653

654

655 def format_text_graph(

656 issues: list[IssueInfo],

657 proposals: list[DependencyProposal] | None = None,

658) -> str:

659 """Generate an ASCII dependency graph diagram.

660

661 Shows existing dependencies as solid arrows and proposed

662 dependencies as dashed arrows.

663

664 Args:

665 issues: List of parsed issue objects

666 proposals: Optional proposed dependencies to include

667

668 Returns:

669 Text graph string readable in the terminal

670 """

671 if not issues:

672 return "(no issues)"

673

674 issue_ids = {i.issue_id for i in issues}

675 sorted_issues = sorted(issues, key=lambda i: (i.priority_int, i.issue_id))

676

677 # Build adjacency: blocker -> list of blocked issues

678 blocks: dict[str, list[str]] = {}

679 for issue in sorted_issues:

680 for blocker_id in issue.blocked_by:

681 if blocker_id in issue_ids:

682 blocks.setdefault(blocker_id, []).append(issue.issue_id)

683

684 # Add proposed edges

685 proposed_edges: set[tuple[str, str]] = set()

686 if proposals:

687 for p in proposals:

688 if p.target_id in issue_ids and p.source_id in issue_ids:

689 blocks.setdefault(p.target_id, []).append(p.source_id)

690 proposed_edges.add((p.target_id, p.source_id))

691

692 # Build chains from roots (issues not blocked by anything in the set)

693 blocked_ids: set[str] = set()

694 for targets in blocks.values():

695 blocked_ids.update(targets)

696 roots = [i.issue_id for i in sorted_issues if i.issue_id not in blocked_ids]

697

698 visited: set[str] = set()

699 chains: list[str] = []

700

701 def build_chain(issue_id: str) -> str:

702 if issue_id in visited:

703 return issue_id

704 visited.add(issue_id)

705 targets = sorted(blocks.get(issue_id, []))

706 if not targets:

707 return issue_id

708 if len(targets) == 1:

709 arrow = "-.→" if (issue_id, targets[0]) in proposed_edges else "──→"

710 return f"{issue_id} {arrow} {build_chain(targets[0])}"

711 # Multiple branches: first inline, rest as separate chains

712 arrow = "-.→" if (issue_id, targets[0]) in proposed_edges else "──→"

713 result = f"{issue_id} {arrow} {build_chain(targets[0])}"

714 for other in targets[1:]:

715 if other not in visited:

716 arrow_other = "-.→" if (issue_id, other) in proposed_edges else "──→"

717 chains.append(f" {issue_id} {arrow_other} {build_chain(other)}")

718 return result

719

720 for root in roots:

721 if root not in visited:

722 chain = build_chain(root)

723 chains.append(f" {chain}")

724

725 # Isolated issues (not in any chain)

726 for issue in sorted_issues:

727 if issue.issue_id not in visited:

728 chains.append(f" {issue.issue_id}")

729

730 lines: list[str] = list(chains)

731

732 if any("──→" in c for c in chains) or any("-.→" in c for c in chains):

733 lines.append("")

734 legend_parts = []

735 if any("──→" in c for c in chains):

736 legend_parts.append("──→ blocks")

737 if any("-.→" in c for c in chains):

738 legend_parts.append("-.→ proposed")

739 lines.append(f"Legend: {', '.join(legend_parts)}")

740

741 return "\n".join(lines)

742

743

744 def apply_proposals(

745 proposals: list[DependencyProposal],

746 issue_files: dict[str, Path],

747) -> list[str]:

748 """Write approved dependency proposals to issue files.

749

750 For each proposal, adds the target to the source's ``## Blocked By``

751 section and the source to the target's ``## Blocks`` section.

752

753 Args:

754 proposals: Approved proposals to apply

755 issue_files: Mapping from issue_id to file path

756

757 Returns:

758 List of modified file paths

759 """

760 modified: set[str] = set()

761

762 for proposal in proposals:

763 # Update source issue: add to ## Blocked By

764 source_path = issue_files.get(proposal.source_id)

765 if source_path and source_path.exists():

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

767 modified.add(str(source_path))

768

769 # Update target issue: add to ## Blocks

770 target_path = issue_files.get(proposal.target_id)

771 if target_path and target_path.exists():

772 _add_to_section(target_path, "Blocks", proposal.source_id)

773 modified.add(str(target_path))

774

775 return sorted(modified)

776

777

778 def _add_to_section(file_path: Path, section_name: str, issue_id: str) -> None:

779 """Add an issue ID to a markdown section in a file.

780

781 If the section exists, appends a new list item.

782 If the section doesn't exist, creates it before the

783 ``## Labels`` or ``## Status`` section, or at the end of the file.

784

785 Args:

786 file_path: Path to the issue file

787 section_name: Section name (e.g., "Blocked By" or "Blocks")

788 issue_id: Issue ID to add (e.g., "FEAT-001")

789 """

790 content = file_path.read_text(encoding="utf-8")

791

792 # Check if the ID is already in the section

793 section_pattern = rf"^##\s+{re.escape(section_name)}\s*$"

794 section_match = re.search(section_pattern, content, re.MULTILINE | re.IGNORECASE)

795

796 if section_match:

797 # Section exists — check if ID already present

798 start = section_match.end()

799 next_section = re.search(r"^##\s+", content[start:], re.MULTILINE)

800 if next_section:

801 section_content = content[start : start + next_section.start()]

802 else:

803 section_content = content[start:]

804

805 if issue_id in section_content:

806 return # Already present

807

808 # Find insertion point: end of section content (before next section or EOF)

809 insert_pos = (

810 start

811 + len(section_content.rstrip())

812 + (len(section_content) - len(section_content.rstrip()))

813 )

814 # Actually, insert at end of the last list item in the section

815 # Find the last non-blank line in the section

816 section_lines = section_content.rstrip().split("\n")

817 last_content_line_offset = 0

818 for line in reversed(section_lines):

819 if line.strip():

820 break

821 last_content_line_offset += len(line) + 1

822

823 insert_pos = start + len(section_content.rstrip())

824 new_entry = f"\n- {issue_id}"

825 content = content[:insert_pos] + new_entry + content[insert_pos:]

826 else:

827 # Section doesn't exist — create it

828 new_section = f"\n## {section_name}\n\n- {issue_id}\n"

829

830 # Try to insert before ## Labels or ## Status

831 for anchor in ("## Labels", "## Status"):

832 anchor_match = re.search(rf"^{re.escape(anchor)}\s*$", content, re.MULTILINE)

833 if anchor_match:

834 insert_pos = anchor_match.start()

835 content = content[:insert_pos] + new_section + "\n" + content[insert_pos:]

836 break

837 else:

838 # Append at end

839 content = content.rstrip() + "\n" + new_section

840

841 file_path.write_text(content, encoding="utf-8")

842

843

844 def _remove_from_section(file_path: Path, section_name: str, issue_id: str) -> bool:

845 """Remove an issue ID from a markdown section in a file.

846

847 If the section becomes empty after removal, the entire section is removed.

848

849 Args:

850 file_path: Path to the issue file

851 section_name: Section name (e.g., "Blocked By" or "Blocks")

852 issue_id: Issue ID to remove (e.g., "FEAT-001")

853

854 Returns:

855 True if a change was made, False if the ID was not found.

856 """

857 content = file_path.read_text(encoding="utf-8")

858

859 section_pattern = rf"^##\s+{re.escape(section_name)}\s*$"

860 section_match = re.search(section_pattern, content, re.MULTILINE | re.IGNORECASE)

861

862 if not section_match:

863 return False

864

865 start = section_match.end()

866 next_section = re.search(r"^##\s+", content[start:], re.MULTILINE)

867 if next_section:

868 section_end = start + next_section.start()

869 else:

870 section_end = len(content)

871

872 section_content = content[start: section_end]

873

874 # Find the line containing this issue ID

875 line_pattern = rf"^[-*]\s+\*{{0,2}}{re.escape(issue_id)}\b[^\n]*\n?"

876 line_match = re.search(line_pattern, section_content, re.MULTILINE)

877 if not line_match:

878 return False

879

880 # Remove the line

881 new_section_content = (

882 section_content[: line_match.start()] + section_content[line_match.end() :]

883 )

884

885 # Check if the section is now empty (no list items remaining)

886 remaining_items = re.search(r"^[-*]\s+", new_section_content, re.MULTILINE)

887 if not remaining_items:

888 # Remove entire section (header + content)

889 # Include leading newline if present

890 remove_start = section_match.start()

891 if remove_start > 0 and content[remove_start - 1] == "\n":

892 remove_start -= 1

893 content = content[:remove_start] + content[section_end:]

894 else:

895 content = content[:start] + new_section_content + content[section_end:]

896

897 file_path.write_text(content, encoding="utf-8")

898 return True

899

900

901@dataclass

902 class FixResult:

903 """Result of auto-fixing dependency validation issues.

904

905 Attributes:

906 changes: Human-readable descriptions of each fix applied

907 modified_files: Set of file paths that were modified

908 skipped_cycles: Number of cycles skipped (out of scope for auto-fix)

909 """

910

911 changes: list[str] = field(default_factory=list)

912 modified_files: set[str] = field(default_factory=set)

913 skipped_cycles: int = 0

914

915

916 def fix_dependencies(

917 issues: list[IssueInfo],

918 completed_ids: set[str] | None = None,

919 all_known_ids: set[str] | None = None,

920 dry_run: bool = False,

921) -> FixResult:

922 """Auto-repair broken dependency references.

923

924 Fixes three types of validation issues:

925 - Broken refs: removes references to non-existent issues from Blocked By

926 - Stale completed refs: removes references to completed issues from Blocked By

927 - Missing backlinks: adds missing Blocks entries for bidirectional consistency

928

929 Cycles are explicitly out of scope and are skipped with a count.

930

931 Args:

932 issues: List of parsed issue objects

933 completed_ids: Set of completed issue IDs

934 all_known_ids: Set of all issue IDs that exist on disk

935 dry_run: If True, report what would change without modifying files

936

937 Returns:

938 FixResult with changes made and files modified

939 """

940 validation = validate_dependencies(issues, completed_ids, all_known_ids)

941 result = FixResult()

942

943 if not validation.has_issues:

944 return result

945

946 # Build issue path map

947 issue_path_map: dict[str, Path] = {issue.issue_id: issue.path for issue in issues}

948

949 # Fix broken refs: remove from Blocked By

950 for issue_id, ref_id in validation.broken_refs:

951 path = issue_path_map.get(issue_id)

952 if not path or not path.exists():

953 continue

954 desc = f"Removed broken ref {ref_id} from {issue_id}"

955 result.changes.append(desc)

956 if not dry_run:

957 if _remove_from_section(path, "Blocked By", ref_id):

958 result.modified_files.add(str(path))

959

960 # Fix stale completed refs: remove from Blocked By

961 for issue_id, ref_id in validation.stale_completed_refs:

962 path = issue_path_map.get(issue_id)

963 if not path or not path.exists():

964 continue

965 desc = f"Removed stale ref {ref_id} (completed) from {issue_id}"

966 result.changes.append(desc)

967 if not dry_run:

968 if _remove_from_section(path, "Blocked By", ref_id):

969 result.modified_files.add(str(path))

970

971 # Fix missing backlinks: add to Blocks

972 for issue_id, ref_id in validation.missing_backlinks:

973 target_path = issue_path_map.get(ref_id)

974 if not target_path or not target_path.exists():

975 continue

976 desc = f"Added backlink: {issue_id} to {ref_id}'s Blocks section"

977 result.changes.append(desc)

978 if not dry_run:

979 _add_to_section(target_path, "Blocks", issue_id)

980 result.modified_files.add(str(target_path))

981

982 # Report skipped cycles

983 result.skipped_cycles = len(validation.cycles)

984

985 return result

986

987

988 def gather_all_issue_ids(issues_dir: Path) -> set[str]:

989 """Scan all issue directories for issue IDs (lightweight, filename-only).

990

991 Scans bugs/, features/, enhancements/, and completed/ subdirectories

992 for markdown files with issue ID patterns in their filenames.

993

994 Args:

995 issues_dir: Path to the issues base directory (e.g., .issues)

996

997 Returns:

998 Set of all issue IDs found across all categories and completed.

999 """

1000 ids: set[str] = set()

1001 for subdir in ["bugs", "features", "enhancements", "completed"]:

1002 d = issues_dir / subdir

1003 if not d.exists():

1004 continue

1005 for f in d.glob("*.md"):

1006 match = re.search(r"(BUG|FEAT|ENH)-(\d+)", f.name)

1007 if match:

1008 ids.add(f"{match.group(1)}-{match.group(2)}")

1009 return ids

1010

1011

1012 def _load_issues(

1013 issues_dir: Path,

1014 only_ids: set[str] | None = None,

1015) -> tuple[list[IssueInfo], dict[str, str], set[str]]:

1016 """Load issues from directory for CLI use.

1017

1018 Args:

1019 issues_dir: Path to the issues base directory (e.g., .issues)

1020 only_ids: If provided, only include issues with these IDs

1021

1022 Returns:

1023 Tuple of (active issues, issue contents map, completed issue IDs)

1024 """

1025 from little_loops.config import BRConfig

1026 from little_loops.issue_parser import find_issues

1027

1028 # Find project root by walking up from issues_dir

1029 project_root = issues_dir.resolve().parent

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

1031 # If issues_dir is already absolute, try to find config relative to it

1032 project_root = issues_dir.parent

1033

1034 config = BRConfig(project_root)

1035 issues = find_issues(config, only_ids=only_ids)

1036

1037 # Build contents map

1038 issue_contents: dict[str, str] = {}

1039 for info in issues:

1040 if info.path.exists():

1041 issue_contents[info.issue_id] = info.path.read_text(encoding="utf-8")

1042

1043 # Gather completed issue IDs

1044 completed_dir = config.get_completed_dir()

1045 completed_ids: set[str] = set()

1046 if completed_dir.exists():

1047 import re as _re

1048

1049 for f in completed_dir.glob("*.md"):

1050 match = _re.search(r"(BUG|FEAT|ENH)-(\d+)", f.name)

1051 if match:

1052 completed_ids.add(f"{match.group(1)}-{match.group(2)}")

1053

1054 return issues, issue_contents, completed_ids

1055

1056

1057 def main() -> int:

1058 """Entry point for ll-deps command.

1059

1060 Analyze cross-issue dependencies and validate existing references.

1061

1062 Returns:

1063 Exit code (0 = success, 1 = failure)

1064 """

1065 import argparse

1066 import json as _json

1067 import sys

1068

1069 parser = argparse.ArgumentParser(

1070 prog="ll-deps",

1071 description="Cross-issue dependency discovery and validation",

1072 formatter_class=argparse.RawDescriptionHelpFormatter,

1073 epilog="""

1074 Examples:

1075 %(prog)s analyze # Full analysis with markdown output

1076 %(prog)s analyze --format json # JSON output for programmatic use

1077 %(prog)s analyze --graph # Include ASCII dependency graph

1078 %(prog)s analyze --sprint my-sprint # Analyze only issues in a sprint

1079 %(prog)s validate # Validation only (broken refs, cycles)

1080 %(prog)s validate --sprint my-sprint # Validate only sprint issue deps

1081 %(prog)s fix # Auto-fix broken refs, stale refs, backlinks

1082 %(prog)s fix --dry-run # Preview fixes without modifying files

1083""",

1084 )

1085

1086 parser.add_argument(

1087 "-d",

1088 "--issues-dir",

1089 type=Path,

1090 default=None,

1091 help="Path to issues directory (default: .issues)",

1092 )

1093

1094 subparsers = parser.add_subparsers(dest="command", help="Available commands")

1095

1096 # analyze subcommand

1097 analyze_parser = subparsers.add_parser(

1098 "analyze",

1099 help="Full dependency analysis (file overlaps + validation)",

1100 )

1101 analyze_parser.add_argument(

1102 "-f",

1103 "--format",

1104 type=str,

1105 choices=["text", "json"],

1106 default="text",

1107 help="Output format (default: text/markdown)",

1108 )

1109 analyze_parser.add_argument(

1110 "--graph",

1111 action="store_true",

1112 help="Include ASCII dependency graph in output",

1113 )

1114 analyze_parser.add_argument(

1115 "--sprint",

1116 type=str,

1117 default=None,

1118 help="Restrict analysis to issues in the named sprint",

1119 )

1120

1121 # validate subcommand

1122 validate_parser = subparsers.add_parser(

1123 "validate",

1124 help="Validate existing dependency references only",

1125 )

1126 validate_parser.add_argument(

1127 "--sprint",

1128 type=str,

1129 default=None,

1130 help="Restrict validation to issues in the named sprint",

1131 )

1132

1133 # fix subcommand

1134 fix_parser = subparsers.add_parser(

1135 "fix",

1136 help="Auto-fix broken refs, stale refs, and missing backlinks",

1137 )

1138 fix_parser.add_argument(

1139 "--dry-run",

1140 "-n",

1141 action="store_true",

1142 help="Show what would be fixed without making changes",

1143 )

1144 fix_parser.add_argument(

1145 "--sprint",

1146 type=str,

1147 default=None,

1148 help="Restrict fixes to issues in the named sprint",

1149 )

1150

1151 args = parser.parse_args()

1152

1153 if not args.command:

1154 parser.print_help()

1155 return 1

1156

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

1158 if not issues_dir.exists():

1159 print(f"Error: Issues directory not found: {issues_dir}", file=sys.stderr)

1160 return 1

1161

1162 # Sprint-scoped filtering

1163 only_ids: set[str] | None = None

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

1165 from little_loops.config import BRConfig as _BRConfig

1166 from little_loops.sprint import Sprint

1167

1168 project_root = issues_dir.resolve().parent

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

1170 project_root = issues_dir.parent

1171 _config = _BRConfig(project_root)

1172 sprints_dir = Path(_config.sprints.sprints_dir)

1173 if not sprints_dir.is_absolute():

1174 sprints_dir = project_root / sprints_dir

1175

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

1177 if sprint is None:

1178 print(f"Error: Sprint not found: {args.sprint}", file=sys.stderr)

1179 return 1

1180 only_ids = set(sprint.issues)

1181 if not only_ids:

1182 print(f"Sprint '{args.sprint}' has no issues.")

1183 return 0

1184

1185 try:

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

1187 except Exception as e:

1188 print(f"Error loading issues: {e}", file=sys.stderr)

1189 return 1

1190

1191 if not issues:

1192 print("No active issues found.")

1193 return 0

1194

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

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

1197 all_known_ids = gather_all_issue_ids(issues_dir)

1198

1199 if args.command == "analyze":

1200 report = analyze_dependencies(issues, issue_contents, completed_ids, all_known_ids)

1201

1202 if args.format == "json":

1203 data = {

1204 "issue_count": report.issue_count,

1205 "existing_dep_count": report.existing_dep_count,

1206 "proposals": [

1207 {

1208 "source_id": p.source_id,

1209 "target_id": p.target_id,

1210 "reason": p.reason,

1211 "confidence": p.confidence,

1212 "rationale": p.rationale,

1213 "overlapping_files": p.overlapping_files,

1214 "conflict_score": p.conflict_score,

1215 }

1216 for p in report.proposals

1217 ],

1218 "parallel_safe": [

1219 {

1220 "issue_a": ps.issue_a,

1221 "issue_b": ps.issue_b,

1222 "shared_files": ps.shared_files,

1223 "conflict_score": ps.conflict_score,

1224 "reason": ps.reason,

1225 }

1226 for ps in report.parallel_safe

1227 ],

1228 "validation": {

1229 "broken_refs": report.validation.broken_refs,

1230 "missing_backlinks": report.validation.missing_backlinks,

1231 "cycles": report.validation.cycles,

1232 "stale_completed_refs": report.validation.stale_completed_refs,

1233 "has_issues": report.validation.has_issues,

1234 },

1235 }

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

1237 else:

1238 print(format_report(report))

1239 if args.graph:

1240 print()

1241 print("## Dependency Graph")

1242 print()

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

1244

1245 return 0

1246

1247 if args.command == "validate":

1248 result = validate_dependencies(issues, completed_ids, all_known_ids)

1249

1250 if not result.has_issues:

1251 print("No validation issues found.")

1252 return 0

1253

1254 lines: list[str] = []

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

1256 lines.append("")

1257

1258 if result.broken_refs:

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

1260 lines.append("")

1261 for issue_id, ref_id in result.broken_refs:

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

1263 lines.append("")

1264

1265 if result.missing_backlinks:

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

1267 lines.append("")

1268 for issue_id, ref_id in result.missing_backlinks:

1269 lines.append(

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

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

1272 )

1273 lines.append("")

1274

1275 if result.cycles:

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

1277 lines.append("")

1278 for cycle in result.cycles:

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

1280 lines.append("")

1281

1282 if result.stale_completed_refs:

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

1284 lines.append("")

1285 for issue_id, ref_id in result.stale_completed_refs:

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

1287 lines.append("")

1288

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

1290 return 0

1291

1292 if args.command == "fix":

1293 fix_result = fix_dependencies(issues, completed_ids, all_known_ids, dry_run=args.dry_run)

1294

1295 if not fix_result.changes:

1296 print("No fixable issues found.")

1297 if fix_result.skipped_cycles:

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

1299 return 0

1300

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

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

1303 print()

1304 for change in fix_result.changes:

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

1306 print()

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

1308

1309 if fix_result.modified_files:

1310 print()

1311 print("Modified files:")

1312 for fpath in sorted(fix_result.modified_files):

1313 print(f" {fpath}")

1314

1315 if fix_result.skipped_cycles:

1316 print()

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

1318

1319 return 0

1320

1321 return 1