oc_chat
Loading...
Searching...
No Matches
inputs.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/roadmap/reconcile/inputs.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/roadmap/test_reconcile.py"
6"""
7Input readers for the roadmap reconciliation check (Plan 205).
8
9Purpose
10-------
11Project the two authored corpora the check reads — the ``roadmap/source/**``
12record tree and the ``plans/**`` frontmatter — into flat, queryable indexes.
13
14Responsibilities
15----------------
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``).
21
22Diagnostics
23-----------
24Domain: OC_CHAT.MAIN
25Levels:
26 L2 - index build lifecycle
27 L3 - corpus size summaries
28 L4 - per-record classification
29
30Contracts
31---------
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).
39
40Dependencies
41------------
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)
46"""
47from __future__ import annotations
48
49import re
50from dataclasses import dataclass, field
51from pathlib import Path
52from typing import Mapping, Sequence
53
54import yaml
55
56from oc_diagnostics import _dbg
57
58from oc_chat.registry.source.canonical import load_record, split_record_text
59from oc_chat.registry.source.scanner import scan_source_tree
60
61_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
62
63#: Record kinds that carry a roadmap status.
64STATUS_KINDS: frozenset[str] = frozenset({"phase", "promoted_item", "roadmap_item"})
65
66#: Statuses that mean "this record will not move again".
67TERMINAL_STATUSES: frozenset[str] = frozenset({"done", "deferred", "superseded"})
68
69#: Statuses that mean "the landing did move this record".
70ADVANCED_STATUSES: frozenset[str] = frozenset({"done", "in_progress"})
71
72#: An id is safe to scan for in free prose only if it is at least two characters
73#: and carries a digit, dot or hyphen. Bare ``A``/``B``/``C`` are real pre-M0-era
74#: item ids AND the workflow's agent slot handles; matching them in prose yields
75#: nothing but noise.
76SCANNABLE_ID = re.compile(r"^(?=.{2,}$).*[0-9.-]")
77
78#: Candidate roadmap-id tokens in prose. Intersected with the real id universe,
79#: so this pattern only has to be generous, never precise.
80ID_TOKEN = re.compile(r"\b[A-Z][A-Za-z0-9]*(?:[.-][A-Za-z0-9]+)*\b")
81
82#: A plan whose ``advances:`` is this token declares, conformantly, that it moves
83#: no roadmap record.
84ADVANCES_NONE = "none"
85
86
87@dataclass(frozen=True)
89 """One status-bearing roadmap record (a phase, sub-phase, or item)."""
90
91 record_id: str
92 status: str
93 kind: str
94 source_ref: str
95 prose: str
96 parent_id: str | None = None
97
98
99@dataclass(frozen=True)
101 """One ``source/changelog/**`` record: its routing kind and its prose."""
102
103 rel_path: str
104 kind: str
105 date: str
106 body: str
107
108
109@dataclass(frozen=True)
111 """The flat projection of a ``roadmap/source/**`` tree the check reads."""
112
113 records: Mapping[str, RoadmapRecord]
114 changelog: Mapping[str, ChangelogRecord]
115 children: Mapping[str, tuple[str, ...]]
116 root: Path
117
118 @property
119 def scannable_ids(self) -> frozenset[str]:
120 """Ids safe to match in free prose."""
121
122 return frozenset(
123 record_id for record_id in self.records if SCANNABLE_ID.match(record_id)
124 )
125
126 @property
127 def unscannable_ids(self) -> tuple[str, ...]:
128 """Ids deliberately excluded from prose scanning, sorted for the payload."""
129
130 return tuple(
131 sorted(
132 record_id
133 for record_id in self.records
134 if not SCANNABLE_ID.match(record_id)
135 )
136 )
137
138 def status_of(self, record_id: str) -> str | None:
139 """The current status of ``record_id``, or None when it has no record."""
140
141 record = self.records.get(record_id)
142 return None if record is None else record.status
143
144
145@dataclass(frozen=True)
147 """One plan file's reconciliation-relevant frontmatter."""
148
149 plan_id: str
150 path: str
151 advances: str | None
152 has_advances_field: bool
153 completed: bool = True
154 #: Set when the frontmatter did not parse as strict YAML. The ``advances:``
155 #: field is still recovered line-wise, but the failure is reported rather
156 #: than silently read as "the plan declared nothing".
157 frontmatter_error: str | None = None
158
159 @property
160 def declares_none(self) -> bool:
161 """True when the plan conformantly declares that it moves no record."""
162
163 return self.has_advances_field and (
164 self.advances is None or self.advances.strip().lower() == ADVANCES_NONE
165 )
166
167 @property
168 def advanced_id(self) -> str | None:
169 """The roadmap id this plan declares it advances, if it names one."""
170
171 if not self.has_advances_field or self.declares_none:
172 return None
173 return (self.advances or "").strip()
174
175
176def _sub_phase_entries(data: Mapping[str, object]) -> list[tuple[str, str]]:
177 """The ``(id, status)`` pairs of a phase record's ``sub_phases`` list."""
178
179 entries: list[tuple[str, str]] = []
180 for sub in data.get("sub_phases") or []: # type: ignore[union-attr]
181 if isinstance(sub, Mapping) and sub.get("id"):
182 entries.append((str(sub["id"]), str(sub.get("status"))))
183 return entries
184
185
186def load_source_index(source_dir: str | Path) -> SourceIndex:
187 """Build the flat index of a ``roadmap/source/**`` tree."""
188
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]] = {}
194
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"])
200 records[record_id] = RoadmapRecord(
201 record_id=record_id,
202 status=str(data.get("status")),
203 kind=scanned.kind,
204 source_ref=scanned.rel_path,
205 prose=body,
206 )
207 for sub_id, sub_status in _sub_phase_entries(data):
208 records[sub_id] = RoadmapRecord(
209 record_id=sub_id,
210 status=sub_status,
211 kind="sub_phase",
212 source_ref=scanned.rel_path,
213 prose=_sub_phase_prose(data, sub_id),
214 parent_id=record_id,
215 )
216 children.setdefault(record_id, []).append(sub_id)
217 elif scanned.kind == "version":
218 changelog[scanned.rel_path] = ChangelogRecord(
219 rel_path=scanned.rel_path,
220 kind=str(data.get("kind") or ""),
221 date=str(data.get("date") or ""),
222 body=body,
223 )
224
225 # A promoted item lives in its own file under ``phases/<PHASE>/`` and is NOT
226 # listed in its parent's ``sub_phases``; fold it into the parent's children so
227 # class-3 sees the whole sub-part set.
228 for record_id, record in records.items():
229 if record.kind != "promoted_item" or "." not in record_id:
230 continue
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)
234
235 _dbg(
236 "OC_CHAT.MAIN",
237 func,
238 f"indexed {len(records)} records + {len(changelog)} changelog records",
239 2,
240 )
241 return SourceIndex(
242 records=records,
243 changelog=changelog,
244 children={key: tuple(value) for key, value in children.items()},
245 root=root,
246 )
247
248
249def _sub_phase_prose(data: Mapping[str, object], sub_id: str) -> str:
250 """The description of ``sub_id`` inside a phase record's ``sub_phases``."""
251
252 for sub in data.get("sub_phases") or []: # type: ignore[union-attr]
253 if isinstance(sub, Mapping) and str(sub.get("id")) == sub_id:
254 return str(sub.get("description") or "")
255 return ""
256
257
258#: The ``advances:`` declaration, read line-wise. Plan frontmatter is authored
259#: prose-first and is NOT reliably strict YAML (an unquoted ``status:`` line
260#: carrying its own ``: `` is enough to break ``safe_load``), so the field is
261#: recovered directly rather than via the whole document. Anything else would
262#: report "no advances field" for every plan whose *other* keys failed to parse.
263_ADVANCES_LINE = re.compile(r"^advances:\s*(.*?)\s*$", re.MULTILINE)
264
265#: A plain ``plan_id:`` line, recovered the same way and for the same reason.
266_PLAN_ID_LINE = re.compile(r"^plan_id:\s*(.*?)\s*$", re.MULTILINE)
267
268
269def _strip_scalar(raw: str) -> str:
270 """Strip an inline ``#`` comment and surrounding quotes from a YAML scalar."""
271
272 value = raw.split("#", 1)[0].strip() if not raw.startswith(("'", '"')) else raw
273 return value.strip().strip("\"'").strip()
274
275
276def _plan_frontmatter(text: str) -> tuple[dict[str, object], str, str | None]:
277 """Return ``(parsed_mapping, raw_frontmatter, parse_error)`` for a plan file.
278
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.
283 """
284
285 try:
286 frontmatter, _body = split_record_text(text)
287 except Exception as error: # noqa: BLE001 - a plan without frontmatter is not fatal
288 return {}, "", f"no frontmatter: {error}"
289 try:
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
295
296
298 plans_dir: str | Path, *, completed: bool = True
299) -> tuple[PlanRecord, ...]:
300 """Read every ``*.md`` plan in ``plans_dir`` (non-recursive), sorted by id."""
301
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)
306 return ()
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)
314 has_field = True
315 elif line_match is not None:
316 advances = _strip_scalar(line_match.group(1)) or None
317 has_field = True
318 else:
319 advances, has_field = None, False
320 plan_id = str(data.get("plan_id") or "").strip()
321 if not plan_id:
322 id_match = _PLAN_ID_LINE.search(frontmatter)
323 plan_id = _strip_scalar(id_match.group(1)) if id_match else path.stem
324 plans.append(
326 plan_id=plan_id or path.stem,
327 path=path.name,
328 advances=advances,
329 has_advances_field=has_field,
330 completed=completed,
331 frontmatter_error=parse_error,
332 )
333 )
334 _dbg("OC_CHAT.MAIN", func, f"read {len(plans)} plans from {directory.name}", 2)
335 return tuple(plans)
336
337
338def named_ids(text: str, scannable: frozenset[str]) -> Sequence[str]:
339 """The roadmap ids named anywhere in ``text``, sorted."""
340
341 return sorted({token for token in ID_TOKEN.findall(text)} & scannable)
Sequence[str] named_ids(str text, frozenset[str] scannable)
Definition inputs.py:338
SourceIndex load_source_index(str|Path source_dir)
Definition inputs.py:186
tuple[dict[str, object], str, str|None] _plan_frontmatter(str text)
Definition inputs.py:276
list[tuple[str, str]] _sub_phase_entries(Mapping[str, object] data)
Definition inputs.py:176
tuple[PlanRecord,...] load_plan_records(str|Path plans_dir, *, bool completed=True)
Definition inputs.py:299
str _sub_phase_prose(Mapping[str, object] data, str sub_id)
Definition inputs.py:249