1"""Decisions and rules log data layer.

2

3 Provides typed dataclasses and CRUD operations for managing architectural

4 decisions, team-enforced rules, and exceptions stored in `.ll/decisions.yaml`.

5"""

6

7 from __future__ import annotations

8

9 from dataclasses import dataclass, field

10 from pathlib import Path

11 from typing import TYPE_CHECKING, Any

12

13 if TYPE_CHECKING:

14 from little_loops.config.core import BRConfig

15

16 import yaml

17

18 from little_loops.file_utils import atomic_write

19

20_DEFAULT_LOG_PATH = Path(".ll") / "decisions.yaml"

21

22

23 def _resolve_path(path: Path | None) -> Path:

24 return path if path is not None else Path.cwd() / _DEFAULT_LOG_PATH

25

26

27@dataclass

28 class DecisionOutcome:

29 """Recorded outcome for a decision entry."""

30

31 result: str

32 measured_at: str

33 notes: str | None = None

34

35 @classmethod

36 def from_dict(cls, data: dict[str, Any]) -> DecisionOutcome:

37 return cls(

38 result=data["result"],

39 measured_at=data["measured_at"],

40 notes=data.get("notes"),

41 )

42

43 def to_dict(self) -> dict[str, Any]:

44 d: dict[str, Any] = {"result": self.result, "measured_at": self.measured_at}

45 if self.notes is not None:

46 d["notes"] = self.notes

47 return d

48

49

50@dataclass

51 class RuleEntry:

52 """An enforced rule in the decisions log."""

53

54 id: str

55 type: str = "rule"

56 timestamp: str = ""

57 category: str = ""

58 labels: list[str] = field(default_factory=list)

59 rationale: str = ""

60 rule: str = ""

61 enforcement: str = "advisory"

62 supersedes: str | None = None

63 issue: str | None = None

64

65 @classmethod

66 def from_dict(cls, data: dict[str, Any]) -> RuleEntry:

67 return cls(

68 id=data["id"],

69 type=data.get("type", "rule"),

70 timestamp=data.get("timestamp", ""),

71 category=data.get("category", ""),

72 labels=data.get("labels", []),

73 rationale=data.get("rationale", ""),

74 rule=data.get("rule", ""),

75 enforcement=data.get("enforcement", "advisory"),

76 supersedes=data.get("supersedes"),

77 issue=data.get("issue"),

78 )

79

80 def to_dict(self) -> dict[str, Any]:

81 d: dict[str, Any] = {

82 "id": self.id,

83 "type": self.type,

84 "timestamp": self.timestamp,

85 "category": self.category,

86 "labels": self.labels,

87 "rationale": self.rationale,

88 "rule": self.rule,

89 "enforcement": self.enforcement,

90 }

91 if self.supersedes is not None:

92 d["supersedes"] = self.supersedes

93 if self.issue is not None:

94 d["issue"] = self.issue

95 return d

96

97

98@dataclass

99 class DecisionEntry:

100 """A recorded architectural or process decision."""

101

102 id: str

103 type: str = "decision"

104 timestamp: str = ""

105 category: str = ""

106 labels: list[str] = field(default_factory=list)

107 rationale: str = ""

108 rule: str = ""

109 alternatives_rejected: str | None = None

110 issue: str | None = None

111 scope: str = "issue"

112 outcome: DecisionOutcome | None = None

113

114 @classmethod

115 def from_dict(cls, data: dict[str, Any]) -> DecisionEntry:

116 outcome_data = data.get("outcome")

117 return cls(

118 id=data["id"],

119 type=data.get("type", "decision"),

120 timestamp=data.get("timestamp", ""),

121 category=data.get("category", ""),

122 labels=data.get("labels", []),

123 rationale=data.get("rationale", ""),

124 rule=data.get("rule", ""),

125 alternatives_rejected=data.get("alternatives_rejected"),

126 issue=data.get("issue"),

127 scope=data.get("scope", "issue"),

128 outcome=DecisionOutcome.from_dict(outcome_data) if outcome_data else None,

129 )

130

131 def to_dict(self) -> dict[str, Any]:

132 d: dict[str, Any] = {

133 "id": self.id,

134 "type": self.type,

135 "timestamp": self.timestamp,

136 "category": self.category,

137 "labels": self.labels,

138 "rationale": self.rationale,

139 "rule": self.rule,

140 "scope": self.scope,

141 }

142 if self.alternatives_rejected is not None:

143 d["alternatives_rejected"] = self.alternatives_rejected

144 if self.issue is not None:

145 d["issue"] = self.issue

146 if self.outcome is not None:

147 d["outcome"] = self.outcome.to_dict()

148 return d

149

150

151@dataclass

152 class ExceptionEntry:

153 """A one-time exception to an existing rule."""

154

155 id: str

156 type: str = "exception"

157 timestamp: str = ""

158 category: str = ""

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

160 rationale: str = ""

161 rule_ref: str = ""

162 issue: str = ""

163 alternatives_rejected: str | None = None

164

165 @classmethod

166 def from_dict(cls, data: dict[str, Any]) -> ExceptionEntry:

167 return cls(

168 id=data["id"],

169 type=data.get("type", "exception"),

170 timestamp=data.get("timestamp", ""),

171 category=data.get("category", ""),

172 labels=data.get("labels", []),

173 rationale=data.get("rationale", ""),

174 rule_ref=data.get("rule_ref", ""),

175 issue=data.get("issue", ""),

176 alternatives_rejected=data.get("alternatives_rejected"),

177 )

178

179 def to_dict(self) -> dict[str, Any]:

180 d: dict[str, Any] = {

181 "id": self.id,

182 "type": self.type,

183 "timestamp": self.timestamp,

184 "category": self.category,

185 "labels": self.labels,

186 "rationale": self.rationale,

187 "rule_ref": self.rule_ref,

188 "issue": self.issue,

189 }

190 if self.alternatives_rejected is not None:

191 d["alternatives_rejected"] = self.alternatives_rejected

192 return d

193

194

195@dataclass

196 class CouplingEntry:

197 """A coupling rule linking changed files to required audit targets in wire-issue."""

198

199 id: str

200 type: str = "coupling"

201 timestamp: str = ""

202 category: str = ""

203 labels: list[str] = field(default_factory=list)

204 rationale: str = ""

205 if_changed: str = ""

206 then_check: list[str] = field(default_factory=list)

207 tier: str = "soft" # hard | soft | fyi

208 archetype: str | None = None

209 enforcement: str = "advisory"

210 supersedes: str | None = None

211 issue: str | None = None

212

213 @classmethod

214 def from_dict(cls, data: dict[str, Any]) -> CouplingEntry:

215 return cls(

216 id=data["id"],

217 type=data.get("type", "coupling"),

218 timestamp=data.get("timestamp", ""),

219 category=data.get("category", ""),

220 labels=data.get("labels", []),

221 rationale=data.get("rationale", ""),

222 if_changed=data.get("if_changed", ""),

223 then_check=data.get("then_check", []),

224 tier=data.get("tier", "soft"),

225 archetype=data.get("archetype"),

226 enforcement=data.get("enforcement", "advisory"),

227 supersedes=data.get("supersedes"),

228 issue=data.get("issue"),

229 )

230

231 def to_dict(self) -> dict[str, Any]:

232 d: dict[str, Any] = {

233 "id": self.id,

234 "type": self.type,

235 "timestamp": self.timestamp,

236 "category": self.category,

237 "labels": self.labels,

238 "rationale": self.rationale,

239 "if_changed": self.if_changed,

240 "then_check": self.then_check,

241 "tier": self.tier,

242 "enforcement": self.enforcement,

243 }

244 if self.archetype is not None:

245 d["archetype"] = self.archetype

246 if self.supersedes is not None:

247 d["supersedes"] = self.supersedes

248 if self.issue is not None:

249 d["issue"] = self.issue

250 return d

251

252

253# Open dispatch registry — add new entry types here without modifying CRUD functions

254 AnyEntry = RuleEntry | DecisionEntry | ExceptionEntry | CouplingEntry

255

256_ENTRY_REGISTRY: dict[str, Any] = {

257 "rule": RuleEntry,

258 "decision": DecisionEntry,

259 "exception": ExceptionEntry,

260 "coupling": CouplingEntry,

261}

262

263

264 def _entry_from_dict(data: dict[str, Any]) -> AnyEntry:

265 entry_type = data.get("type", "rule")

266 cls = _ENTRY_REGISTRY.get(entry_type)

267 if cls is None:

268 raise ValueError(f"Unknown entry type: {entry_type!r}")

269 return cls.from_dict(data)

270

271

272 def load_decisions(path: Path | None = None) -> list[AnyEntry]:

273 """Load all decision log entries from YAML; returns empty list if file absent."""

274 resolved = _resolve_path(path)

275 if not resolved.exists():

276 return []

277 raw = resolved.read_text(encoding="utf-8")

278 data = yaml.safe_load(raw)

279 if not data:

280 return []

281 entries = data if isinstance(data, list) else data.get("entries", [])

282 return [_entry_from_dict(e) for e in entries]

283

284

285 def save_decisions(entries: list[AnyEntry], path: Path | None = None) -> None:

286 """Atomically persist decision log entries to YAML."""

287 resolved = _resolve_path(path)

288 resolved.parent.mkdir(parents=True, exist_ok=True)

289 content = yaml.dump(

290 [e.to_dict() for e in entries],

291 default_flow_style=False,

292 sort_keys=False,

293 allow_unicode=True,

294 )

295 atomic_write(resolved, content)

296

297

298 def add_entry(entry: AnyEntry, path: Path | None = None) -> None:

299 """Append a new entry to the decisions log."""

300 entries = load_decisions(path)

301 entries.append(entry)

302 save_decisions(entries, path)

303

304

305 def list_entries(

306 path: Path | None = None,

307 *,

308 type: str | None = None,

309 category: str | None = None,

310 label: str | None = None,

311) -> list[AnyEntry]:

312 """Return entries, optionally filtered by type, category, or label."""

313 entries = load_decisions(path)

314 if type is not None:

315 entries = [e for e in entries if e.type == type]

316 if category is not None:

317 entries = [e for e in entries if e.category == category]

318 if label is not None:

319 entries = [e for e in entries if label in e.labels]

320 return entries

321

322

323 def resolve_active(entries: list[AnyEntry]) -> list[AnyEntry]:

324 """Return entries excluding those superseded by a newer entry.

325

326 An entry is inactive if another entry's `supersedes` field references its ID.

327 """

328 superseded_ids = {

329 getattr(e, "supersedes", None) for e in entries if getattr(e, "supersedes", None)

330 }

331 return [e for e in entries if e.id not in superseded_ids]

332

333

334 def set_outcome(

335 entry_id: str,

336 result: str,

337 measured_at: str,

338 notes: str | None = None,

339 path: Path | None = None,

340 *,

341 force: bool = False,

342) -> None:

343 """Set the outcome on a decision entry; refuses to overwrite without force=True."""

344 entries = load_decisions(path)

345 for entry in entries:

346 if entry.id == entry_id:

347 if not isinstance(entry, DecisionEntry):

348 raise TypeError(f"Entry {entry_id!r} is not a DecisionEntry (got {entry.type!r})")

349 if entry.outcome is not None and not force:

350 raise ValueError(

351 f"Entry {entry_id!r} already has an outcome. Use force=True to overwrite."

352 )

353 entry.outcome = DecisionOutcome(result=result, measured_at=measured_at, notes=notes)

354 save_decisions(entries, path)

355 return

356 raise KeyError(f"No entry with id {entry_id!r}")

357

358

359 def load_coupling_entries(

360 path: Path | None = None,

361 *,

362 changed_globs: list[str] | None = None,

363 archetype: str | None = None,

364) -> list[CouplingEntry]:

365 """Return coupling entries, optionally filtered by glob match against changed files and archetype.

366

367 Returns an empty list when decisions.yaml is absent (graceful degradation).

368 """

369 from fnmatch import fnmatch

370

371 entries = [e for e in load_decisions(path) if isinstance(e, CouplingEntry)]

372

373 if archetype is not None:

374 entries = [e for e in entries if e.archetype == archetype]

375

376 if changed_globs is not None:

377 entries = [e for e in entries if any(fnmatch(f, e.if_changed) for f in changed_globs)]

378

379 return entries

380

381

382 def generate_from_completed(config: BRConfig) -> int:

383 """Generate DecisionEntry records from completed issues and persist to the log.

384

385 Prefers the SQLite history DB when present; falls back to filesystem scanning.

386 Skips issues that already have an entry in the log. When

387 ``config.decisions.auto_generate`` is non-empty, only issues whose type

388 prefix appears in the list are processed (e.g. ``["FEAT", "ENH"]`` skips

389 BUG entries). Returns the count added.

390 """

391 from little_loops.issue_history.parsing import (

392 scan_completed_issues,

393 scan_completed_issues_from_db,

394 )

395

396 project_root = Path(config.project_root)

397 log_path = project_root / config.decisions.log_path

398 db_path = project_root / ".ll" / "history.db"

399

400 if db_path.exists():

401 completed = scan_completed_issues_from_db(db_path)

402 else:

403 completed = scan_completed_issues(project_root / config.issues.base_dir)

404

405 type_filter: set[str] = set(config.decisions.auto_generate)

406

407 existing = load_decisions(log_path)

408 existing_issue_ids = {e.issue for e in existing if isinstance(e, DecisionEntry) and e.issue}

409

410 count = 0

411 for issue in completed:

412 if type_filter and issue.issue_type not in type_filter:

413 continue

414 if issue.issue_id in existing_issue_ids:

415 continue

416 ts = ""

417 if issue.completed_at:

418 ts = issue.completed_at.strftime("%Y-%m-%dT%H:%M:%SZ")

419 elif issue.captured_at:

420 ts = issue.captured_at.strftime("%Y-%m-%dT%H:%M:%SZ")

421 entry = DecisionEntry(

422 id=f"DEC-{issue.issue_id}",

423 timestamp=ts,

424 category=issue.issue_type.lower(),

425 labels=[issue.priority, issue.issue_type.lower()],

426 rationale=f"Auto-generated from completed issue {issue.issue_id}",

427 issue=issue.issue_id,

428 scope="issue",

429 )

430 add_entry(entry, log_path)

431 count += 1

432

433 return count