oc_chat
Loading...
Searching...
No Matches
_common.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/render/roadmap/_common.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/roadmap/test_render_common.py"
6"""
7Shared plumbing for the living roadmap GeneratedView set (Plan 142 P1.6, §6).
8
9Purpose
10-------
11The pieces every roadmap GeneratedView shares: the **parameterized generated
12header** (build + parse), the canonical **renderer-id** set, the **glyph/label
13maps** for the §7 status/priority enums, and the **render guard** that forbids
14reading a runtime-excluded table or an EXCLUDED column from a source-only
15projection (Registry Source & Storage Spec V0.22 §3.2 / §6 / §7 — AC §8.30).
16The crash-safe atomic writer is reused from ``registry/render/_common.py``
17(the §6 shared ``atomic_write_bytes``).
18
19Responsibilities
20----------------
21- ``build_generated_header`` / ``parse_generated_header`` — the §6 four-line
22 HTML-comment header carrying ``source_hash`` / ``logical_state_hash`` /
23 ``renderer``.
24- ``status_glyph`` / ``status_label`` / ``priority_glyph`` — the §7 enum→glyph
25 mapping (single source for every renderer; AC §8.7).
26- ``assert_render_query_safe`` / ``guarded_query`` — reject a render query that
27 reads a runtime-excluded table or an EXCLUDED column (AC §8.30).
28- ``atomic_write_text`` — atomic UTF-8 (LF, no BOM) write via the shared writer.
29
30Diagnostics
31-----------
32Domain: OC_CHAT.MAIN
33Levels:
34 L2 - header lifecycle
35 L3 - parsed header fields
36 L4 - per-query guard probes
37
38Contracts
39---------
40- The header is output-only metadata; ``render --check`` parses it and treats a
41 drifted/absent header (a hand-edit) as ``E_ROADMAP_UNTRACKED_EDIT`` drift.
42- A source-only GeneratedView reads only INCLUDED columns of source-rebuildable
43 tables; the guard fails closed on any runtime-excluded table/column token.
44
45Dependencies
46------------
47- oc_diagnostics (structured debug logging via _dbg)
48- oc_chat.registry.render._common (the shared atomic writer)
49"""
50from __future__ import annotations
51
52import posixpath
53import re
54import sqlite3
55from pathlib import Path
56from typing import Optional
57
58from oc_diagnostics import _dbg
59
60from oc_chat.registry.render._common import atomic_write_bytes
61
62_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
63
64#: The four canonical artifact-renderer ids (§6).
65RENDERER_LEAN = "roadmap-lean-v4"
66RENDERER_EXTENDED = "roadmap-extended-v3"
67RENDERER_CHANGELOG = "roadmap-changelog-v1"
68RENDERER_ROOT_CHANGELOG = "changelog-product-v2"
69RENDERER_IDS = frozenset(
70 {RENDERER_LEAN, RENDERER_EXTENDED, RENDERER_CHANGELOG, RENDERER_ROOT_CHANGELOG}
71)
72
73#: The current rendered living-set filenames (un-versioned, fixed) — the single
74#: source of truth for both ``ARTIFACTS`` and the generated footer's companion
75#: links (so the footer never names a stale versioned file like ROADMAP-v3.38.md).
76LIVING_SET_FILENAMES: dict[str, str] = {
77 "roadmap": "ROADMAP.md",
78 "roadmap_extended": "ROADMAP-EXTENDED.md",
79 "roadmap_changelog": "ROADMAP-CHANGELOG.md",
80 "root_changelog": "CHANGELOG.md",
81}
82
83#: Human label for each companion link in the generated footer.
84_FOOTER_LABELS: dict[str, str] = {
85 "roadmap": "Lean roadmap",
86 "roadmap_extended": "Extended record",
87 "roadmap_changelog": "Roadmap changelog",
88 "root_changelog": "Product changelog",
89}
90
91#: Each living view's write-dir relative to the repo root — ``artifact_paths`` writes
92#: the three roadmap views to ``oc_chat/roadmap/`` and the root changelog to the repo
93#: root, so the footer's companion links must be artifact-relative, not flat.
94_ARTIFACT_DIRS: dict[str, str] = {
95 "roadmap": "oc_chat/roadmap",
96 "roadmap_extended": "oc_chat/roadmap",
97 "roadmap_changelog": "oc_chat/roadmap",
98 "root_changelog": "",
99}
100
101
102def _companion_link(self_key: str, target_key: str) -> str:
103 """The artifact-relative link from ``self_key``'s file to ``target_key``'s file:
104 a same-dir companion stays flat; a roadmap view → the root changelog becomes
105 ``../../CHANGELOG.md``; the root changelog → a roadmap view becomes
106 ``oc_chat/roadmap/ROADMAP*.md`` (purely lexical via ``posixpath``)."""
107 target = posixpath.join(_ARTIFACT_DIRS[target_key], LIVING_SET_FILENAMES[target_key])
108 return posixpath.relpath(target, _ARTIFACT_DIRS[self_key] or ".")
109
110
111def generated_footer(self_key: str) -> list[str]:
112 """The generated footer for a living view: a do-not-hand-edit provenance note
113 + **artifact-relative** companion links to the *current* rendered set (never a
114 stale versioned filename, and resolvable from this artifact's own write-dir).
115 ``self_key`` is the artifact being rendered (excluded from its own list)."""
116 companions = " · ".join(
117 f"[{_FOOTER_LABELS[key]}]({_companion_link(self_key, key)})"
118 for key in LIVING_SET_FILENAMES
119 if key != self_key
120 )
121 return [
122 "---",
123 "",
124 "_Generated from the registry source (`oc_chat/roadmap/source`) — do not "
125 "hand-edit; edit the source records and run `oct agent roadmap render` to "
126 f"regenerate. Companion views: {companions}._",
127 ]
128
129#: Tables runtime-excluded from the §3.2 logical hash — a source-only
130#: GeneratedView must never read these (AC §8.30). A future view that renders
131#: plan-link traceability must carry a runtime-checkpoint-hash header instead.
132RUNTIME_EXCLUDED_TABLES = frozenset(
133 {
134 "roadmap_plan_links",
135 "roadmap_item_events",
136 "agents",
137 "rounds",
138 "plans",
139 "operation_log",
140 "auth_events",
141 }
142)
143
144#: Columns EXCLUDED from the §3.2 logical hash (volatile timestamps, runtime
145#: provenance, the v2-deferred prose_path) — a renderer must not emit them, since
146#: they differ across rebuilds and would break the byte-stable-render guarantee.
147EXCLUDED_COLUMNS = frozenset(
148 {
149 "created_at",
150 "updated_at",
151 "last_updated_at",
152 "closed_round_no",
153 "created_by_slot",
154 "prose_path",
155 }
156)
157
158#: §7 status token → glyph (source == store tokens are canonical).
159_STATUS_GLYPH = {
160 "todo": "⬜",
161 "in_progress": "🔄",
162 "done": "✅",
163 "blocked": "🔒",
164 "deferred": "↩", # overridden to 🚫 when there is no successor (see status_glyph)
165 "superseded": "↩",
166}
167
168#: §7 status token → render label.
169_STATUS_LABEL = {
170 "todo": "Not started",
171 "in_progress": "In progress",
172 "done": "Complete",
173 "blocked": "Blocked",
174 "deferred": "Deferred",
175 "superseded": "Superseded",
176}
177
178#: §7 priority token → glyph (4-level; store token is ``lower`` not ``low``).
179_PRIORITY_GLYPH = {
180 "critical": "🔴",
181 "high": "🟠",
182 "medium": "🟡",
183 "lower": "🟢",
184}
185
186#: Terminal item statuses (Codex V7 F3) — all three are terminal for the
187#: ``current``-pointer derivation (AC §8.35).
188TERMINAL_STATUSES = frozenset({"done", "deferred", "superseded"})
189
190_HEADER_OPEN = "<!-- GENERATED by oct agent render; do not hand-edit."
191_HEADER_CLOSE = "-->"
192_HEADER_RE = re.compile(
193 # OCT-LINT: disable=no_hardcoded_secrets reason="Table/column names + header tokens are schema constants, not secrets"
194 r"<!-- GENERATED by oct agent render; do not hand-edit\.\s*"
195 r"source_hash=(?P<source_hash>\S+)\s+"
196 r"logical_state_hash=(?P<logical_state_hash>\S+)\s+"
197 r"renderer=(?P<renderer>\S+)\s*-->",
198)
199_IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
200
201
202class RenderGuardError(RuntimeError):
203 """A render query touched a runtime-excluded table or EXCLUDED column."""
204
205
206def status_glyph(status: str, *, has_successor: bool = False) -> str:
207 """Return the §7 glyph for ``status``.
208
209 ``deferred`` with a named successor renders ``↩`` (redirect); ``deferred``
210 with no successor renders ``🚫`` (no successor). ``superseded`` always has a
211 ``superseded_by`` and renders ``↩``.
212 """
213 if status == "deferred":
214 return "↩" if has_successor else "🚫"
215 return _STATUS_GLYPH.get(status, "⬜")
216
217
218def status_label(status: str, *, successor: Optional[str] = None,
219 superseded_by: Optional[str] = None) -> str:
220 """Return the §7 render label (with the redirect target when present)."""
221 if status == "deferred":
222 return f"Deferred → {successor}" if successor else "Deferred (no successor)"
223 if status == "superseded" and superseded_by:
224 return f"Superseded by {superseded_by}"
225 return _STATUS_LABEL.get(status, status)
226
227
228def priority_glyph(priority: Optional[str]) -> str:
229 """Return the §7 priority glyph; ``—`` for null (not applicable)."""
230 if priority is None:
231 return "—"
232 return _PRIORITY_GLYPH.get(priority, "—")
233
234
236 *, source_hash: str, logical_state_hash: str, renderer: str
237) -> str:
238 """Return the §6 four-line generated header (HTML comment)."""
239 if renderer not in RENDERER_IDS:
240 raise ValueError(f"unknown renderer id {renderer!r}")
241 return (
242 f"{_HEADER_OPEN}\n"
243 f"source_hash={source_hash}\n"
244 f"logical_state_hash={logical_state_hash}\n"
245 f"renderer={renderer} {_HEADER_CLOSE}"
246 )
247
248
249def parse_generated_header(text: str) -> Optional[dict[str, str]]:
250 """Parse the §6 header from rendered ``text``; ``None`` if absent/malformed.
251
252 A ``None`` result is itself drift (a hand-edit that dropped the header).
253 """
254 func = "parse_generated_header"
255 match = _HEADER_RE.search(text)
256 if match is None:
257 return None
258 fields = match.groupdict()
259 _dbg("OC_CHAT.MAIN", func, f"renderer={fields['renderer']}", 3)
260 return fields
261
262
263def assert_render_query_safe(sql: str) -> None:
264 """Raise ``RenderGuardError`` if ``sql`` reads a runtime-excluded table or
265 EXCLUDED column (AC §8.30 — the source-only render guard / column-scope guard).
266 """
267 tokens = {token.lower() for token in _IDENTIFIER_RE.findall(sql)}
268 bad_tables = tokens & RUNTIME_EXCLUDED_TABLES
269 if bad_tables:
270 raise RenderGuardError(
271 "source-only render query reads runtime-excluded table(s) "
272 f"{sorted(bad_tables)}; a runtime-checkpoint-hash header is required"
273 )
274 bad_columns = tokens & EXCLUDED_COLUMNS
275 if bad_columns:
276 raise RenderGuardError(
277 "render query references §3.2 EXCLUDED column(s) "
278 f"{sorted(bad_columns)}; these differ across rebuilds"
279 )
280
281
283 conn: sqlite3.Connection, sql: str, params: tuple = ()
284) -> sqlite3.Cursor:
285 """Run a render SELECT through ``assert_render_query_safe`` first (§8.30)."""
287 return conn.execute(sql, params)
288
289
290def atomic_write_text(path: Path, text: str) -> Path:
291 """Atomic-write ``text`` as UTF-8 (LF, no BOM) via the shared §6 writer."""
292 path = Path(path)
293 atomic_write_bytes(path, text.encode("utf-8"))
294 return path
295
296
297__all__ = [
298 "RENDERER_LEAN",
299 "RENDERER_EXTENDED",
300 "RENDERER_CHANGELOG",
301 "RENDERER_ROOT_CHANGELOG",
302 "RENDERER_IDS",
303 "LIVING_SET_FILENAMES",
304 "generated_footer",
305 "RUNTIME_EXCLUDED_TABLES",
306 "EXCLUDED_COLUMNS",
307 "TERMINAL_STATUSES",
308 "RenderGuardError",
309 "status_glyph",
310 "status_label",
311 "priority_glyph",
312 "build_generated_header",
313 "parse_generated_header",
314 "assert_render_query_safe",
315 "guarded_query",
316 "atomic_write_text",
317]
str _companion_link(str self_key, str target_key)
Definition _common.py:102
str priority_glyph(Optional[str] priority)
Definition _common.py:228
Path atomic_write_text(Path path, str text)
Definition _common.py:290
Optional[dict[str, str]] parse_generated_header(str text)
Definition _common.py:249
list[str] generated_footer(str self_key)
Definition _common.py:111
str status_label(str status, *, Optional[str] successor=None, Optional[str] superseded_by=None)
Definition _common.py:219
str status_glyph(str status, *, bool has_successor=False)
Definition _common.py:206
sqlite3.Cursor guarded_query(sqlite3.Connection conn, str sql, tuple params=())
Definition _common.py:284
str build_generated_header(*, str source_hash, str logical_state_hash, str renderer)
Definition _common.py:237