Coverage for little_loops / decisions.py: 0%

198 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-15 17:27 -0500

1"""Decisions and rules log data layer. 

2 

3Provides typed dataclasses and CRUD operations for managing architectural 

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

5""" 

6 

7from __future__ import annotations 

8 

9from dataclasses import dataclass, field 

10from pathlib import Path 

11from typing import TYPE_CHECKING, Any 

12 

13if TYPE_CHECKING: 

14 from little_loops.config.core import BRConfig 

15 

16import yaml 

17 

18from little_loops.file_utils import atomic_write 

19 

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

21 

22 

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

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

25 

26 

27@dataclass 

28class 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 

51class 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 

99class 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 

152class 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 

196class 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 

254AnyEntry = 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 

264def _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 

272def 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 

285def 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 

298def 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 

305def 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 

323def 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 

334def 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 

359def 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 

382def 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. Returns the count added. 

387 """ 

388 from little_loops.issue_history.parsing import ( 

389 scan_completed_issues, 

390 scan_completed_issues_from_db, 

391 ) 

392 

393 project_root = Path(config.project_root) 

394 log_path = project_root / config.decisions.log_path 

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

396 

397 if db_path.exists(): 

398 completed = scan_completed_issues_from_db(db_path) 

399 else: 

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

401 

402 existing = load_decisions(log_path) 

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

404 

405 count = 0 

406 for issue in completed: 

407 if issue.issue_id in existing_issue_ids: 

408 continue 

409 ts = "" 

410 if issue.completed_at: 

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

412 elif issue.captured_at: 

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

414 entry = DecisionEntry( 

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

416 timestamp=ts, 

417 category=issue.issue_type.lower(), 

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

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

420 issue=issue.issue_id, 

421 scope="issue", 

422 ) 

423 add_entry(entry, log_path) 

424 count += 1 

425 

426 return count