7Result types for the roadmap reconciliation check (Plan 205).
11Carry the findings, counts and plan-link join rows the reconciliation check
12produces, in a shape that is **visibly distinguishable from drift**.
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.
25 L2 - report construction lifecycle
26 L3 - counts and severity summaries
27 L4 - per-finding construction
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.
40- oc_diagnostics (structured debug logging via _dbg)
42from __future__
import annotations
44from dataclasses
import dataclass, field
45from typing
import Literal, Mapping, Sequence
47from oc_diagnostics
import _dbg
49_dbg(
"OC_CHAT.MAIN",
"module",
"loaded", 4)
51Severity = Literal[
"violation",
"advisory"]
56QUESTION =
"source-records-vs-what-landed"
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)"
68FINDING_KINDS: tuple[str, ...] = (
70 "changelog_status_divergence",
73 "advances_not_advanced",
74 "advances_unknown_record",
80 "plan_frontmatter_unparseable",
84ADVISORY_ONLY_KINDS: frozenset[str] = frozenset(
85 {
"stale_completion",
"stale_deliverables",
"plan_frontmatter_unparseable"}
89@dataclass(frozen=True)
91 """One reconciliation finding: a claim that the roadmap state does not back."""
98 evidence: Mapping[str, object] = field(default_factory=dict)
101 """The JSON projection of this finding."""
113@dataclass(frozen=True)
115 """A plan-to-roadmap-item join row, derived from a plan's ``advances:`` field.
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.
124 contribution: Literal[
"full",
"partial",
"context_only"]
128 """The JSON projection of this join row."""
138@dataclass(frozen=True)
140 """A status movement observed in git history, keyed by roadmap record id."""
143 from_status: str |
None
149 """The JSON projection of this transition."""
160@dataclass(frozen=True)
162 """The full result of one reconciliation run."""
164 findings: tuple[Finding, ...]
165 links: tuple[PlanLink, ...]
166 transitions: tuple[StatusTransition, ...]
167 counts: Mapping[str, int]
168 advances_none_count: int
170 changelog_records_checked: int
172 unscannable_ids: tuple[str, ...]
173 question: str = QUESTION
175 history_error: str |
None =
None
179 changelog_scope_error: str |
None =
None
183 """False when class 1 examined nothing, for ANY reason.
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.
195 """Why class 1 examined nothing, or None when it really did run."""
201 """How many findings a failing mode would act on."""
203 return sum(1
for finding
in self.
findings if finding.severity ==
"violation")
207 """How many findings are advisory-only."""
209 return sum(1
for finding
in self.
findings if finding.severity ==
"advisory")
213 """Per-kind ``{violation, advisory}`` split.
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.
220 split = {kind: {
"violation": 0,
"advisory": 0}
for kind
in FINDING_KINDS}
222 bucket = split.setdefault(finding.kind, {
"violation": 0,
"advisory": 0})
223 bucket[finding.severity] = bucket.get(finding.severity, 0) + 1
228 """True when a failing mode would exit non-zero on this run."""
233 def ok(self) -> bool:
234 """Always True — the check lands advisory (see the module Contracts)."""
239 """The JSON surface, stamped with the question this check answers."""
248 "counts": dict(self.
counts),
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],
267 """Count ``findings`` by kind, with every known kind present (zeros included).
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.
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)
dict[str, object] to_dict(self)
dict[str, str] to_dict(self)
changelog_records_checked
changelog_not_evaluated_reason
changelog_class_evaluated
str changelog_scope_error
dict[str, object] to_dict(self)
dict[str, object] to_dict(self)
dict[str, int] count_by_kind(Sequence[Finding] findings)