oc_chat
Loading...
Searching...
No Matches
parser.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/roadmap/parser.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/roadmap/test_parser.py"
6"""
7Markdown parser for ROADMAP registry ingest.
8
9Purpose
10-------
11Convert the four ROADMAP markdown artifact families into deterministic
12records for the registry v0.3 roadmap tables.
13
14Responsibilities
15----------------
16- Classify roadmap artifact file kinds and versions.
17- Extract phases, items, dependencies, tracks, and render-section hints.
18- Produce stable semantic hashes for ingest idempotence checks.
19
20Diagnostics
21-----------
22Domain: OC_CHAT.MAIN
23Levels:
24 L2 - parser lifecycle
25 L3 - extracted roadmap records
26 L4 - per-row markdown parsing details
27
28Contracts
29---------
30- Parsing is read-only and never mutates registry or filesystem state.
31- Output records are deterministic for identical markdown bytes.
32
33Dependencies
34------------
35- oc_chat.registry.canonical (stable JSON hashing)
36- oc_diagnostics (structured debug logging via _dbg)
37"""
38from __future__ import annotations
39
40from dataclasses import dataclass
41import hashlib
42import re
43from pathlib import Path
44
45from oc_diagnostics import _dbg
46
47from oc_chat.registry.canonical import canonical_json
48
49_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
50
51
52class RoadmapParseError(ValueError):
53 """Raised when a roadmap file is not parseable enough for ingest."""
54
55
56@dataclass(frozen=True)
58 phase_code: str
59 label: str
60 sort_order: int
61 parent_phase_code: str | None = None
62 priority_label: str | None = None
63 parallel_track: bool = False
64 target_version: str | None = None
65
66
67@dataclass(frozen=True)
69 track_code: str
70 label: str
71 description: str | None
72 sort_order: int
73
74
75@dataclass(frozen=True)
77 item_id: str
78 title: str
79 track_kind: str
80 sort_order: int
81 status: str
82 priority: str | None = None
83 phase_code: str | None = None
84 parent_item_id: str | None = None
85 target_version: str | None = None
86 prose: str | None = None
87
88 def natural_key(self) -> str:
89 return self.item_id
90
91 def content_hash(self) -> str:
93 "roadmap_items_v3",
94 self.item_id,
95 {
96 "title": self.title,
97 "track_kind": self.track_kind,
98 "status": self.status,
99 "priority": self.priority,
100 "phase_code": self.phase_code,
101 "parent_item_id": self.parent_item_id,
102 "target_version": self.target_version,
103 "prose": self.prose,
104 "sort_order": self.sort_order,
105 },
106 )
107
108
109@dataclass(frozen=True)
111 from_item_id: str
112 to_item_id: str
113 kind: str = "gates"
114
115
116@dataclass(frozen=True)
118 file_kind: str
119 section_path: str
120 sort_order: int
121 query_kind: str
122 query_params_json: str | None = None
123
124
125@dataclass(frozen=True)
127 path: Path
128 file_kind: str
129 version_id: str
130 title: str
131 content_sha256: str
132 era_id: str
133 created_at: str
134 last_updated_at: str
135 phases: tuple[ParsedPhase, ...]
136 tracks: tuple[ParsedTrack, ...]
137 items: tuple[ParsedItem, ...]
138 dependencies: tuple[ParsedDependency, ...]
139 render_sections: tuple[ParsedRenderSection, ...]
140
141
142VERSION_RE = re.compile(r"\b(?:v)?(?P<version>\d+\.\d+)\b")
143H1_RE = re.compile(r"^#\s+(?P<title>.+?)\s*$", re.MULTILINE)
144# OCT-LINT: disable=no_hardcoded_secrets reason="Roadmap regex patterns are parser constants, not secrets"
145HEADER_RE = re.compile(r"^\*\*(?P<name>Version|Created|Last updated):\*\*\s*(?P<value>.+?)\s*$")
146PHASE_HEADING_RE = re.compile(
147 # OCT-LINT: disable=no_hardcoded_secrets reason="Roadmap regex patterns are parser constants, not secrets"
148 r"^#\s+PHASE\s+(?P<code>M\d+(?:\.\d+[a-z]?|[a-z])?)\s*(?:[-\u2014]\s*(?P<label>.+?))?\s*$",
149 re.IGNORECASE,
150)
151ITEM_ID_RE = re.compile(
152 # OCT-LINT: disable=no_hardcoded_secrets reason="Roadmap regex patterns are parser constants, not secrets"
153 r"^(?:"
154 r"M\d+(?:\.\d+(?:\.[a-z0-9]+)?[a-z]?|[a-z])?|"
155 r"M11-GATE|"
156 r"FS-\d+|V\d+|L\d+|U\d+|X\d+|QUEUE-\d+|TUI-R\d+|"
157 r"[A-Z](?:\.\d+[a-z]?| V2|\s+\‍([A-Z]\‍))?|"
158 r"[A-Z]\.\d+[a-z]?(?:-\w+)?|"
159 r"P\.\d+[a-z]?(?:-\w+)?|O\d[a-z]?"
160 r")$"
161)
162PHASE_CODE_RE = re.compile(r"^M\d+(?:\.\d+[a-z]?|[a-z])?$|^M11-GATE$")
163FRACTION_RE = re.compile(r"\b\d+\s*/\s*\d+\b")
164PLAN_ID_RE = re.compile(r"\b\d{3}-[A-Za-z0-9][A-Za-z0-9_.-]*\b")
165
166DONE_MARKERS = ("\u2705", "[x]", "done", "complete", "completed", "shipped", "closed")
167PROGRESS_MARKERS = ("\U0001f535", "[~]", "in progress", "partial", "started")
168BLOCKED_MARKERS = ("\U0001f512", "[!]", "blocked", "gated")
169TODO_MARKERS = ("\u2b1c", "[ ]", "todo", "not started")
170
171PRIORITY_MARKERS = (
172 ("critical", ("\U0001f534", "critical", "urgent")),
173 ("high", ("\U0001f7e0", "high")),
174 ("medium", ("\U0001f7e1", "medium")),
175 ("lower", ("\U0001f7e2", "lower", "low", "nice-to-have")),
176)
177
178DEFAULT_TRACKS = (
179 ParsedTrack("m_phase", "M-phase delivery", "Active migration-era sequence", 10),
180 ParsedTrack("registry", "Orchestration Registry", "M11 registry track", 20),
181 ParsedTrack("vision", "Vision-track", "AI Software Factory backlog", 30),
182 ParsedTrack("memory", "Memory / context / learning", "Memory backlog", 40),
183 ParsedTrack("undo", "Undo / rollback", "Rollback backlog", 50),
184 ParsedTrack("tooling", "Tooling extensions", "Tooling backlog", 60),
185 ParsedTrack("git_workflow", "Git workflow", "Repository workflow backlog", 70),
186 ParsedTrack("pre_m0_era", "Pre-M0 era", "Historical carried-forward work", 80),
187)
188
189
190def parse_roadmap_file(path: str | Path) -> ParsedRoadmap:
191 """Parse one ROADMAP markdown artifact into normalized records."""
192
193 source = Path(path)
194 text = source.read_text(encoding="utf-8")
195 if not text.strip():
196 raise RoadmapParseError(f"roadmap file is empty: {source}")
197 file_kind = _file_kind(source)
198 version_id = _version_id(source, text)
199 if version_id is None:
200 raise RoadmapParseError(f"roadmap version not found: {source}")
201 header = _header_values(text)
202 title = _title(text)
203 created_at = header.get("created") or _fallback_timestamp(version_id)
204 last_updated_at = header.get("last updated") or created_at
205 phases = _parse_phases(text, version_id)
206 items = _parse_items(text, file_kind, version_id, phases)
207 dependencies = _parse_dependencies(text, {item.item_id for item in items})
208 render_sections = _parse_render_sections(text, file_kind)
209 return ParsedRoadmap(
210 path=source,
211 file_kind=file_kind,
212 version_id=version_id,
213 title=title,
214 content_sha256=hashlib.sha256(text.encode("utf-8")).hexdigest(),
215 era_id=_era_id(version_id),
216 created_at=created_at,
217 last_updated_at=last_updated_at,
218 phases=phases,
219 tracks=DEFAULT_TRACKS,
220 items=items,
221 dependencies=dependencies,
222 render_sections=render_sections,
223 )
224
225
226def canonical_record_hash(table: str, natural_key: str, fields: dict[str, object]) -> str:
227 """Return the stable ingest hash for one semantic record."""
228
229 payload = {"table": table, "natural_key": natural_key, "fields": fields}
230 return hashlib.sha256(canonical_json(payload).encode("utf-8")).hexdigest()
231
232
233def _file_kind(path: Path) -> str:
234 name = path.name.upper()
235 if name.startswith("ROADMAP-EXEC-ORDER-"):
236 return "roadmap_exec_order"
237 if name.startswith("ROADMAP-CHANGELOG-"):
238 return "roadmap_changelog"
239 if name.startswith("ROADMAP-TODO-"):
240 return "roadmap_todo"
241 if name.startswith("ROADMAP-"):
242 return "roadmap"
243 raise RoadmapParseError(f"not a recognized roadmap artifact: {path.name}")
244
245
246def _version_id(path: Path, text: str) -> str | None:
247 match = VERSION_RE.search(path.stem)
248 if match is None:
249 header = _header_values(text)
250 match = VERSION_RE.search(header.get("version", ""))
251 if match is None:
252 return None
253 return f"v{match.group('version')}"
254
255
256def _era_id(version_id: str) -> str:
257 major = version_id.removeprefix("v").split(".", 1)[0]
258 return f"v{major}"
259
260
261def _header_values(text: str) -> dict[str, str]:
262 values: dict[str, str] = {}
263 for line in text.splitlines():
264 match = HEADER_RE.match(line.strip())
265 if match is not None:
266 values[match.group("name").lower()] = match.group("value").strip()
267 return values
268
269
270def _title(text: str) -> str:
271 match = H1_RE.search(text)
272 if match is None:
273 raise RoadmapParseError("roadmap H1 title not found")
274 return match.group("title").strip()
275
276
277def _fallback_timestamp(version_id: str) -> str:
278 return f"{version_id}-source"
279
280
281def _parse_phases(text: str, version_id: str) -> tuple[ParsedPhase, ...]:
282 phases: dict[str, ParsedPhase] = {}
283 order = 0
284 for line in text.splitlines():
285 match = PHASE_HEADING_RE.match(line.strip())
286 if match is None:
287 continue
288 order += 10
289 code = match.group("code")
290 label = _strip_markdown(match.group("label") or code)
291 phases[code] = ParsedPhase(
292 phase_code=code,
293 label=label,
294 sort_order=order,
295 parent_phase_code=_parent_phase_code(code),
296 parallel_track=code == "M11",
297 target_version=_version_target(version_id),
298 )
299
300 for row in _iter_markdown_rows(text):
301 code = _phase_code_from_cells(row)
302 if code is None or code in phases:
303 continue
304 order += 10
305 label = _strip_markdown(_cell_after_phase_code(row, code) or code)
306 phases[code] = ParsedPhase(
307 phase_code=code,
308 label=label,
309 sort_order=order,
310 parent_phase_code=_parent_phase_code(code),
311 priority_label=_priority_from_cells(row),
312 parallel_track=code == "M11",
313 target_version=_version_target(version_id),
314 )
315 return tuple(sorted(phases.values(), key=lambda item: item.sort_order))
316
317
319 text: str,
320 file_kind: str,
321 version_id: str,
322 phases: tuple[ParsedPhase, ...],
323) -> tuple[ParsedItem, ...]:
324 phase_codes = {phase.phase_code for phase in phases}
325 seen: dict[str, ParsedItem] = {}
326 order = 0
327 for row in _iter_markdown_rows(text):
328 item_id = _item_id_from_cells(row)
329 if item_id is None:
330 continue
331 order += 10
332 title = _item_title(row, item_id)
333 if not title:
334 title = item_id
335 phase_code = _phase_for_item(item_id, phase_codes)
336 track_kind = _track_kind(item_id, file_kind, phase_code)
337 status = _status_from_cells(row)
338 priority = _priority_from_cells(row)
339 prose = _row_prose(row, item_id, title)
340 candidate = ParsedItem(
341 item_id=item_id,
342 title=title,
343 track_kind=track_kind,
344 sort_order=order,
345 status=status,
346 priority=priority,
347 phase_code=phase_code,
348 parent_item_id=_parent_item_id(item_id),
349 target_version=_version_target(version_id),
350 prose=prose,
351 )
352 previous = seen.get(item_id)
353 if previous is None or _item_score(candidate) > _item_score(previous):
354 seen[item_id] = candidate
355 return tuple(sorted(seen.values(), key=lambda item: (item.sort_order, item.item_id)))
356
357
359 text: str,
360 item_ids: set[str],
361) -> tuple[ParsedDependency, ...]:
362 dependencies: set[ParsedDependency] = set()
363 for row in _iter_markdown_rows(text):
364 item_id = _item_id_from_cells(row)
365 if item_id is None:
366 continue
367 for cell in row:
368 if "depend" not in cell.lower() and "gate" not in cell.lower():
369 continue
370 for target in item_ids:
371 if target == item_id:
372 continue
373 if re.search(rf"(?<![A-Za-z0-9_.-]){re.escape(target)}(?![A-Za-z0-9_.-])", cell):
374 dependencies.add(ParsedDependency(item_id, target))
375 return tuple(sorted(dependencies, key=lambda item: (item.from_item_id, item.to_item_id, item.kind)))
376
377
379 text: str,
380 file_kind: str,
381) -> tuple[ParsedRenderSection, ...]:
382 sections: list[ParsedRenderSection] = []
383 order = 0
384 for line in text.splitlines():
385 stripped = line.strip()
386 if not stripped.startswith("## "):
387 continue
388 order += 10
389 path = stripped
390 query_kind = "prose_only"
391 if "execution order" in stripped.lower():
392 query_kind = "m_phase_table"
393 elif "backlog" in stripped.lower():
394 query_kind = "backlog_track_table"
395 elif "completed" in stripped.lower() or "recent" in stripped.lower():
396 query_kind = "recently_completed_window"
397 sections.append(
399 file_kind=file_kind,
400 section_path=path,
401 sort_order=order,
402 query_kind=query_kind,
403 )
404 )
405 if sections:
406 return tuple(sections)
407 return (
409 file_kind=file_kind,
410 section_path="## Roadmap",
411 sort_order=10,
412 query_kind="prose_only",
413 ),
414 )
415
416
417def _iter_markdown_rows(text: str) -> list[list[str]]:
418 rows: list[list[str]] = []
419 for line in text.splitlines():
420 stripped = line.strip()
421 if not (stripped.startswith("|") and stripped.endswith("|")):
422 continue
423 cells = [cell.strip() for cell in stripped.strip("|").split("|")]
424 if not cells:
425 continue
426 if all(_is_separator_cell(cell) for cell in cells):
427 continue
428 rows.append(cells)
429 return rows
430
431
432def _is_separator_cell(cell: str) -> bool:
433 text = cell.replace(":", "").replace("-", "").strip()
434 return text == ""
435
436
437def _item_id_from_cells(cells: list[str]) -> str | None:
438 for cell in cells:
439 cleaned = _strip_markdown(cell).strip()
440 if ITEM_ID_RE.match(cleaned):
441 return cleaned
442 return None
443
444
445def _phase_code_from_cells(cells: list[str]) -> str | None:
446 for cell in cells:
447 cleaned = _strip_markdown(cell).strip()
448 if PHASE_CODE_RE.match(cleaned):
449 return cleaned
450 return None
451
452
453def _cell_after_phase_code(cells: list[str], code: str) -> str | None:
454 for index, cell in enumerate(cells):
455 if _strip_markdown(cell).strip() != code:
456 continue
457 if index + 1 < len(cells):
458 return cells[index + 1]
459 return None
460
461
462def _item_title(cells: list[str], item_id: str) -> str:
463 for index, cell in enumerate(cells):
464 if _strip_markdown(cell).strip() != item_id:
465 continue
466 for candidate in cells[index + 1:]:
467 text = _strip_markdown(candidate)
468 if text and not _looks_status_or_priority(text):
469 return text
470 for cell in cells:
471 text = _strip_markdown(cell)
472 if text and text != item_id and not _looks_status_or_priority(text):
473 return text
474 return ""
475
476
477def _row_prose(cells: list[str], item_id: str, title: str) -> str | None:
478 values = [
479 _strip_markdown(cell)
480 for cell in cells
481 if _strip_markdown(cell) not in {item_id, title}
483 ]
484 text = " | ".join(value for value in values if value)
485 return text or None
486
487
488def _status_from_cells(cells: list[str]) -> str:
489 haystack = " ".join(cells).lower()
490 if any(marker.lower() in haystack for marker in BLOCKED_MARKERS):
491 return "blocked"
492 if any(marker.lower() in haystack for marker in DONE_MARKERS):
493 return "done"
494 if any(marker.lower() in haystack for marker in PROGRESS_MARKERS):
495 return "in_progress"
496 if any(marker.lower() in haystack for marker in TODO_MARKERS):
497 return "todo"
498 if "deferred" in haystack:
499 return "deferred"
500 return "todo"
501
502
503def _priority_from_cells(cells: list[str]) -> str | None:
504 haystack = " ".join(cells).lower()
505 for priority, markers in PRIORITY_MARKERS:
506 if any(marker.lower() in haystack for marker in markers):
507 return priority
508 return None
509
510
511def _looks_status_or_priority(text: str) -> bool:
512 lowered = text.lower()
513 if FRACTION_RE.search(lowered):
514 return True
515 marker_groups = DONE_MARKERS + PROGRESS_MARKERS + BLOCKED_MARKERS + TODO_MARKERS
516 if any(marker.lower() in lowered for marker in marker_groups):
517 return True
518 return any(
519 marker.lower() in lowered
520 for _, markers in PRIORITY_MARKERS
521 for marker in markers
522 )
523
524
525def _phase_for_item(item_id: str, phase_codes: set[str]) -> str | None:
526 if item_id in phase_codes:
527 return item_id
528 if not item_id.startswith("M"):
529 return None
530 parts = item_id.split(".")
531 candidates = [parts[0]]
532 if len(parts) > 1:
533 candidates.insert(0, ".".join(parts[:2]))
534 for candidate in candidates:
535 if candidate in phase_codes:
536 return candidate
537 return parts[0] if parts[0].startswith("M") else None
538
539
540def _track_kind(item_id: str, file_kind: str, phase_code: str | None) -> str:
541 if item_id.startswith("V"):
542 return "vision"
543 if file_kind == "roadmap_exec_order" and phase_code is None:
544 return "pre_m0_era"
545 if phase_code is not None:
546 return "m_phase"
547 if item_id.startswith(("FS-", "P.", "G.", "B.", "O", "L", "U", "X")):
548 return "backlog"
549 return "pre_m0_era"
550
551
552def _parent_phase_code(code: str) -> str | None:
553 if "." in code:
554 return code.split(".", 1)[0]
555 return None
556
557
558def _parent_item_id(item_id: str) -> str | None:
559 if "." not in item_id or not item_id.startswith("M"):
560 return None
561 return item_id.rsplit(".", 1)[0]
562
563
564def _version_target(version_id: str) -> str:
565 return version_id
566
567
568def _item_score(item: ParsedItem) -> tuple[int, int]:
569 return (
570 1 if item.prose else 0,
571 len(item.title) + len(item.prose or ""),
572 )
573
574
575def _strip_markdown(value: str | None) -> str:
576 if value is None:
577 return ""
578 text = value.strip()
579 text = re.sub(r"`([^`]+)`", r"\1", text)
580 text = re.sub(r"\‍[([^\‍]]+)\‍]\‍([^)]+\‍)", r"\1", text)
581 text = text.replace("**", "").replace("__", "").strip()
582 return text
str _status_from_cells(list[str] cells)
Definition parser.py:488
str|None _cell_after_phase_code(list[str] cells, str code)
Definition parser.py:453
str|None _phase_code_from_cells(list[str] cells)
Definition parser.py:445
dict[str, str] _header_values(str text)
Definition parser.py:261
tuple[int, int] _item_score(ParsedItem item)
Definition parser.py:568
str _track_kind(str item_id, str file_kind, str|None phase_code)
Definition parser.py:540
tuple[ParsedRenderSection,...] _parse_render_sections(str text, str file_kind)
Definition parser.py:381
str|None _row_prose(list[str] cells, str item_id, str title)
Definition parser.py:477
str _fallback_timestamp(str version_id)
Definition parser.py:277
str|None _version_id(Path path, str text)
Definition parser.py:246
list[list[str]] _iter_markdown_rows(str text)
Definition parser.py:417
str|None _item_id_from_cells(list[str] cells)
Definition parser.py:437
str|None _priority_from_cells(list[str] cells)
Definition parser.py:503
ParsedRoadmap parse_roadmap_file(str|Path path)
Definition parser.py:190
bool _looks_status_or_priority(str text)
Definition parser.py:511
str|None _parent_phase_code(str code)
Definition parser.py:552
str|None _parent_item_id(str item_id)
Definition parser.py:558
str _item_title(list[str] cells, str item_id)
Definition parser.py:462
tuple[ParsedItem,...] _parse_items(str text, str file_kind, str version_id, tuple[ParsedPhase,...] phases)
Definition parser.py:323
bool _is_separator_cell(str cell)
Definition parser.py:432
str _version_target(str version_id)
Definition parser.py:564
str|None _phase_for_item(str item_id, set[str] phase_codes)
Definition parser.py:525
tuple[ParsedDependency,...] _parse_dependencies(str text, set[str] item_ids)
Definition parser.py:361
str _strip_markdown(str|None value)
Definition parser.py:575
str _era_id(str version_id)
Definition parser.py:256
tuple[ParsedPhase,...] _parse_phases(str text, str version_id)
Definition parser.py:281
str canonical_record_hash(str table, str natural_key, dict[str, object] fields)
Definition parser.py:226