Coverage for little_loops / hooks / sweep_stale_refs.py: 0%
107 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-26 17:38 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-26 17:38 -0500
1"""SessionEnd hook handler: sweep stale cross-issue status references — FEAT-1680.
3Fires at the end of every Claude Code session. Collects all issue IDs with
4``status: done``, then scans open issue files for prose that still asserts
5those IDs are ``open``, ``in_progress``, or active blockers. Findings are
6reported via ``LLHookResult.feedback`` (stderr). When ``hooks.stale_ref_fix``
7is ``"auto"`` in ``ll-config.json``, stale phrases are rewritten in-place.
9The handler exits 0 in all cases — it is advisory only and must never block
10session end.
11"""
13from __future__ import annotations
15import json
16import re
17from pathlib import Path
19from little_loops.config.core import BRConfig, resolve_config_path
20from little_loops.file_utils import atomic_write
21from little_loops.hooks.types import LLHookEvent, LLHookResult
22from little_loops.issue_parser import find_issues
23from little_loops.text_utils import _CODE_FENCE
25# Matches any issue ID token (e.g. FEAT-1112, ENH-42, BUG-007, EPIC-3)
26_ISSUE_ID_RE = re.compile(r"\b(ENH|BUG|FEAT|EPIC)-(\d+)\b")
28# Stale-status phrase patterns — matched *after* the issue ID on the same line
29_STALE_STATUS_RE = re.compile(
30 r"\bis\s+(?:still\s+)?(open|in_progress|active)\b",
31 re.IGNORECASE,
32)
34# Blocked-by phrase — the issue ID appears after "blocked by"
35_BLOCKED_BY_RE = re.compile(r"\bblocked\s+by\b", re.IGNORECASE)
38def _in_fence(pos_start: int, pos_end: int, fence_spans: list[tuple[int, int]]) -> bool:
39 """Return True if the span [pos_start, pos_end) falls inside a code fence."""
40 return any(fs <= pos_start and pos_end <= fe for fs, fe in fence_spans)
43def _scan_file(
44 path: Path,
45 done_ids: set[str],
46) -> list[tuple[int, str, str]]:
47 """Scan *path* for stale references to any ID in *done_ids*.
49 Returns a list of ``(lineno, issue_id, snippet)`` tuples (1-based lineno).
50 Lines inside code fences are skipped.
51 """
52 try:
53 content = path.read_text(encoding="utf-8", errors="replace")
54 except OSError:
55 return []
57 fence_spans = [(m.start(), m.end()) for m in _CODE_FENCE.finditer(content)]
59 findings: list[tuple[int, str, str]] = []
60 offset = 0
61 for lineno, line in enumerate(content.splitlines(), start=1):
62 line_start = offset
63 line_end = offset + len(line)
64 offset = line_end + 1 # +1 for the newline
66 if _in_fence(line_start, line_end, fence_spans):
67 continue
69 for m in _ISSUE_ID_RE.finditer(line):
70 issue_id = m.group(0)
71 if issue_id not in done_ids:
72 continue
74 # Check for stale-status phrase anywhere on the line
75 if _STALE_STATUS_RE.search(line):
76 snippet = line.strip()
77 findings.append((lineno, issue_id, snippet))
78 break # one finding per line is enough
80 # Check for "blocked by <ID>" where this ID is done
81 # Pattern: "blocked by" appears before the ID on the line
82 pre_id = line[: m.start()]
83 if _BLOCKED_BY_RE.search(pre_id):
84 snippet = line.strip()
85 findings.append((lineno, issue_id, snippet))
86 break
88 return findings
91def _auto_fix_file(path: Path, done_ids: set[str]) -> bool:
92 """Rewrite stale status phrases in *path* for any done ID mentioned.
94 Uses :func:`atomic_write` for safe in-place replacement. Returns True if
95 the file was modified.
96 """
97 try:
98 content = path.read_text(encoding="utf-8", errors="replace")
99 except OSError:
100 return False
102 fence_spans = [(m.start(), m.end()) for m in _CODE_FENCE.finditer(content)]
104 new_lines: list[str] = []
105 modified = False
106 offset = 0
107 for line in content.splitlines(keepends=True):
108 line_start = offset
109 line_end = offset + len(line)
110 offset = line_end
112 if _in_fence(line_start, line_end, fence_spans):
113 new_lines.append(line)
114 continue
116 # Only rewrite lines that mention a done ID
117 has_done_ref = any(m.group(0) in done_ids for m in _ISSUE_ID_RE.finditer(line))
118 if has_done_ref and _STALE_STATUS_RE.search(line):
119 new_line = _STALE_STATUS_RE.sub("is done", line)
120 new_lines.append(new_line)
121 modified = True
122 else:
123 new_lines.append(line)
125 if modified:
126 atomic_write(path, "".join(new_lines))
127 return modified
130def handle(event: LLHookEvent) -> LLHookResult:
131 """Sweep open issue files for stale references to done issue IDs.
133 Reads ``hooks.stale_ref_fix`` from ``ll-config.json`` (default
134 ``"report"``). When ``"auto"``, stale phrases are rewritten in-place.
135 Always returns ``LLHookResult(exit_code=0)`` — findings are advisory and
136 must never block session end.
137 """
138 try:
139 payload = event.payload or {}
140 raw_cwd = payload.get("cwd") or (event.cwd or "")
141 cwd = Path(raw_cwd) if raw_cwd else Path.cwd()
143 # Load raw hooks config to read stale_ref_fix mode
144 config_path = resolve_config_path(cwd)
145 fix_mode = "report"
146 if config_path is not None:
147 try:
148 raw_config = json.loads(config_path.read_text(encoding="utf-8"))
149 raw_hooks = raw_config.get("hooks", {})
150 if isinstance(raw_hooks, dict):
151 fix_mode = raw_hooks.get("stale_ref_fix", "report")
152 except (OSError, json.JSONDecodeError):
153 pass
155 # Build BRConfig for find_issues()
156 config = BRConfig(project_root=cwd)
158 # Collect done IDs
159 done_issues = find_issues(config, status_filter={"done"})
160 done_ids: set[str] = {i.issue_id for i in done_issues}
162 if not done_ids:
163 return LLHookResult(exit_code=0)
165 # Collect open issues (default call skips done/cancelled/deferred)
166 open_issues = find_issues(config)
168 all_findings: list[tuple[Path, int, str, str]] = []
170 for issue in open_issues:
171 path: Path = issue.path
172 if not path.is_file():
173 continue
175 if fix_mode == "auto":
176 _auto_fix_file(path, done_ids)
178 findings = _scan_file(path, done_ids)
179 for lineno, issue_id, snippet in findings:
180 all_findings.append((path, lineno, issue_id, snippet))
182 if not all_findings:
183 return LLHookResult(exit_code=0)
185 lines = [f"[ll] {len(all_findings)} stale cross-issue reference(s) found:"]
186 for path, lineno, issue_id, snippet in all_findings:
187 lines.append(f" {path}:{lineno}: [{issue_id}] {snippet}")
188 feedback = "\n".join(lines)
190 return LLHookResult(exit_code=0, feedback=feedback)
192 except Exception:
193 return LLHookResult(exit_code=0)