7Markdown parser for ROADMAP registry ingest.
11Convert the four ROADMAP markdown artifact families into deterministic
12records for the registry v0.3 roadmap tables.
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.
25 L3 - extracted roadmap records
26 L4 - per-row markdown parsing details
30- Parsing is read-only and never mutates registry or filesystem state.
31- Output records are deterministic for identical markdown bytes.
35- oc_chat.registry.canonical (stable JSON hashing)
36- oc_diagnostics (structured debug logging via _dbg)
38from __future__
import annotations
40from dataclasses
import dataclass
43from pathlib
import Path
45from oc_diagnostics
import _dbg
49_dbg(
"OC_CHAT.MAIN",
"module",
"loaded", 4)
53 """Raised when a roadmap file is not parseable enough for ingest."""
56@dataclass(frozen=
True)
61 parent_phase_code: str |
None =
None
62 priority_label: str |
None =
None
63 parallel_track: bool =
False
64 target_version: str |
None =
None
67@dataclass(frozen=True)
71 description: str |
None
75@dataclass(frozen=True)
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
109@dataclass(frozen=True)
116@dataclass(frozen=True)
122 query_params_json: str |
None =
None
125@dataclass(frozen=True)
135 phases: tuple[ParsedPhase, ...]
136 tracks: tuple[ParsedTrack, ...]
137 items: tuple[ParsedItem, ...]
138 dependencies: tuple[ParsedDependency, ...]
139 render_sections: tuple[ParsedRenderSection, ...]
142VERSION_RE = re.compile(
r"\b(?:v)?(?P<version>\d+\.\d+)\b")
143H1_RE = re.compile(
r"^#\s+(?P<title>.+?)\s*$", re.MULTILINE)
145HEADER_RE = re.compile(
r"^\*\*(?P<name>Version|Created|Last updated):\*\*\s*(?P<value>.+?)\s*$")
146PHASE_HEADING_RE = re.compile(
148 r"^#\s+PHASE\s+(?P<code>M\d+(?:\.\d+[a-z]?|[a-z])?)\s*(?:[-\u2014]\s*(?P<label>.+?))?\s*$",
151ITEM_ID_RE = re.compile(
154 r"M\d+(?:\.\d+(?:\.[a-z0-9]+)?[a-z]?|[a-z])?|"
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]?"
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")
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")
172 (
"critical", (
"\U0001f534",
"critical",
"urgent")),
173 (
"high", (
"\U0001f7e0",
"high")),
174 (
"medium", (
"\U0001f7e1",
"medium")),
175 (
"lower", (
"\U0001f7e2",
"lower",
"low",
"nice-to-have")),
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),
191 """Parse one ROADMAP markdown artifact into normalized records."""
194 text = source.read_text(encoding=
"utf-8")
199 if version_id
is None:
204 last_updated_at = header.get(
"last updated")
or created_at
206 items =
_parse_items(text, file_kind, version_id, phases)
212 version_id=version_id,
214 content_sha256=hashlib.sha256(text.encode(
"utf-8")).hexdigest(),
216 created_at=created_at,
217 last_updated_at=last_updated_at,
219 tracks=DEFAULT_TRACKS,
221 dependencies=dependencies,
222 render_sections=render_sections,
227 """Return the stable ingest hash for one semantic record."""
229 payload = {
"table": table,
"natural_key": natural_key,
"fields": fields}
230 return hashlib.sha256(canonical_json(payload).encode(
"utf-8")).hexdigest()
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-"):
247 match = VERSION_RE.search(path.stem)
250 match = VERSION_RE.search(header.get(
"version",
""))
253 return f
"v{match.group('version')}"
257 major = version_id.removeprefix(
"v").split(
".", 1)[0]
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()
271 match = H1_RE.search(text)
274 return match.group(
"title").strip()
278 return f
"{version_id}-source"
282 phases: dict[str, ParsedPhase] = {}
284 for line
in text.splitlines():
285 match = PHASE_HEADING_RE.match(line.strip())
289 code = match.group(
"code")
296 parallel_track=code ==
"M11",
302 if code
is None or code
in phases:
312 parallel_track=code ==
"M11",
315 return tuple(sorted(phases.values(), key=
lambda item: item.sort_order))
322 phases: tuple[ParsedPhase, ...],
323) -> tuple[ParsedItem, ...]:
324 phase_codes = {phase.phase_code
for phase
in phases}
325 seen: dict[str, ParsedItem] = {}
336 track_kind =
_track_kind(item_id, file_kind, phase_code)
343 track_kind=track_kind,
347 phase_code=phase_code,
352 previous = seen.get(item_id)
354 seen[item_id] = candidate
355 return tuple(sorted(seen.values(), key=
lambda item: (item.sort_order, item.item_id)))
361) -> tuple[ParsedDependency, ...]:
362 dependencies: set[ParsedDependency] = set()
368 if "depend" not in cell.lower()
and "gate" not in cell.lower():
370 for target
in item_ids:
371 if target == item_id:
373 if re.search(rf
"(?<![A-Za-z0-9_.-]){re.escape(target)}(?![A-Za-z0-9_.-])", cell):
375 return tuple(sorted(dependencies, key=
lambda item: (item.from_item_id, item.to_item_id, item.kind)))
381) -> tuple[ParsedRenderSection, ...]:
382 sections: list[ParsedRenderSection] = []
384 for line
in text.splitlines():
385 stripped = line.strip()
386 if not stripped.startswith(
"## "):
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"
402 query_kind=query_kind,
406 return tuple(sections)
410 section_path=
"## Roadmap",
412 query_kind=
"prose_only",
418 rows: list[list[str]] = []
419 for line
in text.splitlines():
420 stripped = line.strip()
421 if not (stripped.startswith(
"|")
and stripped.endswith(
"|")):
423 cells = [cell.strip()
for cell
in stripped.strip(
"|").split(
"|")]
433 text = cell.replace(
":",
"").replace(
"-",
"").strip()
440 if ITEM_ID_RE.match(cleaned):
448 if PHASE_CODE_RE.match(cleaned):
454 for index, cell
in enumerate(cells):
457 if index + 1 < len(cells):
458 return cells[index + 1]
463 for index, cell
in enumerate(cells):
466 for candidate
in cells[index + 1:]:
477def _row_prose(cells: list[str], item_id: str, title: str) -> str |
None:
484 text =
" | ".join(value
for value
in values
if value)
489 haystack =
" ".join(cells).lower()
490 if any(marker.lower()
in haystack
for marker
in BLOCKED_MARKERS):
492 if any(marker.lower()
in haystack
for marker
in DONE_MARKERS):
494 if any(marker.lower()
in haystack
for marker
in PROGRESS_MARKERS):
496 if any(marker.lower()
in haystack
for marker
in TODO_MARKERS):
498 if "deferred" in haystack:
504 haystack =
" ".join(cells).lower()
505 for priority, markers
in PRIORITY_MARKERS:
506 if any(marker.lower()
in haystack
for marker
in markers):
512 lowered = text.lower()
513 if FRACTION_RE.search(lowered):
515 marker_groups = DONE_MARKERS + PROGRESS_MARKERS + BLOCKED_MARKERS + TODO_MARKERS
516 if any(marker.lower()
in lowered
for marker
in marker_groups):
519 marker.lower()
in lowered
520 for _, markers
in PRIORITY_MARKERS
521 for marker
in markers
526 if item_id
in phase_codes:
528 if not item_id.startswith(
"M"):
530 parts = item_id.split(
".")
531 candidates = [parts[0]]
533 candidates.insert(0,
".".join(parts[:2]))
534 for candidate
in candidates:
535 if candidate
in phase_codes:
537 return parts[0]
if parts[0].startswith(
"M")
else None
540def _track_kind(item_id: str, file_kind: str, phase_code: str |
None) -> str:
541 if item_id.startswith(
"V"):
543 if file_kind ==
"roadmap_exec_order" and phase_code
is None:
545 if phase_code
is not None:
547 if item_id.startswith((
"FS-",
"P.",
"G.",
"B.",
"O",
"L",
"U",
"X")):
554 return code.split(
".", 1)[0]
559 if "." not in item_id
or not item_id.startswith(
"M"):
561 return item_id.rsplit(
".", 1)[0]
570 1
if item.prose
else 0,
571 len(item.title) + len(item.prose
or ""),
579 text = re.sub(
r"`([^`]+)`",
r"\1", text)
580 text = re.sub(
r"\[([^\]]+)\]\([^)]+\)",
r"\1", text)
581 text = text.replace(
"**",
"").replace(
"__",
"").strip()
str _status_from_cells(list[str] cells)
str|None _cell_after_phase_code(list[str] cells, str code)
str|None _phase_code_from_cells(list[str] cells)
dict[str, str] _header_values(str text)
tuple[int, int] _item_score(ParsedItem item)
str _track_kind(str item_id, str file_kind, str|None phase_code)
tuple[ParsedRenderSection,...] _parse_render_sections(str text, str file_kind)
str|None _row_prose(list[str] cells, str item_id, str title)
str _fallback_timestamp(str version_id)
str|None _version_id(Path path, str text)
list[list[str]] _iter_markdown_rows(str text)
str|None _item_id_from_cells(list[str] cells)
str|None _priority_from_cells(list[str] cells)
ParsedRoadmap parse_roadmap_file(str|Path path)
bool _looks_status_or_priority(str text)
str|None _parent_phase_code(str code)
str _file_kind(Path path)
str|None _parent_item_id(str item_id)
str _item_title(list[str] cells, str item_id)
tuple[ParsedItem,...] _parse_items(str text, str file_kind, str version_id, tuple[ParsedPhase,...] phases)
bool _is_separator_cell(str cell)
str _version_target(str version_id)
str|None _phase_for_item(str item_id, set[str] phase_codes)
tuple[ParsedDependency,...] _parse_dependencies(str text, set[str] item_ids)
str _strip_markdown(str|None value)
str _era_id(str version_id)
tuple[ParsedPhase,...] _parse_phases(str text, str version_id)
str canonical_record_hash(str table, str natural_key, dict[str, object] fields)