1"""ll-issues epic-consistency: detect and reconcile EPIC body/parent drift (FEAT-2332)."""

2

3 from __future__ import annotations

4

5 import argparse

6 import re

7 from dataclasses import dataclass, field

8 from pathlib import Path

9 from typing import TYPE_CHECKING

10

11 if TYPE_CHECKING:

12 from little_loops.config import BRConfig

13 from little_loops.issue_parser import IssueInfo

14

15_ALL_STATUSES: set[str] = {"open", "in_progress", "blocked", "done", "cancelled", "deferred"}

16

17# Matches the frontmatter type: field value (captures the value after "type: ")

18_FM_TYPE_RE = re.compile(r"^type:\s*(\S+)", re.MULTILINE)

19# Matches a frontmatter children: key (list start)

20_FM_CHILDREN_RE = re.compile(r"^children\s*:", re.MULTILINE)

21

22# Issue types that participate in the (a)/(b) parent-child diff.

23# EPIC-* refs in body are treated as sub-epic prose (advisory only).

24# Everything else (MR-*, CT-*, EG-*, …) is skipped.

25_REAL_ISSUE_TYPES = frozenset({"BUG", "FEAT", "ENH"})

26

27# Matches bullet list items with an optional bold wrapper around the ID:

28# - FEAT-001 … → group(1) = "FEAT-001"

29# - **FEAT-001** — … → group(1) = "FEAT-001"

30_BODY_BULLET_RE = re.compile(r"^\s*[-*]\s+\*{0,2}([A-Z]+-\d+)", re.MULTILINE)

31

32

33@dataclass

34 class EpicDrift:

35 """Drift report for a single EPIC."""

36

37 epic_id: str

38 epic_title: str

39 missing_from_body: list[str] = field(default_factory=list) # (a)

40 body_without_parent: list[str] = field(default_factory=list) # (b)

41 relates_to_is_child: list[str] = field(default_factory=list) # (c)

42 sub_epic_advisory: list[str] = field(default_factory=list) # advisory

43 type_casing_wrong: bool = False # schema: type: must be 'EPIC'

44 has_children_frontmatter: bool = False # schema: children: key forbidden

45

46 @property

47 def has_drift(self) -> bool:

48 """True when any actionable drift exists (a/b/c categories or schema violations)."""

49 return bool(

50 self.missing_from_body

51 or self.body_without_parent

52 or self.relates_to_is_child

53 or self.type_casing_wrong

54 or self.has_children_frontmatter

55 )

56

57 def to_dict(self) -> dict:

58 """Serialize to a JSON-serializable dict for --format json output."""

59 return {

60 "epic": self.epic_id,

61 "missing_from_body": sorted(self.missing_from_body),

62 "body_without_parent": sorted(self.body_without_parent),

63 "relates_to_is_child": sorted(self.relates_to_is_child),

64 "sub_epic_advisory": sorted(self.sub_epic_advisory),

65 "type_casing_wrong": self.type_casing_wrong,

66 "has_children_frontmatter": self.has_children_frontmatter,

67 }

68

69

70 def _section_bounds(content: str, heading: str) -> tuple[int, int] | None:

71 """Return (body_start, body_end) byte offsets for a ## heading section.

72

73 Returns None when the heading is absent.

74 """

75 pattern = rf"^##\s+{re.escape(heading)}\s*$"

76 match = re.search(pattern, content, re.MULTILINE)

77 if not match:

78 return None

79 start = match.end()

80 next_match = re.search(r"^##\s", content[start:], re.MULTILINE)

81 end = start + next_match.start() if next_match else len(content)

82 return start, end

83

84

85 def _parse_children_body(section_text: str) -> tuple[set[str], set[str]]:

86 """Parse a ## Children section body.

87

88 Returns:

89 (real_issue_ids, sub_epic_ids) where real_issue_ids contains only

90 BUG/FEAT/ENH tokens and sub_epic_ids contains EPIC-* tokens.

91 Non-issue tokens (MR-*, CT-*, EG-*, …) are silently dropped.

92 """

93 real_ids: set[str] = set()

94 sub_epic_ids: set[str] = set()

95 for m in _BODY_BULLET_RE.finditer(section_text):

96 token = m.group(1)

97 issue_type = token.split("-")[0]

98 if issue_type in _REAL_ISSUE_TYPES:

99 real_ids.add(token)

100 elif issue_type == "EPIC":

101 sub_epic_ids.add(token)

102 # else: skip non-issue tokens (MR-*, CT-*, EG-*, …)

103 return real_ids, sub_epic_ids

104

105

106 def compute_drift(

107 epic_id: str,

108 all_issues: list[IssueInfo],

109) -> EpicDrift | None:

110 """Compute drift for a single EPIC against all loaded issues.

111

112 Returns None when the EPIC ID is not found in all_issues.

113 """

114 epic_id = epic_id.upper()

115

116 epic_matches = [i for i in all_issues if i.issue_id == epic_id]

117 if not epic_matches:

118 return None

119 epic_info = epic_matches[0]

120

121 # Authoritative child set: issues carrying parent: epic_id (real types only)

122 parent_children: set[str] = {

123 i.issue_id

124 for i in all_issues

125 if i.parent == epic_id and i.issue_id.split("-")[0] in _REAL_ISSUE_TYPES

126 }

127

128 # Advisory: EPICs that carry parent: epic_id (should use relates_to + prose)

129 sub_epic_advisory: list[str] = sorted(

130 i.issue_id for i in all_issues if i.parent == epic_id and i.issue_id.startswith("EPIC-")

131 )

132

133 # Parse file content for body and frontmatter checks

134 try:

135 content = epic_info.path.read_text(encoding="utf-8")

136 except OSError:

137 content = ""

138

139 # Extract frontmatter block (between first pair of --- delimiters)

140 fm_block = ""

141 if content.startswith("---"):

142 end_marker = content.find("\n---", 3)

143 if end_marker != -1:

144 fm_block = content[3:end_marker]

145

146 # Schema check (d): type: must be 'EPIC' when present; absent is OK

147 type_casing_wrong = False

148 type_match = _FM_TYPE_RE.search(fm_block)

149 if type_match and type_match.group(1) != "EPIC":

150 type_casing_wrong = True

151

152 # Schema check (e): children: frontmatter key is forbidden

153 has_children_frontmatter = bool(_FM_CHILDREN_RE.search(fm_block))

154

155 bounds = _section_bounds(content, "Children")

156 if bounds is not None:

157 section_body = content[bounds[0] : bounds[1]]

158 body_real_ids, _ = _parse_children_body(section_body)

159 else:

160 body_real_ids = set()

161

162 # (a) parent: child missing from body

163 missing_from_body = sorted(parent_children - body_real_ids)

164

165 # (b) body-listed real issue with no parent: backref

166 body_without_parent = sorted(body_real_ids - parent_children)

167

168 # (c) relates_to: entries that are also parent: children

169 relates_to_set = set(epic_info.relates_to or [])

170 relates_to_is_child = sorted(relates_to_set & parent_children)

171

172 return EpicDrift(

173 epic_id=epic_id,

174 epic_title=epic_info.title,

175 missing_from_body=missing_from_body,

176 body_without_parent=body_without_parent,

177 relates_to_is_child=relates_to_is_child,

178 sub_epic_advisory=sub_epic_advisory,

179 type_casing_wrong=type_casing_wrong,

180 has_children_frontmatter=has_children_frontmatter,

181 )

182

183

184 def fix_epic(epic_path: Path, missing_from_body: list[str]) -> None:

185 """Add missing parent: children to an EPIC's ## Children section.

186

187 Existing lines (sub-epic prose, non-issue tokens, category-(b) entries)

188 are preserved. Only category-(a) drift is fixed. Running twice on an

189 already-fixed file is a no-op (idempotent).

190 """

191 if not missing_from_body:

192 return

193

194 content = epic_path.read_text(encoding="utf-8")

195 new_bullets = "\n".join(

196 f"- **{child_id}** — (added by epic-consistency --fix)"

197 for child_id in sorted(missing_from_body)

198 )

199

200 bounds = _section_bounds(content, "Children")

201 if bounds is None:

202 # No ## Children section — create one at the end of the file

203 new_section = "\n## Children\n\n" + new_bullets + "\n"

204 new_content = content.rstrip("\n") + "\n" + new_section

205 else:

206 start, end = bounds

207 section_body = content[start:end]

208 # Append after existing content (preserving all existing lines)

209 stripped = section_body.rstrip("\n")

210 sep = "\n" if stripped.strip() else ""

211 new_section_body = stripped + sep + "\n" + new_bullets + "\n"

212 new_content = content[:start] + new_section_body + content[end:]

213

214 from little_loops.file_utils import atomic_write

215

216 atomic_write(epic_path, new_content)

217

218

219 def add_epic_consistency_parser(subs: argparse._SubParsersAction) -> argparse.ArgumentParser:

220 """Register the epic-consistency subparser on *subs*."""

221 from little_loops.cli_args import add_config_arg

222

223 p = subs.add_parser(

224 "epic-consistency",

225 aliases=["ec"],

226 help="Detect and reconcile EPIC body/parent drift",

227 )

228 p.set_defaults(command="epic-consistency")

229 p.add_argument(

230 "epic_id",

231 nargs="?",

232 default=None,

233 help="EPIC ID (e.g., EPIC-1773); omit when using --all",

234 )

235 p.add_argument("--all", "-a", action="store_true", help="Check all EPICs in epics dir")

236 p.add_argument(

237 "--fix",

238 action="store_true",

239 help="Rewrite ## Children for category-(a) drift (report-only by default)",

240 )

241 p.add_argument(

242 "--format",

243 "-f",

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

245 default="text",

246 help="Output format (default: text)",

247 )

248 add_config_arg(p)

249 return p

250

251

252 def cmd_epic_consistency(config: BRConfig, args: argparse.Namespace) -> int:

253 """Detect and reconcile EPIC body/parent drift.

254

255 Returns:

256 0 when no drift found (or --fix ran cleanly), 1 on drift/error.

257 """

258 from little_loops.cli.output import print_json

259 from little_loops.issue_parser import find_issues

260

261 epic_id: str | None = getattr(args, "epic_id", None)

262 check_all: bool = getattr(args, "all", False)

263 fix: bool = getattr(args, "fix", False)

264 fmt: str = getattr(args, "format", "text") or "text"

265

266 if not epic_id and not check_all:

267 print("Error: provide an EPIC-ID or --all")

268 return 1

269

270 if epic_id and not epic_id.upper().startswith("EPIC-"):

271 print(f"Error: expected an EPIC ID (e.g., EPIC-1773), got {epic_id!r}")

272 return 1

273

274 all_issues = find_issues(config, status_filter=_ALL_STATUSES)

275

276 if check_all:

277 epic_ids = sorted(i.issue_id for i in all_issues if i.issue_id.startswith("EPIC-"))

278 else:

279 epic_ids = [epic_id.upper()] # type: ignore[union-attr]

280

281 results: list[EpicDrift] = []

282 for eid in epic_ids:

283 drift = compute_drift(eid, all_issues)

284 if drift is None:

285 print(f"Error: EPIC {eid!r} not found")

286 return 1

287 results.append(drift)

288

289 if fix:

290 for drift in results:

291 if drift.missing_from_body:

292 epic_issues = [i for i in all_issues if i.issue_id == drift.epic_id]

293 if epic_issues:

294 fix_epic(epic_issues[0].path, drift.missing_from_body)

295 # Re-check after fix so output reflects the updated state

296 results = []

297 for eid in epic_ids:

298 drift = compute_drift(eid, all_issues)

299 if drift is not None:

300 results.append(drift)

301

302 any_drift = any(d.has_drift for d in results)

303

304 if fmt == "json":

305 if len(results) == 1:

306 print_json(results[0].to_dict())

307 else:

308 print_json({"results": [d.to_dict() for d in results]})

309 return 0 if fix else (1 if any_drift else 0)

310

311 # text format

312 for drift in results:

313 if not drift.has_drift and not drift.sub_epic_advisory:

314 print(f"{drift.epic_id}: {drift.epic_title} — OK")

315 continue

316 print(f"{drift.epic_id}: {drift.epic_title}")

317 if drift.type_casing_wrong:

318 print(" (d) Schema: type: casing must be 'EPIC' (not lowercase 'epic')")

319 if drift.has_children_frontmatter:

320 print(" (e) Schema: frontmatter children: array is forbidden; use parent: backrefs")

321 if drift.missing_from_body:

322 print(" (a) Missing from body (parent: child not documented):")

323 for child_id in drift.missing_from_body:

324 print(f" {child_id}")

325 if drift.body_without_parent:

326 print(" (b) Body-listed, no parent: backref (human decision needed):")

327 for child_id in drift.body_without_parent:

328 print(f" {child_id}")

329 if drift.relates_to_is_child:

330 print(" (c) relates_to: leaking membership:")

331 for child_id in drift.relates_to_is_child:

332 print(f" {child_id}")

333 if drift.sub_epic_advisory:

334 print(" [advisory] Sub-epic via parent: (should use relates_to + prose):")

335 for child_id in drift.sub_epic_advisory:

336 print(f" {child_id}")

337

338 return 0 if fix else (1 if any_drift else 0)