oc_chat
Loading...
Searching...
No Matches
seed_142_prose_backfill.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# tools/seed_142_prose_backfill.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/seed/test_prose_backfill.py"
6"""
7Per-phase prose backfill (Plan 142 prose-preservation Phase 2, GATE2 step 1).
8
9Purpose
10-------
11P1.7 seeded the roadmap ITEM SET into the registry but dropped the per-phase
12PROSE — every ``# PHASE Mx`` section in the legacy
13``oc_chat/roadmap/ROADMAP-v3.38.md`` (phase metadata + the full ``### Deliverables``
14table with each item's *rich description + status provenance* + ``### Acceptance``)
15was lost: the seeded ``phases/<code>.md`` records carry an EMPTY body and a few
16``sub_phases`` descriptions degraded to bare-id stubs (e.g. ``M0.2``).
17
18This tool restores them, byte-faithfully, mirroring the Phase-1 pattern (verbatim
19section -> record body): for each ``# PHASE`` section it writes the verbatim
20section body into the matching ``phases/<code>.md`` record BODY (which the P1.4
21build already carries into ``roadmap_items_v3.prose`` via ``prose=body or None``,
22so the EXTENDED render can emit it). It also replaces each ``description == id``
23sub-phase stub with the real Item-column text from that phase's deliverables
24table (single-lined, capped to the ``Line240`` contract).
25
26No contract change: the Phase model is unchanged; only record BODIES (and the
27handful of stub descriptions) are rewritten. Idempotent — re-running yields
28identical bytes.
29
30Responsibilities
31----------------
32- ``parse_phase_sections`` (split the legacy file on ``# PHASE`` headers),
33 ``deliverable_items`` (id -> Item-column text), ``backfill`` (re-emit records),
34 ``coverage`` (the per-phase / per-item map for the audit artifact).
35
36Diagnostics
37-----------
38Domain: OC_CHAT.MAIN
39Levels:
40 L2 - backfill lifecycle
41 L3 - per-phase section extraction
42 L4 - per-record emission
43
44Contracts
45---------
46- Deterministic; writes only ``source/phases/<code>.md`` records; never touches
47 the live legacy roadmap; every rewritten record re-validates + re-canonicalises.
48
49Dependencies
50------------
51- oc_diagnostics (structured debug logging via _dbg)
52- oc_chat.registry.regenerate (imported for the contracts bootstrap it runs)
53- oc_chat.registry.source.canonical (load_record and format_record_text - reads each phase record and re-emits it with the restored body)
54- oc_chat.registry.source.scanner (scan_source_tree - finds the phase records)
55"""
56from __future__ import annotations
57
58import re
59import sys
60from dataclasses import dataclass, field
61from pathlib import Path
62
63# Force the WORKTREE-local oc_chat ahead of the editable main-repo install (a
64# tools/ script's sys.path[0] is tools/, not the worktree root).
65sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
66
67from oc_diagnostics import _dbg
68
69import oc_chat.registry.regenerate # noqa: F401 (contracts bootstrap)
70
71from oc_chat.registry.source.canonical import format_record_text, load_record
72from oc_chat.registry.source.scanner import scan_source_tree
73
74_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
75
76#: A ``# PHASE <code> — <title>`` header line (em-dash separated, col 0).
77_PHASE_HEADER_RE = re.compile(r"^# PHASE (\S+) — (.+)$", re.MULTILINE)
78#: The next top-level boundary after a phase section (next phase / next ``## ``).
79_BOUNDARY_RE = re.compile(r"^(# PHASE |## )", re.MULTILINE)
80#: A trailing horizontal-rule separator emitted between phase sections.
81_TRAILING_RULE_RE = re.compile(r"\n*-{3,}\s*$")
82#: A deliverables-table data row (a leading-id cell, not the header/separator).
83_DELIV_ROW_RE = re.compile(r"^\|\s*(?P<id>[A-Za-z][\w.\-]*)\s*\|\s*(?P<item>.+?)\s*\|.*\|\s*$")
84
85_LINE240_MAX = 240
86
87
88@dataclass
90 phases_written: int = 0
91 bodies: dict = field(default_factory=dict) # code -> verbatim body
92 stubs_fixed: list = field(default_factory=list) # (code, sub_id, new_desc)
93 skipped: list = field(default_factory=list) # codes with no legacy section
94
95
96def parse_phase_sections(roadmap_text: str) -> dict[str, str]:
97 """Return ``{phase_code: verbatim_section_body}`` for every ``# PHASE`` section.
98
99 The body is everything after the header line up to the next ``# PHASE`` /
100 top-level ``## `` heading, with the inter-section ``---`` rule stripped.
101 """
102 text = roadmap_text.replace("\r\n", "\n")
103 out: dict[str, str] = {}
104 for match in _PHASE_HEADER_RE.finditer(text):
105 code = match.group(1)
106 nxt = _BOUNDARY_RE.search(text, match.end())
107 end = nxt.start() if nxt else len(text)
108 body = text[match.end():end].strip("\n")
109 body = _TRAILING_RULE_RE.sub("", body).strip("\n")
110 out[code] = body
111 return out
112
113
114def deliverable_items(section_body: str) -> dict[str, str]:
115 """Map ``sub_id -> Item-column text`` from a phase section's deliverables table."""
116 items: dict[str, str] = {}
117 for line in section_body.split("\n"):
118 stripped = line.strip()
119 if not stripped.startswith("|") or set(stripped) <= set("|-: "):
120 continue # not a row, or the header separator
121 match = _DELIV_ROW_RE.match(stripped)
122 if match is None:
123 continue
124 item_id = match.group("id").strip()
125 item_text = " ".join(match.group("item").split()).strip()
126 if item_id.lower() in ("#", "item") or not item_text:
127 continue # the table header row
128 items[item_id] = item_text
129 return items
130
131
132def _capped(text: str) -> str:
133 """Single-line + cap to the ``Line240`` contract (whole-word trim)."""
134 text = " ".join(text.split())
135 if len(text) <= _LINE240_MAX:
136 return text
137 clipped = text[: _LINE240_MAX - 1].rstrip()
138 return (clipped[: clipped.rfind(" ")] if " " in clipped else clipped).rstrip() + "…"
139
140
141def backfill(roadmap_md: Path, source_dir: Path) -> BackfillReport:
142 """Backfill every ``phases/<code>.md`` body from the legacy ``# PHASE`` sections."""
143 func = "backfill"
144 sections = parse_phase_sections(Path(roadmap_md).read_text(encoding="utf-8"))
145 report = BackfillReport()
146 for scanned in scan_source_tree(Path(source_dir)):
147 if scanned.kind != "phase":
148 continue
149 code = scanned.stem
150 body = sections.get(code)
151 if body is None:
152 report.skipped.append(code)
153 continue
154 model, _old_body = load_record(scanned)
155 items = deliverable_items(body)
156 new_subs = []
157 for sub in model.sub_phases:
158 if sub.description == sub.id and sub.id in items: # frozen models: copy-update
159 new_desc = _capped(items[sub.id])
160 new_subs.append(sub.model_copy(update={"description": new_desc}))
161 report.stubs_fixed.append((code, sub.id, new_desc))
162 else:
163 new_subs.append(sub)
164 model = model.model_copy(update={"sub_phases": new_subs})
165 scanned.path.write_text(
166 format_record_text(model, body=body), encoding="utf-8", newline=""
167 )
168 report.phases_written += 1
169 report.bodies[code] = body
170 _dbg("OC_CHAT.MAIN", func, f"backfilled {code} ({len(body)} chars)", 3)
171 _dbg("OC_CHAT.MAIN", func, f"wrote {report.phases_written} phase bodies", 2)
172 return report
173
174
175def _main() -> int:
176 func = "_main"
177 repo = Path(__file__).resolve().parents[1]
178 roadmap = repo / "oc_chat" / "roadmap" / "ROADMAP-v3.38.md"
179 source = repo / "oc_chat" / "roadmap" / "source"
180 report = backfill(roadmap, source)
181 _dbg("OC_CHAT.MAIN", func, f"phases={report.phases_written} stubs={len(report.stubs_fixed)} "
182 f"skipped={report.skipped}", 2)
183 print(f"phases_written={report.phases_written}")
184 print(f"stubs_fixed={report.stubs_fixed}")
185 print(f"skipped (no legacy # PHASE section)={report.skipped}")
186 return 0
187
188
189if __name__ == "__main__":
190 raise SystemExit(_main())
dict[str, str] parse_phase_sections(str roadmap_text)
BackfillReport backfill(Path roadmap_md, Path source_dir)
dict[str, str] deliverable_items(str section_body)