oc_chat
Loading...
Searching...
No Matches
seed_142_product_changelog.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# tools/seed_142_product_changelog.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/seed/test_product_changelog.py"
6"""
7Seed the live root product CHANGELOG.md into kind=product source records (Plan 142).
8
9Purpose
10-------
11The live repo-root product ``CHANGELOG.md`` is not registry-managed (0 ``kind=product``
12records → a 296-byte stub render). Tile it **content-faithfully** into
13``source/changelog/product/<id>.md`` records: one ``kind=product`` record per
14separator-delimited segment, body = the verbatim slice (canonicalised at
15``format_record_text`` time), so the verbatim-seq render reproduces
16``canonicalize(backup)`` byte-for-byte. The raw original survives as the Phase-0
17backup receipt; this writes ONLY ``source/changelog/product/**`` (the roadmap seed
18is never touched), and never the live ``CHANGELOG.md``.
19
20Deliberately NOT the roadmap reseed: no destructive ``changelog/`` ``rglob`` cleanup,
21no ``_BUMP_RE`` arrow classifier. Keys are **independent synthetic** versions in a
22``v0.0.x`` band proven disjoint from every version in the source tree (all kinds) +
23the store, fail-closed (mirroring a roadmap bump arrow would mint ``v3.03–v3.10`` and
24hard-fail at ``SRC_VERSION_METADATA_CONFLICT`` or silently dedup-merge).
25
26Responsibilities
27----------------
28- ``backfill`` (the migration), ``synthetic_versions`` (disjoint band, fail-closed),
29 ``_roadmap_snapshot`` (the untouched guard), reported ``SeedReport`` for the audit.
30
31Diagnostics
32-----------
33Domain: OC_CHAT.MAIN
34Levels:
35 L2 - seed lifecycle
36 L3 - per-segment record emission
37 L4 - version/date assignment
38
39Contracts
40---------
41- Writes ONLY ``source/changelog/product/**``; a pre/post hash+filelist guard over
42 ``source/changelog/roadmap/**`` fails closed on any change. Idempotent: a non-empty
43 product dir must match this run's expected record set or it fails closed.
44
45Dependencies
46------------
47- oc_diagnostics (structured debug logging via _dbg)
48- option_c_registry_contracts (the Version model and SCHEMA_VERSION for each product record)
49- oc_chat.registry.regenerate (imported for the contracts bootstrap it runs)
50- oc_chat.registry.regenerate.pipeline (regenerate - rebuilds a temporary store for the fidelity proof)
51- oc_chat.registry.render.roadmap (render_living_set - renders the root changelog for the fidelity proof)
52- oc_chat.registry.source.canonical (format_record_text and load_record - writes each product record and reads existing versions)
53- oc_chat.registry.source.canonical_product (split_segments, segment_line_ranges and product_changelog_canonicalize - tiles and canonicalises the changelog)
54- oc_chat.registry.source.scanner (scan_source_tree - finds the version records whose versions the synthetic band avoids)
55"""
56from __future__ import annotations
57
58import hashlib
59import re
60import sys
61import tempfile
62from dataclasses import dataclass, field
63from pathlib import Path
64
65# Force the WORKTREE-local oc_chat ahead of the editable main-repo install.
66sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
67
68from oc_diagnostics import _dbg
69
70import oc_chat.registry.regenerate # noqa: F401 (contracts bootstrap)
71import option_c_registry_contracts as rc
72
73from oc_chat.registry.regenerate.pipeline import regenerate
74from oc_chat.registry.render.roadmap import render_living_set
75from oc_chat.registry.source.canonical import format_record_text, load_record
77 product_changelog_canonicalize,
78 segment_line_ranges,
79 split_segments,
80)
81from oc_chat.registry.source.scanner import scan_source_tree
82
83_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
84
85#: Synthetic product-record metadata profile (mirrors the roadmap reseed's date/era).
86_ERA = "v3"
87#: NAMED date fallback for a segment carrying no inline ISO date (recorded in coverage).
88_FALLBACK_DATE = "2026-05-15"
89#: The reserved synthetic-version band floor (the roadmap synthetic band runs dense
90#: to v0.0.58; 1000+ is well clear — but disjointness is asserted at runtime).
91_SYNTH_FLOOR = 1000
92_ISO_DATE_RE = re.compile(r"\b(\d{4}-\d{2}-\d{2})\b")
93
94
95@dataclass
97 records_written: int = 0
98 segments: int = 0
99 synthetic_band: tuple[str, str] = ("", "")
100 date_fallback_seqs: list[int] = field(default_factory=list)
101 canonical_bytes: int = 0
102 rows: list[dict] = field(default_factory=list) # per-segment audit
103
104
105def _roadmap_snapshot(roadmap_dir: Path) -> dict[str, str]:
106 """``{relpath: sha256}`` over ``source/changelog/roadmap/**`` (the untouched guard)."""
107 out: dict[str, str] = {}
108 if roadmap_dir.is_dir():
109 for path in sorted(roadmap_dir.rglob("*.md")):
110 out[path.name] = hashlib.sha256(path.read_bytes()).hexdigest()
111 return out
112
113
114def _gather_existing_versions(source_dir: Path) -> set[str]:
115 """Every NON-product ``version`` string in the scanned source tree.
116
117 The synthetic band must be disjoint from the roadmap (and any other non-product)
118 version set — colliding there hard-fails at ``SRC_VERSION_METADATA_CONFLICT`` or
119 silently dedup-merges. The product records this seed (re)writes are deliberately
120 EXCLUDED so the band is stable across re-runs (idempotent); product versions are
121 unique within the band by construction.
122 """
123 versions: set[str] = set()
124 for scanned in scan_source_tree(source_dir):
125 if scanned.kind == "version":
126 model, _body = load_record(scanned)
127 if model.kind != "product":
128 versions.add(model.version)
129 return versions
130
131
132def synthetic_versions(count: int, existing: set[str]) -> list[str]:
133 """A deterministic ``v0.0.<n>`` band of ``count`` versions, provably disjoint
134 from ``existing`` (fail-closed if no disjoint band is found at the floor)."""
135 base = _SYNTH_FLOOR
136 while any(f"v0.0.{base + i}" in existing for i in range(count)):
137 base += count
138 band = [f"v0.0.{base + i}" for i in range(count)]
139 overlap = set(band) & existing
140 if overlap: # fail-closed (cannot happen given the loop, but assert the invariant)
141 raise ValueError(f"synthetic version band collides with existing versions: {sorted(overlap)}")
142 return band
143
144
145def _first_iso_date(segment: str) -> str | None:
146 match = _ISO_DATE_RE.search(segment)
147 return match.group(1) if match else None
148
149
150def backfill(backup_path: Path, source_dir: Path) -> SeedReport:
151 """Tile ``backup_path`` into ``source/changelog/product/**`` records."""
152 func = "backfill"
153 source_dir = Path(source_dir)
154 roadmap_dir = source_dir / "changelog" / "roadmap"
155 product_dir = source_dir / "changelog" / "product"
156
157 before = _roadmap_snapshot(roadmap_dir)
158 raw = Path(backup_path).read_text(encoding="utf-8")
159 segments = split_segments(raw)
160 line_ranges = segment_line_ranges(raw)
161 canonical = product_changelog_canonicalize(raw)
162
163 existing = _gather_existing_versions(source_dir)
164 band = synthetic_versions(len(segments), existing)
165
166 # Plan the records before writing (for the idempotency check).
167 planned: dict[str, str] = {} # filename -> file text
168 report = SeedReport(segments=len(segments), canonical_bytes=len(canonical.encode("utf-8")))
169 report.synthetic_band = (band[0], band[-1])
170 for index, (segment, version, (lo, hi)) in enumerate(zip(segments, band, line_ranges), start=1):
171 date = _first_iso_date(segment)
172 if date is None:
173 date = _FALLBACK_DATE
174 report.date_fallback_seqs.append(index)
175 model = rc.Version(
176 schema_version=rc.SCHEMA_VERSION,
177 version=version,
178 date=date,
179 era=_ERA,
180 kind="product",
181 extensions={"changelog_seq": index},
182 )
183 text = format_record_text(model, body=segment)
184 filename = f"{index:03d}-{version}.md"
185 planned[filename] = text
186 body_hash = hashlib.sha256(text.encode("utf-8")).hexdigest()
187 report.rows.append({
188 "seq": index, "version": version, "lines": f"{lo}-{hi}",
189 "destination": f"changelog/product/{filename}", "date": date,
190 "body_sha256": body_hash,
191 })
192
193 # Idempotency: a non-empty product dir must match exactly what we plan to write.
194 if product_dir.is_dir():
195 on_disk = {p.name: p.read_text(encoding="utf-8") for p in product_dir.glob("*.md")}
196 if on_disk and on_disk != planned:
197 raise ValueError(
198 "product changelog dir is non-empty and does not match this seed's expected "
199 f"record set (on-disk {len(on_disk)} vs planned {len(planned)}); refusing (fail-closed)"
200 )
201
202 product_dir.mkdir(parents=True, exist_ok=True)
203 for filename, text in planned.items():
204 (product_dir / filename).write_text(text, encoding="utf-8", newline="")
205 report.records_written += 1
206
207 after = _roadmap_snapshot(roadmap_dir)
208 if before != after:
209 raise ValueError("roadmap changelog records changed during product seed (fail-closed)")
210
211 # Self-validating fidelity: the verbatim-seq render must reproduce
212 # canonicalize(backup) byte-for-byte (header-stripped). Fail-closed.
213 with tempfile.TemporaryDirectory() as tmp:
214 check_db = Path(tmp) / "seed_fidelity.sqlite"
215 result = regenerate(source_dir, check_db)
216 if not result.ok:
217 raise ValueError(f"regenerate failed after product seed (fail-closed): {result.envelope}")
218 rendered = render_living_set(check_db, source_dir)["root_changelog"]
219 body = rendered.split("-->", 1)[1].lstrip("\n")
220 if body != canonical:
221 raise ValueError("render(seed) != canonicalize(backup) — content-faithful proof failed (fail-closed)")
222
223 _dbg("OC_CHAT.MAIN", func, f"wrote {report.records_written} product records; render==canon", 2)
224 return report
225
226
227def _main() -> int:
228 func = "_main"
229 repo = Path(__file__).resolve().parents[1]
230 backup = repo / "CHANGELOG.md" # the stable live oracle (byte-identical to the backup)
231 source = repo / "oc_chat" / "roadmap" / "source"
232 report = backfill(backup, source)
233 print(f"records_written={report.records_written} segments={report.segments}")
234 print(f"synthetic_band={report.synthetic_band[0]}..{report.synthetic_band[1]}")
235 print(f"date_fallback_seqs={report.date_fallback_seqs}")
236 print(f"canonical_bytes={report.canonical_bytes}")
237 _dbg("OC_CHAT.MAIN", func, f"records={report.records_written}", 2)
238 return 0
239
240
241if __name__ == "__main__":
242 raise SystemExit(_main())
set[str] _gather_existing_versions(Path source_dir)
list[str] synthetic_versions(int count, set[str] existing)
dict[str, str] _roadmap_snapshot(Path roadmap_dir)
SeedReport backfill(Path backup_path, Path source_dir)