oc_chat
Loading...
Searching...
No Matches
model.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/roadmap/reconcile/model.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/roadmap/test_reconcile.py"
6"""
7Result types for the roadmap reconciliation check (Plan 205).
8
9Purpose
10-------
11Carry the findings, counts and plan-link join rows the reconciliation check
12produces, in a shape that is **visibly distinguishable from drift**.
13
14Responsibilities
15----------------
16- Define the finding vocabulary (``FINDING_KINDS``) and the two severities.
17- Carry the plan-to-item join rows the check derives from ``advances:``.
18- Expose ``ReconcileReport.to_dict()`` — the JSON surface, which names the
19 question it answers so a reader can never mistake it for the drift check.
20
21Diagnostics
22-----------
23Domain: OC_CHAT.MAIN
24Levels:
25 L2 - report construction lifecycle
26 L3 - counts and severity summaries
27 L4 - per-finding construction
28
29Contracts
30---------
31- Every type here is frozen and side-effect free.
32- ``ok`` reflects the *advisory* landing mode: it stays True with violations
33 present. ``would_fail`` is the signal a failing mode would act on. Callers
34 choosing to fail must read ``would_fail``, never ``ok``.
35- ``question`` is never the string ``"drift"``; the two checks answer different
36 questions and conflating them is what Plan 205 exists to prevent.
37
38Dependencies
39------------
40- oc_diagnostics (structured debug logging via _dbg)
41"""
42from __future__ import annotations
43
44from dataclasses import dataclass, field
45from typing import Literal, Mapping, Sequence
46
47from oc_diagnostics import _dbg
48
49_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
50
51Severity = Literal["violation", "advisory"]
52
53#: The question this check answers, stamped into every payload. The drift check
54#: answers "do the rendered views match the source records?"; this one answers
55#: "do the source records match what actually landed?".
56QUESTION = "source-records-vs-what-landed"
57
58#: The one-paragraph disclaimer rendered above human output, so a reader never
59#: again reads a green drift line as "the roadmap is true".
60NOT_DRIFT_BANNER = (
61 "roadmap reconciliation — this is NOT the drift check\n"
62 " drift : rendered views vs source records (internal consistency of two\n"
63 " artifacts generated seconds apart; cannot see reality)\n"
64 " this : source records vs what actually landed (claims vs state)"
65)
66
67#: Finding kinds, grouped by the plan's three classes.
68FINDING_KINDS: tuple[str, ...] = (
69 # class 1 — changelog-vs-status divergence
70 "changelog_status_divergence",
71 # class 2 — advances: conformance
72 "advances_missing",
73 "advances_not_advanced",
74 "advances_unknown_record",
75 # class 3 — stale-completion drift (advisory by construction)
76 "stale_completion",
77 "stale_deliverables",
78 # input quality — reported separately so a corpus the check could not read
79 # never masquerades as a corpus that read clean
80 "plan_frontmatter_unparseable",
81)
82
83#: Kinds that can never be violations, whatever the evidence looks like.
84ADVISORY_ONLY_KINDS: frozenset[str] = frozenset(
85 {"stale_completion", "stale_deliverables", "plan_frontmatter_unparseable"}
86)
87
88
89@dataclass(frozen=True)
90class Finding:
91 """One reconciliation finding: a claim that the roadmap state does not back."""
92
93 kind: str
94 severity: Severity
95 subject: str
96 detail: str
97 source_ref: str
98 evidence: Mapping[str, object] = field(default_factory=dict)
99
100 def to_dict(self) -> dict[str, object]:
101 """The JSON projection of this finding."""
102
103 return {
104 "kind": self.kind,
105 "severity": self.severity,
106 "subject": self.subject,
107 "detail": self.detail,
108 "source_ref": self.source_ref,
109 "evidence": dict(self.evidence),
110 }
111
112
113@dataclass(frozen=True)
115 """A plan-to-roadmap-item join row, derived from a plan's ``advances:`` field.
116
117 This is the row Plan 205 wants living in ``roadmap_plan_links`` rather than
118 in prose. ``contribution`` follows the schema's CHECK vocabulary: ``full``
119 when the advanced record is terminal-done, ``partial`` otherwise.
120 """
121
122 plan_id: str
123 item_id: str
124 contribution: Literal["full", "partial", "context_only"]
125 plan_path: str
126
127 def to_dict(self) -> dict[str, str]:
128 """The JSON projection of this join row."""
129
130 return {
131 "plan_id": self.plan_id,
132 "item_id": self.item_id,
133 "contribution": self.contribution,
134 "plan_path": self.plan_path,
135 }
136
137
138@dataclass(frozen=True)
140 """A status movement observed in git history, keyed by roadmap record id."""
141
142 record_id: str
143 from_status: str | None
144 to_status: str
145 sha: str
146 date: str
147
148 def to_dict(self) -> dict[str, object]:
149 """The JSON projection of this transition."""
150
151 return {
152 "record_id": self.record_id,
153 "from_status": self.from_status,
154 "to_status": self.to_status,
155 "sha": self.sha,
156 "date": self.date,
157 }
158
159
160@dataclass(frozen=True)
162 """The full result of one reconciliation run."""
163
164 findings: tuple[Finding, ...]
165 links: tuple[PlanLink, ...]
166 transitions: tuple[StatusTransition, ...]
167 counts: Mapping[str, int]
168 advances_none_count: int
169 plans_checked: int
170 changelog_records_checked: int
171 records_checked: int
172 unscannable_ids: tuple[str, ...]
173 question: str = QUESTION
174 #: Set when git history could not be read at all.
175 history_error: str | None = None
176 #: Set when class 1 examined nothing although there was something to examine
177 #: — a window or ``--source`` pathspec that matched no commits. Distinct from
178 #: ``history_error``: git worked, it just was not pointed at anything.
179 changelog_scope_error: str | None = None
180
181 @property
182 def changelog_class_evaluated(self) -> bool:
183 """False when class 1 examined nothing, for ANY reason.
184
185 A zero class-1 count with this False means "not checked", NOT "clean".
186 Keeping those two indistinguishable is the failure this plan exists to
187 eliminate — it is how a green drift line let the roadmap rot for three
188 weeks, and the tool must not reproduce it.
189 """
190
191 return self.history_error is None and self.changelog_scope_error is None
192
193 @property
194 def changelog_not_evaluated_reason(self) -> str | None:
195 """Why class 1 examined nothing, or None when it really did run."""
196
197 return self.history_error or self.changelog_scope_error
198
199 @property
200 def violation_count(self) -> int:
201 """How many findings a failing mode would act on."""
202
203 return sum(1 for finding in self.findings if finding.severity == "violation")
204
205 @property
206 def advisory_count(self) -> int:
207 """How many findings are advisory-only."""
208
209 return sum(1 for finding in self.findings if finding.severity == "advisory")
210
211 @property
212 def counts_by_severity(self) -> dict[str, dict[str, int]]:
213 """Per-kind ``{violation, advisory}`` split.
214
215 The flat per-kind count alone is misleading once a kind can carry both
216 severities: ``advances_missing: 300`` reads as 300 violations when they
217 may all be grandfathered advisories.
218 """
219
220 split = {kind: {"violation": 0, "advisory": 0} for kind in FINDING_KINDS}
221 for finding in self.findings:
222 bucket = split.setdefault(finding.kind, {"violation": 0, "advisory": 0})
223 bucket[finding.severity] = bucket.get(finding.severity, 0) + 1
224 return split
225
226 @property
227 def would_fail(self) -> bool:
228 """True when a failing mode would exit non-zero on this run."""
229
230 return self.violation_count > 0
231
232 @property
233 def ok(self) -> bool:
234 """Always True — the check lands advisory (see the module Contracts)."""
235
236 return True
237
238 def to_dict(self) -> dict[str, object]:
239 """The JSON surface, stamped with the question this check answers."""
240
241 return {
242 "ok": self.ok,
243 "question": self.question,
244 "not_drift": True,
245 "would_fail": self.would_fail,
246 "violations": self.violation_count,
247 "advisories": self.advisory_count,
248 "counts": dict(self.counts),
249 "counts_by_severity": self.counts_by_severity,
250 "history_error": self.history_error,
251 "changelog_class_evaluated": self.changelog_class_evaluated,
252 "changelog_not_evaluated_reason": self.changelog_not_evaluated_reason,
253 "advances_none": self.advances_none_count,
254 "checked": {
255 "plans": self.plans_checked,
256 "changelog_records": self.changelog_records_checked,
257 "roadmap_records": self.records_checked,
258 },
259 "unscannable_ids": list(self.unscannable_ids),
260 "findings": [finding.to_dict() for finding in self.findings],
261 "links": [link.to_dict() for link in self.links],
262 "transitions": [transition.to_dict() for transition in self.transitions],
263 }
264
265
266def count_by_kind(findings: Sequence[Finding]) -> dict[str, int]:
267 """Count ``findings`` by kind, with every known kind present (zeros included).
268
269 Zeros are emitted deliberately: a kind silently absent from the payload reads
270 as "not checked", which is the same confusion Plan 205 exists to remove.
271 """
272
273 func = "count_by_kind"
274 counts = {kind: 0 for kind in FINDING_KINDS}
275 for finding in findings:
276 counts[finding.kind] = counts.get(finding.kind, 0) + 1
277 _dbg("OC_CHAT.MAIN", func, f"counted {len(findings)} findings", 3)
278 return counts
dict[str, int] count_by_kind(Sequence[Finding] findings)
Definition model.py:266