7Input readers for the roadmap reconciliation check (Plan 205).
11Project the two authored corpora the check reads — the ``roadmap/source/**``
12record tree and the ``plans/**`` frontmatter — into flat, queryable indexes.
16- ``load_source_index`` — id to status/kind/children/prose, plus the changelog
17 record bodies, read through the real scanner + record loader.
18- ``load_plan_records`` — plan id, path, and the ``advances:`` declaration,
19 distinguishing *absent field* from the conformant *declared none*.
20- Classify which ids are safe to scan for in prose (``SCANNABLE_ID``).
26 L2 - index build lifecycle
27 L3 - corpus size summaries
28 L4 - per-record classification
32- Read-only: neither loader writes to the corpora it reads.
33- The source index is built from ``source/**`` and never from a projection
34 store: the on-disk authored projection can be stale, and a check for staleness
35 must not take its evidence from a possibly-stale artifact.
36- Single-letter and other degenerate roadmap ids are excluded from prose
37 scanning and reported in ``unscannable_ids`` — excluded visibly, never
38 silently (they collide with agent slot handles and list markers).
42- oc_diagnostics (structured debug logging via _dbg)
43- oc_chat.registry.source.scanner (source tree classification)
44- oc_chat.registry.source.canonical (record loading)
45- yaml (plan frontmatter parsing)
47from __future__
import annotations
50from dataclasses
import dataclass, field
51from pathlib
import Path
52from typing
import Mapping, Sequence
56from oc_diagnostics
import _dbg
61_dbg(
"OC_CHAT.MAIN",
"module",
"loaded", 4)
64STATUS_KINDS: frozenset[str] = frozenset({
"phase",
"promoted_item",
"roadmap_item"})
67TERMINAL_STATUSES: frozenset[str] = frozenset({
"done",
"deferred",
"superseded"})
70ADVANCED_STATUSES: frozenset[str] = frozenset({
"done",
"in_progress"})
76SCANNABLE_ID = re.compile(
r"^(?=.{2,}$).*[0-9.-]")
80ID_TOKEN = re.compile(
r"\b[A-Z][A-Za-z0-9]*(?:[.-][A-Za-z0-9]+)*\b")
87@dataclass(frozen=True)
89 """One status-bearing roadmap record (a phase, sub-phase, or item)."""
96 parent_id: str |
None =
None
99@dataclass(frozen=True)
101 """One ``source/changelog/**`` record: its routing kind and its prose."""
109@dataclass(frozen=True)
111 """The flat projection of a ``roadmap/source/**`` tree the check reads."""
113 records: Mapping[str, RoadmapRecord]
114 changelog: Mapping[str, ChangelogRecord]
115 children: Mapping[str, tuple[str, ...]]
120 """Ids safe to match in free prose."""
123 record_id
for record_id
in self.
records if SCANNABLE_ID.match(record_id)
128 """Ids deliberately excluded from prose scanning, sorted for the payload."""
134 if not SCANNABLE_ID.match(record_id)
139 """The current status of ``record_id``, or None when it has no record."""
141 record = self.
records.get(record_id)
142 return None if record
is None else record.status
145@dataclass(frozen=True)
147 """One plan file's reconciliation-relevant frontmatter."""
152 has_advances_field: bool
153 completed: bool =
True
157 frontmatter_error: str |
None =
None
161 """True when the plan conformantly declares that it moves no record."""
163 return self.has_advances_field
and (
169 """The roadmap id this plan declares it advances, if it names one."""
173 return (self.
advances or "").strip()
177 """The ``(id, status)`` pairs of a phase record's ``sub_phases`` list."""
179 entries: list[tuple[str, str]] = []
180 for sub
in data.get(
"sub_phases")
or []:
181 if isinstance(sub, Mapping)
and sub.get(
"id"):
182 entries.append((str(sub[
"id"]), str(sub.get(
"status"))))
187 """Build the flat index of a ``roadmap/source/**`` tree."""
189 func =
"load_source_index"
190 root = Path(source_dir)
191 records: dict[str, RoadmapRecord] = {}
192 changelog: dict[str, ChangelogRecord] = {}
193 children: dict[str, list[str]] = {}
195 for scanned
in scan_source_tree(root):
196 model, body = load_record(scanned)
197 data = model.model_dump()
198 if scanned.kind
in STATUS_KINDS:
199 record_id = str(data[
"id"])
202 status=str(data.get(
"status")),
204 source_ref=scanned.rel_path,
212 source_ref=scanned.rel_path,
216 children.setdefault(record_id, []).append(sub_id)
217 elif scanned.kind ==
"version":
219 rel_path=scanned.rel_path,
220 kind=str(data.get(
"kind")
or ""),
221 date=str(data.get(
"date")
or ""),
228 for record_id, record
in records.items():
229 if record.kind !=
"promoted_item" or "." not in record_id:
231 parent = record_id.rsplit(
".", 1)[0]
232 if parent
in records
and record_id
not in children.get(parent, []):
233 children.setdefault(parent, []).append(record_id)
238 f
"indexed {len(records)} records + {len(changelog)} changelog records",
244 children={key: tuple(value)
for key, value
in children.items()},
250 """The description of ``sub_id`` inside a phase record's ``sub_phases``."""
252 for sub
in data.get(
"sub_phases")
or []:
253 if isinstance(sub, Mapping)
and str(sub.get(
"id")) == sub_id:
254 return str(sub.get(
"description")
or "")
263_ADVANCES_LINE = re.compile(
r"^advances:\s*(.*?)\s*$", re.MULTILINE)
266_PLAN_ID_LINE = re.compile(
r"^plan_id:\s*(.*?)\s*$", re.MULTILINE)
270 """Strip an inline ``#`` comment and surrounding quotes from a YAML scalar."""
272 value = raw.split(
"#", 1)[0].strip()
if not raw.startswith((
"'",
'"'))
else raw
273 return value.strip().strip(
"\"'").strip()
277 """Return ``(parsed_mapping, raw_frontmatter, parse_error)`` for a plan file.
279 The mapping is best-effort: an empty one means the YAML did not parse, NOT
280 that the plan declared nothing. Callers must consult ``raw_frontmatter`` for
281 the fields they care about and surface ``parse_error`` rather than treating
282 a failed parse as an absent field.
286 frontmatter, _body = split_record_text(text)
287 except Exception
as error:
288 return {},
"", f
"no frontmatter: {error}"
290 data = yaml.safe_load(frontmatter)
or {}
291 except yaml.YAMLError
as error:
292 first_line = str(error).strip().splitlines()[0]
293 return {}, frontmatter, f
"frontmatter is not strict YAML: {first_line}"
294 return (data
if isinstance(data, dict)
else {}), frontmatter,
None
298 plans_dir: str | Path, *, completed: bool =
True
299) -> tuple[PlanRecord, ...]:
300 """Read every ``*.md`` plan in ``plans_dir`` (non-recursive), sorted by id."""
302 func =
"load_plan_records"
303 directory = Path(plans_dir)
304 if not directory.is_dir():
305 _dbg(
"OC_CHAT.MAIN", func, f
"no plans dir at {directory}", 3)
307 plans: list[PlanRecord] = []
308 for path
in sorted(directory.glob(
"*.md")):
309 data, frontmatter, parse_error =
_plan_frontmatter(path.read_text(encoding=
"utf-8"))
310 line_match = _ADVANCES_LINE.search(frontmatter)
311 if "advances" in data:
312 raw = data.get(
"advances")
313 advances =
None if raw
is None else str(raw)
315 elif line_match
is not None:
319 advances, has_field =
None,
False
320 plan_id = str(data.get(
"plan_id")
or "").strip()
322 id_match = _PLAN_ID_LINE.search(frontmatter)
323 plan_id =
_strip_scalar(id_match.group(1))
if id_match
else path.stem
326 plan_id=plan_id
or path.stem,
329 has_advances_field=has_field,
331 frontmatter_error=parse_error,
334 _dbg(
"OC_CHAT.MAIN", func, f
"read {len(plans)} plans from {directory.name}", 2)
338def named_ids(text: str, scannable: frozenset[str]) -> Sequence[str]:
339 """The roadmap ids named anywhere in ``text``, sorted."""
341 return sorted({token
for token
in ID_TOKEN.findall(text)} & scannable)