Coverage for little_loops / decisions.py: 0%

163 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-04 12:21 -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# Open dispatch registry — add new entry types here without modifying CRUD functions 

196AnyEntry = RuleEntry | DecisionEntry | ExceptionEntry 

197 

198_ENTRY_REGISTRY: dict[str, Any] = { 

199 "rule": RuleEntry, 

200 "decision": DecisionEntry, 

201 "exception": ExceptionEntry, 

202} 

203 

204 

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

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

207 cls = _ENTRY_REGISTRY.get(entry_type) 

208 if cls is None: 

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

210 return cls.from_dict(data) 

211 

212 

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

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

215 resolved = _resolve_path(path) 

216 if not resolved.exists(): 

217 return [] 

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

219 data = yaml.safe_load(raw) 

220 if not data: 

221 return [] 

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

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

224 

225 

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

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

228 resolved = _resolve_path(path) 

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

230 content = yaml.dump( 

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

232 default_flow_style=False, 

233 sort_keys=False, 

234 allow_unicode=True, 

235 ) 

236 atomic_write(resolved, content) 

237 

238 

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

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

241 entries = load_decisions(path) 

242 entries.append(entry) 

243 save_decisions(entries, path) 

244 

245 

246def list_entries( 

247 path: Path | None = None, 

248 *, 

249 type: str | None = None, 

250 category: str | None = None, 

251 label: str | None = None, 

252) -> list[AnyEntry]: 

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

254 entries = load_decisions(path) 

255 if type is not None: 

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

257 if category is not None: 

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

259 if label is not None: 

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

261 return entries 

262 

263 

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

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

266 

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

268 """ 

269 superseded_ids = { 

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

271 } 

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

273 

274 

275def set_outcome( 

276 entry_id: str, 

277 result: str, 

278 measured_at: str, 

279 notes: str | None = None, 

280 path: Path | None = None, 

281 *, 

282 force: bool = False, 

283) -> None: 

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

285 entries = load_decisions(path) 

286 for entry in entries: 

287 if entry.id == entry_id: 

288 if not isinstance(entry, DecisionEntry): 

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

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

291 raise ValueError( 

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

293 ) 

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

295 save_decisions(entries, path) 

296 return 

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

298 

299 

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

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

302 

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

304 Skips issues that already have an entry in the log. Returns the count added. 

305 """ 

306 from little_loops.issue_history.parsing import ( 

307 scan_completed_issues, 

308 scan_completed_issues_from_db, 

309 ) 

310 

311 project_root = Path(config.project_root) 

312 log_path = project_root / config.decisions.log_path 

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

314 

315 if db_path.exists(): 

316 completed = scan_completed_issues_from_db(db_path) 

317 else: 

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

319 

320 existing = load_decisions(log_path) 

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

322 

323 count = 0 

324 for issue in completed: 

325 if issue.issue_id in existing_issue_ids: 

326 continue 

327 ts = "" 

328 if issue.completed_at: 

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

330 elif issue.captured_at: 

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

332 entry = DecisionEntry( 

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

334 timestamp=ts, 

335 category=issue.issue_type.lower(), 

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

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

338 issue=issue.issue_id, 

339 scope="issue", 

340 ) 

341 add_entry(entry, log_path) 

342 count += 1 

343 

344 return count