oc_chat
Loading...
Searching...
No Matches
engine.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/roadmap/reconcile/engine.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/roadmap/test_reconcile.py"
6"""
7Reconciliation run + plan-link persistence (Plan 205).
8
9Purpose
10-------
11Compose the three check classes into one report, and persist the plan-to-item
12join into ``roadmap_plan_links`` — the table designed for it, unused since it
13was created.
14
15Responsibilities
16----------------
17- ``reconcile`` — pure composition over injected indexes and history.
18- ``reconcile_workspace`` — the convenience wrapper that loads a real workspace.
19- ``write_plan_links`` — persist the join, after a preflight that names every
20 missing foreign-key parent instead of fabricating one.
21
22Diagnostics
23-----------
24Domain: OC_CHAT.MAIN
25Levels:
26 L2 - run lifecycle
27 L3 - per-class and per-write summaries
28 L4 - per-row preflight details
29
30Contracts
31---------
32- ``reconcile`` performs no writes of any kind.
33- ``write_plan_links`` targets a **live** registry, never the authored
34 projection: the projection is rebuilt into a temp store and swapped on every
35 ``regenerate``, so a row written there is destroyed by the next round close,
36 and ``roadmap_plan_links`` is a runtime-excluded table by design.
37- The writer never creates a foreign-key parent to make its own insert succeed.
38 A missing ``plans`` or ``roadmap_items_v3`` row is reported, not invented:
39 the check reports, the operator remediates.
40- ``roadmap_item_events`` is deliberately NOT written. The only operation that
41 appends one, ``roadmap_item.set_status``, also mutates the item's status, and
42 a reconciliation pass must never change the state it is auditing.
43
44Dependencies
45------------
46- oc_diagnostics (structured debug logging via _dbg)
47- oc_chat.registry.api (Registry facade)
48- oc_chat.registry.roadmap.operations (the plan-link operation executor)
49- oc_chat.registry.roadmap.reconcile.checks (the three check classes)
50- oc_chat.registry.roadmap.reconcile.history (the git history reader for class 1)
51- oc_chat.registry.roadmap.reconcile.inputs (the source index and plan record loaders)
52- oc_chat.registry.roadmap.reconcile.model (the finding, link, transition and report types)
53"""
54from __future__ import annotations
55
56from dataclasses import dataclass
57from pathlib import Path
58from typing import Sequence
59
60from oc_diagnostics import _dbg
61
62from oc_chat.registry.api import Registry
63from oc_chat.registry.roadmap.operations import ActorType, roadmap_plan_link_create
65 check_advances_conformance,
66 check_changelog_status_divergence,
67 check_stale_completion,
68)
70 CommitRecord,
71 HistoryError,
72 read_history,
73)
75 PlanRecord,
76 SourceIndex,
77 load_plan_records,
78 load_source_index,
79)
81 Finding,
82 PlanLink,
83 ReconcileReport,
84 StatusTransition,
85 count_by_kind,
86)
87
88_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
89
90#: Workspace-relative default locations.
91DEFAULT_SOURCE_REL = "oc_chat/oc_chat/roadmap/source"
92DEFAULT_PLANS_REL = "oc_chat/oc_chat/plans/completed"
93
94_SEVERITY_ORDER = {"violation": 0, "advisory": 1}
95
96
97class ReconcileConfigError(ValueError):
98 """The check was pointed at something it cannot read as a result.
99
100 A ``--source`` outside ``--repo`` yields a git pathspec that can never match,
101 which would otherwise surface as "0 records, nothing wrong". A pathspec that
102 cannot match is a configuration error, not a clean bill of health.
103 """
104
105
106def _resolve_scope(root: Path, source: Path) -> str:
107 """Return the repo-relative POSIX pathspec for ``source``, or refuse.
108
109 Both paths are **resolved first**. The original bug (R1) asked
110 ``Path.is_relative_to`` about an unresolved, cwd-relative source, decided it
111 lay outside the repo, and passed the raw relative string to ``git log`` as a
112 pathspec — where it silently matched nothing.
113 """
114
115 if not source.is_relative_to(root):
117 f"--source is not inside --repo, so no git pathspec can match it.\n"
118 f" --source resolved to: {source}\n"
119 f" --repo resolved to: {root}\n"
120 "Point --source at a path inside the repository, or pass the matching "
121 "--repo. Refusing to report a result for a scope that cannot be read."
122 )
123 return source.relative_to(root).as_posix()
124
125
126def _sort_key(finding: Finding) -> tuple[int, str, str]:
127 """Order findings violations-first, then by kind, then by subject."""
128
129 return (_SEVERITY_ORDER.get(finding.severity, 9), finding.kind, finding.subject)
130
131
133 records_examined: int, records_on_disk: int, history_error: str | None
134) -> str | None:
135 """Flag class 1 as not-evaluated when it examined nothing it could have.
136
137 This is the backstop for every cause not named by ``_resolve_scope``: a
138 ``--since`` window matching no commits, a corpus whose records were never
139 committed, a pathspec typo that still lands inside the repo. If there was
140 something on disk to examine and nothing was examined, the class verified
141 NOTHING — which must never render as a clean zero.
142
143 An empty changelog corpus is left evaluated: nothing to examine is a real
144 result, and crying wolf there would train readers to ignore the signal.
145 """
146
147 if history_error is not None or records_examined > 0 or records_on_disk == 0:
148 return None
149 return (
150 f"class 1 examined 0 changelog records, but {records_on_disk} exist on disk "
151 "under the resolved source tree — the commit window or source pathspec "
152 "matched nothing, so this class verified NOTHING (it is not clean)"
153 )
154
155
156def reconcile(
157 *,
158 source_index: SourceIndex,
159 plan_records: Sequence[PlanRecord] = (),
160 history: Sequence[CommitRecord] = (),
161 repo_root: Path | None = None,
162 advances_required_from: str | None = None,
163 history_error: str | None = None,
164) -> ReconcileReport:
165 """Run all three check classes over the supplied inputs.
166
167 Pure: every input is injected, so the engine is exercised without git and
168 without a registry.
169 """
170
171 func = "reconcile"
172 findings: list[Finding] = []
173 findings += check_changelog_status_divergence(source_index, history)
174 advances_findings, links, none_count = check_advances_conformance(
175 source_index, plan_records, advances_required_from=advances_required_from
176 )
177 findings += advances_findings
178 findings += check_stale_completion(source_index, repo_root=repo_root)
179 findings.sort(key=_sort_key)
180
181 transitions = tuple(
183 record_id=change.record_id,
184 from_status=change.from_status,
185 to_status=change.to_status,
186 sha=commit.sha,
187 date=commit.date,
188 )
189 for commit in history
190 for change in commit.status_changes
191 )
192
193 records_examined = sum(len(commit.added_changelog) for commit in history)
194 report = ReconcileReport(
195 findings=tuple(findings),
196 links=tuple(links),
197 transitions=transitions,
198 counts=count_by_kind(findings),
199 advances_none_count=none_count,
200 plans_checked=len(plan_records),
201 changelog_records_checked=records_examined,
202 records_checked=len(source_index.records),
203 unscannable_ids=source_index.unscannable_ids,
204 history_error=history_error,
205 changelog_scope_error=_scope_blind_spot(
206 records_examined, len(source_index.changelog), history_error
207 ),
208 )
209 _dbg(
210 "OC_CHAT.MAIN",
211 func,
212 f"{report.violation_count} violations / {report.advisory_count} advisories",
213 2,
214 )
215 return report
216
217
219 workspace_root: str | Path,
220 *,
221 source_dir: str | Path | None = None,
222 plans_dir: str | Path | None = None,
223 since: str | None = None,
224 advances_required_from: str | None = None,
225) -> ReconcileReport:
226 """Load a real workspace and reconcile it."""
227
228 func = "reconcile_workspace"
229 root = Path(workspace_root).resolve()
230 source = (Path(source_dir) if source_dir else root / DEFAULT_SOURCE_REL).resolve()
231 plans = (Path(plans_dir) if plans_dir else root / DEFAULT_PLANS_REL).resolve()
232 source_rel = _resolve_scope(root, source)
233 _dbg("OC_CHAT.MAIN", func, f"reconciling {root} (scope {source_rel})", 2)
234 # Class 1 is the only class that needs git. When history is unreadable the
235 # other two still run, but the report says so explicitly: a zero class-1
236 # count must never be mistaken for a clean one.
237 history: tuple[CommitRecord, ...] = ()
238 history_error: str | None = None
239 try:
240 history = read_history(root, source_rel=source_rel, since=since)
241 except HistoryError as error:
242 history_error = str(error)
243 _dbg("OC_CHAT.MAIN", func, "class 1 not evaluated: no git history", 2)
244 return reconcile(
245 source_index=load_source_index(source),
246 plan_records=load_plan_records(plans),
247 history=history,
248 repo_root=root,
249 advances_required_from=advances_required_from,
250 history_error=history_error,
251 )
252
253
254@dataclass(frozen=True)
256 """The outcome of persisting the plan-link join."""
257
258 ok: bool
259 links_written: int
260 missing_plans: tuple[str, ...]
261 missing_items: tuple[str, ...]
262 detail: str
263
264 def to_dict(self) -> dict[str, object]:
265 """The JSON projection of this write attempt."""
266
267 return {
268 "ok": self.ok,
269 "links_written": self.links_written,
270 "missing_plans": list(self.missing_plans),
271 "missing_items": list(self.missing_items),
272 "detail": self.detail,
273 }
274
275
276def _existing(registry: Registry, table: str, column: str, wanted: set[str]) -> set[str]:
277 """The subset of ``wanted`` that already exists in ``table``.``column``."""
278
279 if not wanted:
280 return set()
281 backend = registry.backend
282 with backend.connect() as conn: # type: ignore[attr-defined]
283 placeholders = ",".join("?" for _ in wanted)
284 rows = conn.execute(
285 # OCT-LINT: disable=no_hardcoded_secrets reason="Registry table and column names are schema constants, not secrets"
286 f"SELECT {column} FROM {table} WHERE {column} IN ({placeholders})",
287 tuple(sorted(wanted)),
288 ).fetchall()
289 return {str(row[0]) for row in rows}
290
291
293 registry: Registry,
294 links: Sequence[PlanLink],
295 *,
296 actor_slot: str,
297 actor_type: ActorType = "human_operator",
298 system_actor: bool = True,
299 dry_run: bool = False,
300) -> JoinWriteResult:
301 """Persist ``links`` into ``roadmap_plan_links`` after a fail-loud preflight.
302
303 The two foreign-key parents of this table — ``plans(plan_id)`` and
304 ``roadmap_items_v3(item_id)`` — must both be present in the target store.
305 When either is missing the write is refused whole and the missing ids are
306 named; nothing is fabricated to make the insert succeed.
307
308 ``actor_type`` defaults to ``human_operator`` because the CLI resolves the
309 store's active *operator* slot, and the backend rejects a slot whose
310 registered type disagrees with the claimed one.
311 """
312
313 func = "write_plan_links"
314 if not links:
315 return JoinWriteResult(True, 0, (), (), "no links to write")
316
317 have_plans = _existing(registry, "plans", "plan_id", {link.plan_id for link in links})
318 have_items = _existing(
319 registry, "roadmap_items_v3", "item_id", {link.item_id for link in links}
320 )
321 missing_plans = tuple(sorted({link.plan_id for link in links} - have_plans))
322 missing_items = tuple(sorted({link.item_id for link in links} - have_items))
323 if missing_plans or missing_items:
324 detail = (
325 "refused: roadmap_plan_links has two foreign-key parents and this store "
326 f"is missing {len(missing_plans)} plan row(s) and {len(missing_items)} "
327 "roadmap item row(s). The check reports; it does not invent parents. "
328 "Populate the live store first (queue the plans; `roadmap import` the "
329 "items) or resolve where the join is meant to live."
330 )
331 _dbg("OC_CHAT.MAIN", func, "preflight refused the write", 2)
332 return JoinWriteResult(False, 0, missing_plans, missing_items, detail)
333
334 if dry_run:
335 return JoinWriteResult(True, 0, (), (), f"dry run: {len(links)} link(s) would be written")
336
337 for link in links:
338 roadmap_plan_link_create(
339 registry,
340 plan_id=link.plan_id,
341 item_id=link.item_id,
342 contribution=link.contribution,
343 actor_slot=actor_slot,
344 actor_type=actor_type,
345 command="roadmap reconcile --write-links",
346 system_actor=system_actor,
347 )
348 _dbg("OC_CHAT.MAIN", func, f"wrote {len(links)} plan links", 2)
349 return JoinWriteResult(True, len(links), (), (), f"wrote {len(links)} plan link(s)")
tuple[int, str, str] _sort_key(Finding finding)
Definition engine.py:126
str|None _scope_blind_spot(int records_examined, int records_on_disk, str|None history_error)
Definition engine.py:134
str _resolve_scope(Path root, Path source)
Definition engine.py:106
JoinWriteResult write_plan_links(Registry registry, Sequence[PlanLink] links, *, str actor_slot, ActorType actor_type="human_operator", bool system_actor=True, bool dry_run=False)
Definition engine.py:300
ReconcileReport reconcile_workspace(str|Path workspace_root, *, str|Path|None source_dir=None, str|Path|None plans_dir=None, str|None since=None, str|None advances_required_from=None)
Definition engine.py:225
set[str] _existing(Registry registry, str table, str column, set[str] wanted)
Definition engine.py:276