oc_chat
Loading...
Searching...
No Matches
seed_142_ingest.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# tools/seed_142_ingest.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/seed/test_seed_142.py"
6"""
7The v3.38 binary -> AuthoredSource seed migration (Plan 142 P1.7, §9 / §5.8).
8
9Purpose
10-------
11Ingest the live ``ROADMAP-v3.38*`` four-file set into a worktree-local store
12(the oracle), then EMIT the per-record ``oc_chat/roadmap/source/**`` tree: 14
13top-level phases as ``Phase`` records (sub-phases nested as ``sub_phases`` or
14promoted to ``phases/<code>/<sub_id>.md`` per §5.6), non-phase items as
15``RoadmapItem`` records, plus the era/tracks/version — backfilling a non-empty
16``purpose`` for every phase + item (AC §8.22) and sanitising legacy ids/dates.
17
18Responsibilities
19----------------
20- ``ingest_oracle(roadmap_dir, db)`` — build the oracle store.
21- ``emit_source(db, out_dir, roadmap_md)`` — write the canonical source tree;
22 return a migration report (counts, promotions, id/date sanitisations).
23
24Diagnostics
25-----------
26Domain: OC_CHAT.MAIN
27Levels:
28 L2 - migration lifecycle
29 L3 - per-entity emission
30 L4 - per-record sanitisation
31
32Contracts
33---------
34- Deterministic: identical inputs -> identical source bytes.
35- Never writes outside ``out_dir``; the oracle DB is worktree-local + temporary.
36- Every emitted record validates against the P1.1 / P1.4 models.
37
38Dependencies
39------------
40- oc_diagnostics (structured debug logging via _dbg)
41- oc_chat.registry.regenerate (imported for the contracts bootstrap it runs)
42- option_c_registry_contracts (the record models, SCHEMA_VERSION, ID_RE and TERMINAL_STATUSES for the emitted source tree)
43- oc_chat.registry (Registry - the oracle registry over the SQLite backend)
44- oc_chat.registry.backends.sqlite (SQLiteBackend and RegistrySeed - the worktree-local oracle store)
45- oc_chat.registry.regenerate.promoted (PromotedItem - the record for a sub-phase promoted to its own file)
46- oc_chat.registry.roadmap.ingest (ingest_corpus - ingests the v3.38 four-file set into the oracle)
47- oc_chat.registry.source.canonical (format_record_text - writes each emitted record canonically)
48"""
49from __future__ import annotations
50
51import hashlib
52import re
53import sqlite3
54import uuid
55from dataclasses import dataclass, field
56from pathlib import Path
57
58from oc_diagnostics import _dbg
59
60import oc_chat.registry.regenerate # noqa: F401 (runs the contracts bootstrap)
61import option_c_registry_contracts as rc
62
63from oc_chat.registry import Registry
64from oc_chat.registry.backends.sqlite import RegistrySeed, SQLiteBackend
65from oc_chat.registry.regenerate.promoted import PromotedItem
66from oc_chat.registry.roadmap.ingest import ingest_corpus
67from oc_chat.registry.source.canonical import format_record_text
68
69_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
70
71_ISO_DATE_RE = re.compile(r"(\d{4}-\d{2}-\d{2})")
72_PHASE_HEADER_RE = re.compile(
73 # OCT-LINT: disable=no_hardcoded_secrets reason="Seed migration constants + placeholder slot are not secrets"
74 r"^#\s+PHASE\s+(?P<code>M\S+)\s*[—-]\s*(?P<title>.+?)\s*$", re.MULTILINE
75)
76_ID_OK_RE = re.compile(rc.ID_RE)
77_PRIORITIES = {"critical", "high", "medium", "lower"}
78_DEFAULT_DATE = "2026-06-02"
79
80
81@dataclass
83 counts: dict = field(default_factory=dict)
84 promotions: list = field(default_factory=list)
85 id_sanitisations: list = field(default_factory=list)
86 purpose_backfilled: int = 0
87 dropped_parent_child_deps: list = field(default_factory=list)
88 reconciled_statuses: list = field(default_factory=list)
89
90
91def _one_line(text: object, max_len: int = 240) -> str:
92 collapsed = " ".join(str(text or "").split())
93 return collapsed[:max_len].strip()
94
95
96def _iso_date(value: object) -> str:
97 match = _ISO_DATE_RE.search(str(value or ""))
98 return match.group(1) if match else _DEFAULT_DATE
99
100
101def _sanitize_id(raw: str, report: MigrationReport) -> str:
102 """Map a legacy id to the ASCII id shape (en/em-dash -> '-', illegal -> '_')."""
103 candidate = raw.replace("–", "-").replace("—", "-")
104 candidate = re.sub(r"[^A-Za-z0-9._-]", "_", candidate).strip("._-")
105 if not candidate:
106 candidate = "x"
107 if candidate != raw:
108 report.id_sanitisations.append({"from": raw, "to": candidate})
109 return candidate
110
111
112def _derive_purpose(title: object, prose: object, report: MigrationReport) -> str:
113 """Non-empty single-line ≤240 purpose: prose first sentence -> title (AC §8.22)."""
114 text = ""
115 if prose and str(prose).strip():
116 first = str(prose).strip().splitlines()[0]
117 text = re.split(r"(?<=[.!?])\s", first)[0]
118 if not text.strip():
119 text = str(title or "")
120 purpose = _one_line(text, 240)
121 if not purpose:
122 purpose = "Seed item (purpose pending operator backfill)"
123 report.purpose_backfilled += 1
124 return purpose
125
126
127def phase_title_map(roadmap_md: Path) -> dict[str, str]:
128 text = roadmap_md.read_text(encoding="utf-8")
129 return {
130 match.group("code"): _one_line(match.group("title"))
131 for match in _PHASE_HEADER_RE.finditer(text)
132 }
133
134
135def ingest_oracle(roadmap_dir: Path, db_path: Path) -> Registry:
136 backend = SQLiteBackend(db_path)
137 backend.initialize(
139 workspace_id=str(uuid.uuid4()), registry_id=str(uuid.uuid4()),
140 workspace_root=str(db_path.parent), git_common_dir=str(db_path.parent / ".git"),
141 registry_path=str(db_path), checkpoint_path=str(db_path.parent / ".option_c" / "cp.json"),
142 operator_slot="operator-seed", hmac_key_id=str(uuid.uuid4()),
143 secret_fingerprint=hashlib.sha256(b"seed").hexdigest(), app_version="seed",
144 )
145 )
146 registry = Registry(backend, clock=backend.clock)
147 result = ingest_corpus(registry, roadmap_dir, actor_slot="operator-seed")
148 if not result.ok:
149 raise RuntimeError(f"v3.38 ingest failed: {result.errors}")
150 return registry
151
152
153def _deps_and_tracks(conn: sqlite3.Connection, report: MigrationReport) -> tuple[dict, dict]:
154 parent_of = {
155 row["item_id"]: row["parent_item_id"]
156 for row in conn.execute("SELECT item_id, parent_item_id FROM roadmap_items_v3")
157 }
158 deps: dict[str, list] = {}
159 for row in conn.execute("SELECT from_item_id, to_item_id, kind FROM roadmap_item_dependencies"):
160 pred, dep = row["from_item_id"], row["to_item_id"]
161 # Drop parent-child depends_on edges: part_of is carried by parent_item_id
162 # nesting (§3.2), never depends_on — and these form the v3.38 M11<->M11.8 cycle.
163 if parent_of.get(dep) == pred or parent_of.get(pred) == dep:
164 report.dropped_parent_child_deps.append({"from": pred, "to": dep, "kind": row["kind"]})
165 continue
166 deps.setdefault(dep, []).append((pred, row["kind"]))
167 tracks: dict[str, list] = {}
168 sql = ("SELECT it.item_id, t.track_code FROM roadmap_item_tracks it "
169 "JOIN roadmap_tracks t ON t.track_id = it.track_id ORDER BY t.track_code")
170 for row in conn.execute(sql):
171 tracks.setdefault(row["item_id"], []).append(row["track_code"])
172 return deps, tracks
173
174
175def _depends_on(item_id: str, deps: dict, sanitized: dict) -> list:
176 out: list = []
177 for predecessor, kind in deps.get(item_id, []):
178 pid = sanitized.get(predecessor, predecessor)
179 out.append(pid if kind == "blocks" else rc.DependencyEdge(id=pid, kind=kind))
180 return out
181
182
183def _needs_promotion(row: sqlite3.Row, deps: dict) -> bool:
184 """§9 3c: promote only on item-specific data the nested form cannot hold —
185 description >240, a (non-inherited) priority, or dependencies. The inherited
186 parent track + the global ``v3.38`` version stamp are NOT triggers (they are
187 classified dedup drops, not per-item data)."""
188 return bool(len((row["title"] or "")) > 240 or row["priority"] is not None or deps.get(row["item_id"]))
189
190
191def _write(out_dir: Path, rel: str, model: rc.RegistryModel, body: str = "") -> None:
192 path = out_dir / rel
193 path.parent.mkdir(parents=True, exist_ok=True)
194 path.write_text(format_record_text(model, body=body), encoding="utf-8", newline="")
195
196
197def emit_source(db_path: Path, out_dir: Path, roadmap_md: Path) -> MigrationReport:
198 """Emit the canonical source tree from the oracle store; return the report."""
199 func = "emit_source"
200 report = MigrationReport()
201 titles = phase_title_map(roadmap_md)
202 conn = sqlite3.connect(str(db_path))
203 conn.row_factory = sqlite3.Row
204 deps, tracks = _deps_and_tracks(conn, report)
205 # Sanitise every item id up front so cross-references resolve consistently.
206 sanitized = {
207 row["item_id"]: _sanitize_id(row["item_id"], report)
208 for row in conn.execute("SELECT item_id FROM roadmap_items_v3")
209 }
210
211 version_row = conn.execute(
212 # OCT-LINT: disable=no_hardcoded_secrets reason="Seed migration constants + placeholder slot are not secrets"
213 "SELECT version_id FROM roadmap_versions ORDER BY status='current' DESC, version_id DESC LIMIT 1"
214 ).fetchone()
215 roadmap_version = version_row["version_id"] if version_row else "v3.38"
216 _write(out_dir, "manifest.md", rc.Manifest(
217 schema_version=rc.SCHEMA_VERSION, roadmap_version=roadmap_version))
218
219 era_row = conn.execute("SELECT * FROM roadmap_eras LIMIT 1").fetchone()
220 _write(out_dir, f"eras/{era_row['era_id']}.md", rc.Era(
221 schema_version=rc.SCHEMA_VERSION, id=era_row["era_id"],
222 era_label=_one_line(era_row["era_label"]) or "roadmap era",
223 started_at=_iso_date(era_row["started_at"]), order=era_row["sort_order"]))
224
225 for trow in conn.execute("SELECT * FROM roadmap_tracks ORDER BY sort_order"):
226 _write(out_dir, f"tracks/{trow['track_code']}.md", rc.Track(
227 schema_version=rc.SCHEMA_VERSION, track_code=trow["track_code"],
228 label=_one_line(trow["label"]) or trow["track_code"],
229 description=_one_line(trow["description"]) or None,
230 order=trow["sort_order"], active=bool(trow["active"])))
231
232 for vrow in conn.execute("SELECT * FROM roadmap_versions ORDER BY version_id"):
233 _write(out_dir, f"changelog/roadmap/{vrow['version_id']}.md", rc.Version(
234 schema_version=rc.SCHEMA_VERSION, version=vrow["version_id"],
235 date=_iso_date(vrow["released_at"]), era=vrow["era_id"], kind="roadmap",
236 round=vrow["source_round_no"], notes=_one_line(vrow["notes"], 200) or None,
237 entries=[rc.ChangelogEntry(scope="roadmap", text=f"{vrow['version_id']} seed import")]))
238
239 _emit_phases(conn, out_dir, titles, deps, tracks, sanitized, report)
240 _emit_items(conn, out_dir, deps, tracks, sanitized, report)
241
242 counts = {t: conn.execute(f"SELECT COUNT(*) FROM {t}").fetchone()[0]
243 for t in ("roadmap_eras", "roadmap_tracks", "roadmap_versions", "roadmap_phases", "roadmap_items_v3")}
244 report.counts = counts
245 conn.close()
246 _dbg("OC_CHAT.MAIN", func, f"emitted source (promotions={len(report.promotions)})", 2)
247 return report
248
249
250def _sub_rows(conn: sqlite3.Connection, phase_code: str) -> list:
251 return conn.execute(
252 # OCT-LINT: disable=no_hardcoded_secrets reason="Seed migration constants + placeholder slot are not secrets"
253 "SELECT * FROM roadmap_items_v3 WHERE parent_item_id = ? ORDER BY sort_order, item_id",
254 (phase_code,),
255 ).fetchall()
256
257
258def _emit_phases(conn, out_dir, titles, deps, tracks, sanitized, report) -> None:
259 phase_rows = conn.execute(
260 # OCT-LINT: disable=no_hardcoded_secrets reason="Seed migration constants + placeholder slot are not secrets"
261 "SELECT p.*, i.status FROM roadmap_phases p JOIN roadmap_items_v3 i ON i.item_id = p.phase_code "
262 "WHERE i.parent_item_id IS NULL ORDER BY p.sort_order"
263 ).fetchall()
264 for prow in phase_rows:
265 code = prow["phase_code"]
266 parent_done = prow["status"] == "done"
267 sub_models: list = []
268 for srow in _sub_rows(conn, code):
269 sid = sanitized.get(srow["item_id"], srow["item_id"])
270 status = _reconcile_child_status(sid, srow["status"], parent_done, report)
271 if _needs_promotion(srow, deps):
272 report.promotions.append({"id": sid, "reason": _promotion_reason(srow, deps)})
273 _write(out_dir, f"phases/{code}/{sid}.md", PromotedItem(
274 schema_version=rc.SCHEMA_VERSION, id=sid, title=_one_line(srow["title"], 240) or sid,
275 purpose=_derive_purpose(srow["title"], srow["prose"], report), status=status,
276 priority=_priority(srow["priority"]), order=srow["sort_order"],
277 depends_on=_depends_on(srow["item_id"], deps, sanitized)),
278 body=_one_line(srow["prose"], 100000))
279 else:
280 sub_models.append(rc.SubPhase(id=sid, status=status,
281 description=_one_line(srow["title"], 240) or sid))
282 priority = _priority(prow["priority_label"])
283 if priority is None:
284 priority = "lower" # top-level phases all carry one; defensive
285 title = titles.get(code) or _one_line(prow["label"]) or code
286 _write(out_dir, f"phases/{code}.md", rc.Phase(
287 schema_version=rc.SCHEMA_VERSION, id=code, era=conn.execute("SELECT era_id FROM roadmap_eras LIMIT 1").fetchone()[0],
288 title=title, purpose=_one_line(title, 240), priority=priority, status=prow["status"],
289 depends_on=_depends_on(code, deps, sanitized), track_codes=tracks.get(code, []),
290 order=prow["sort_order"],
291 sub_phases=sub_models))
292
293
294def _emit_items(conn, out_dir, deps, tracks, sanitized, report) -> None:
295 rows = conn.execute(
296 # OCT-LINT: disable=no_hardcoded_secrets reason="Seed migration constants + placeholder slot are not secrets"
297 "SELECT * FROM roadmap_items_v3 WHERE track_kind != 'm_phase' ORDER BY track_kind, sort_order, item_id"
298 ).fetchall()
299 for row in rows:
300 iid = sanitized.get(row["item_id"], row["item_id"])
301 _write(out_dir, f"items/{row['track_kind']}/{iid}.md", rc.RoadmapItem(
302 schema_version=rc.SCHEMA_VERSION, id=iid, track_kind=row["track_kind"],
303 title=_one_line(row["title"], 240) or iid,
304 purpose=_derive_purpose(row["title"], row["prose"], report),
305 status=row["status"], priority=_priority(row["priority"]),
306 depends_on=_depends_on(row["item_id"], deps, sanitized),
307 track_codes=tracks.get(row["item_id"], []), order=row["sort_order"]),
308 body=_one_line(row["prose"], 100000))
309
310
311def _reconcile_child_status(item_id: str, status: str, parent_done: bool, report: MigrationReport) -> str:
312 """A done parent's non-terminal child is reconciled to ``deferred`` (it was
313 deferred when the milestone shipped) — preserves the done milestone signal
314 while satisfying the done-parent⇒children-terminal invariant (AC §8.50).
315 Flagged for operator review (§9 3b)."""
316 if parent_done and status not in rc.TERMINAL_STATUSES:
317 report.reconciled_statuses.append({"id": item_id, "from": status, "to": "deferred"})
318 return "deferred"
319 return status
320
321
322def _priority(value: object) -> object:
323 return value if value in _PRIORITIES else None
324
325
326def _promotion_reason(row: sqlite3.Row, deps: dict) -> str:
327 reasons = []
328 if len((row["title"] or "")) > 240:
329 reasons.append("title>240")
330 if row["priority"] is not None:
331 reasons.append("priority")
332 if deps.get(row["item_id"]):
333 reasons.append("dependencies")
334 return ",".join(reasons)
None _emit_phases(conn, out_dir, titles, deps, tracks, sanitized, report)
Registry ingest_oracle(Path roadmap_dir, Path db_path)
None _emit_items(conn, out_dir, deps, tracks, sanitized, report)
dict[str, str] phase_title_map(Path roadmap_md)
str _promotion_reason(sqlite3.Row row, dict deps)
str _iso_date(object value)
MigrationReport emit_source(Path db_path, Path out_dir, Path roadmap_md)
tuple[dict, dict] _deps_and_tracks(sqlite3.Connection conn, MigrationReport report)
object _priority(object value)
str _reconcile_child_status(str item_id, str status, bool parent_done, MigrationReport report)
list _sub_rows(sqlite3.Connection conn, str phase_code)
bool _needs_promotion(sqlite3.Row row, dict deps)
str _derive_purpose(object title, object prose, MigrationReport report)
str _one_line(object text, int max_len=240)
str _sanitize_id(str raw, MigrationReport report)
None _write(Path out_dir, str rel, rc.RegistryModel model, str body="")
list _depends_on(str item_id, dict deps, dict sanitized)