orchestrata_ultimate.knowledge_graph.adr_parser
Reference › orchestrata_ultimate › knowledge_graph › adr_parser
A pure text-to-dataclass parser for the ADR corpus (ontology phase 2). parse_adrs reads the ## ADR-NNN: Title sections of a project's decisions.md wiki page and returns a list of frozen ParsedAdr records: number, title, status, date, context, decision, alternatives, consequences, plus the derived register and padded_id. parse_adr_file parses one standalone ADR document of the form docs/adr/NNNN-kebab-title.md into a single record. Only the recognised body bullets (status, date, context, decision, alternatives and consequences) are read; other bullets are ignored. Helpers normalise the status to its canonical leading token among Accepted, Proposed, Rejected, Deprecated and Superseded (_normalize_status), fold raw markdown blocks into paragraph text (_fold_block) and classify the heading digits into an ADR register (_classify_register, ADR-0047). The module does no I/O. Its consumers are the scripts sync_architecture_to_graph, which feeds the records to architecture_writer, and check_adr_graph_sync.
At a glance
| Source | src/orchestrata_ultimate/knowledge_graph/adr_parser.py |
| Lines of code | 505 |
| Classes | 1 |
| Functions | 5 |
| Imports (internal) | none |
| Imported by | scripts.check_adr_graph_sync, scripts.sync_architecture_to_graph |
| Tests that import it | tests/knowledge_graph/test_adr_parser.py, tests/knowledge_graph/test_architecture_writer_status_line.py, tests/scripts/test_check_adr_graph_sync.py, tests/scripts/test_check_adr_graph_sync_bodies.py, tests/scripts/test_sync_adrs_pointer_entries.py, tests/scripts/test_sync_first_capture.py, tests/scripts/test_sync_rebaseline_log_line.py, tests/scripts/test_sync_rebaseline_under_authority.py |
Class diagram
Dependency diagram
API
Constants
| Name | Type | Value |
|---|---|---|
_BULLET_FIELDS |
frozenset[str] |
frozenset({'status', 'date', 'context', 'decision', 'alternatives', 'consequenc… |
_STATUS_TOKENS |
tuple[str, ...] |
('Superseded', 'Deprecated', 'Accepted', 'Proposed', 'Rejected') |
Classes
ParsedAdr (dataclass)
Decorators: dataclass(frozen=True) · Line 167
One parsed ## ADR-NNN: Title section.
| Attribute | Type | Default |
|---|---|---|
number |
int |
|
title |
str |
|
status |
str |
|
date |
str |
|
context |
str |
|
decision |
str |
|
alternatives |
str |
|
consequences |
str |
|
register |
str |
|
padded_id |
str |
Functions
| Function | Signature | Summary |
|---|---|---|
_classify_register |
_classify_register(digits: str) -> str |
Classify a heading's digit string into its ADR register (ADR-0047). |
_normalize_status |
_normalize_status(raw: str) -> str |
Reduce an annotated status to its canonical leading token. |
_fold_block |
_fold_block(text: str) -> str |
Fold a raw markdown text block into paragraph text. |
parse_adrs |
parse_adrs(markdown: str) -> list[ParsedAdr] |
Extract every ## ADR-NNN: Title section from markdown. |
parse_adr_file |
parse_adr_file(markdown: str) -> ParsedAdr |
Parse ONE docs/adr/NNNN-kebab-title.md document into a :class:ParsedAdr. |
Source
"""Pure text -> dataclass parser for the ADR corpus (wave ontology-phase2).
Parses the ``## ADR-NNN: Title`` sections of the knowledge-wiki's per-project
``decisions.md`` page (e.g.
``knowledge-wiki/projects/orchestrata-ultimate/decisions.md``) into
:class:`ParsedAdr` records that ``scripts/sync_architecture_to_graph.py``
feeds into :meth:`ArchitectureWriter.upsert_architecture_decision`.
Each section is shaped::
## ADR-001: Memgraph as Semantic Graph Backend
- **Status:** Accepted
- **Date:** 2026-07-07
- **Context:** ...
- **Decision:** ...
- **Alternatives:** ... (optional -- not every ADR records one)
- **Consequences:** ...
Only the five body bullets (Status/Date/Context/Decision/Alternatives/
Consequences) are recognized; every other bullet is a Nygard-template
optional field this module has no need for, and is silently ignored. Any
prose before, between, or after ``## ADR-*`` sections (front matter, the page
banner, non-ADR ``##`` headings) is ignored entirely -- only real ADR
sections are extracted.
A bullet's value may wrap across several physical source lines (the writer
hand-wraps long Context/Decision/Consequences prose at the editor's line
length, e.g. ``decisions.md`` ADR-019/ADR-020) -- those indented continuation
lines are folded back into the bullet's single logical value, joined with a
single space, exactly the way Markdown itself treats a "loose" list item's
wrapped lines as one paragraph. This is line-*unwrapping*, not reflow: word
order and content are preserved verbatim, nothing is re-wrapped to a new
width. A blank line (or the next bullet/heading) ends the continuation.
The corpus contains a SECOND section shape (ADR-009..ADR-019): a single
inline meta line ``**Date:** 2026-07-08 · **Status:** Accepted`` followed by
free-prose paragraphs instead of the field bullets. For those sections the
meta line supplies ``date``/``status`` and the whole prose body is captured
into ``decision`` (it IS the decision record), with intra-paragraph wrapping
folded exactly like bullet continuations and paragraph breaks preserved as
``\n\n``. ``context``/``alternatives``/``consequences`` stay ``""``.
``status`` is NORMALIZED to its leading canonical token (``Accepted`` /
``Proposed`` / ``Rejected`` / ``Superseded`` / ``Deprecated``): the corpus
annotates statuses ("Accepted, live", "Accepted (session 226)."), and the
ruling-1 immutability guard in ``ArchitectureWriter`` keys on the EXACT
string ``"Accepted"`` -- an annotated variant would silently dodge the
guard. Unrecognized status text is preserved verbatim.
This module does no file I/O -- callers read the markdown text themselves
(``scripts/sync_architecture_to_graph.py`` resolves the ``decisions.md`` path
at call time, per project doctrine, and passes its contents in here).
``parse_adr_file`` (below) parses a DIFFERENT, later corpus shape: the
one-ADR-per-file records under ``docs/adr/NNNN-kebab-title.md`` (format
declared in ``docs/adr/README.md``: ``Status · Date · Context · Decision ·
Alternatives rejected · Consequences · Provenance``). That corpus uses a
level-1 ``# ADR-NNNN: Title`` (or ``# ADR-NNNN — Title``) heading, a
metadata bullet block shaped ``- **Field**: value`` (colon OUTSIDE the bold,
the inverse of this file's ``## ADR-NNN`` bullet shape), and free-prose
``## Context`` / ``## Decision`` / ``## Alternatives rejected`` /
``## Consequences`` sections rather than inline bullets. It is a sibling
parser, not a generalization of ``parse_adrs`` -- the two shapes differ in
heading level, number width, and bullet-colon position, so unifying them
into one regex set would risk silently breaking the working ``decisions.md``
path. See ``parse_adr_file``'s own docstring for its folding policy.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
_HEADING_RE = re.compile(r"^##\s+ADR-([^:\s][^:]*):\s*(.*)$")
_BULLET_RE = re.compile(r"^-\s+\*\*([A-Za-z][A-Za-z ]*):\*\*\s?(.*)$")
# The ADR-009..019 inline shape: **Date:** 2026-07-08 · **Status:** Accepted
_META_LINE_RE = re.compile(
r"^\*\*Date:\*\*\s*(?P<date>.*?)\s*·\s*\*\*Status:\*\*\s*(?P<status>.*)$"
)
# The only five body bullets this parser recognizes -- everything else
# (e.g. a stray "- **Deciders:**" some future ADR might add) is ignored.
_BULLET_FIELDS: frozenset[str] = frozenset(
{"status", "date", "context", "decision", "alternatives", "consequences"}
)
# Canonical lifecycle tokens: an annotated corpus status ("Accepted, live",
# "Accepted (session 226).") normalizes to its leading token so the ruling-1
# guard (exact-match on "Accepted") always engages. Longest-first so a
# future "AcceptedX" never half-matches.
_STATUS_TOKENS: tuple[str, ...] = (
"Superseded",
"Deprecated",
"Accepted",
"Proposed",
"Rejected",
)
def _classify_register(digits: str) -> str:
"""Classify a heading's digit string into its ADR register (ADR-0047).
``"factory"`` for a 4-digit heading id (``docs/adr/NNNN-*.md`` and its
wiki mirror); ``"project"`` for a 1-3 digit one (the wiki's own,
pre-factory register). The heading's raw digit string IS the identity
(``padded_id``) -- this only classifies its width.
"""
return "factory" if len(digits) == 4 else "project"
def _normalize_status(raw: str) -> str:
"""Reduce an annotated status to its canonical leading token.
Unrecognized text is returned verbatim (never silently coerced).
"""
text = raw.strip()
for token in _STATUS_TOKENS:
if text == token or text.startswith((f"{token} ", f"{token},", f"{token}.")):
return token
return text
# ---------------------------------------------------------------------------
# docs/adr/*.md -- the one-ADR-per-file corpus (see module docstring).
# ---------------------------------------------------------------------------
# "# ADR-0001: Title" or "# ADR-0037 — Title" (em dash variant, ADR-0037).
# The number group is permissive ([^:—] rather than \d+), matching
# _HEADING_RE's design above: a non-numeric "number" (e.g. "ADR-abcd") must
# still MATCH the heading shape and reach the int() conversion below, so the
# "no parseable number" error path stays reachable instead of being dead
# code a stricter \d+ would make unhittable.
_FILE_HEADING_RE = re.compile(r"^#\s+ADR-([^:\s—][^:—]*)\s*(?::|—)\s*(.*)$")
# "- **Field**: value" -- colon OUTSIDE the bold (inverse of _BULLET_RE).
_FILE_LIST_BULLET_RE = re.compile(r"^-\s+\*\*([A-Za-z][A-Za-z ]*)\*\*:\s*(.*)$")
# "**Field:** value" -- bare bold-colon line with no leading "- " (ADR-0037's
# Status/Date lines).
_FILE_BARE_META_RE = re.compile(r"^\*\*([A-Za-z][A-Za-z ]*):\*\*\s*(.*)$")
# "Status: **Accepted** (annotation) · Date: 2026-10-06" -- the plain-text
# one-line form (ADR-0073 and later): no bullet, no bold label, the Date
# riding the same line after a middle dot.
_FILE_STATUS_LINE_RE = re.compile(r"^Status:\s*(.*?)(?:\s+·\s+Date:\s*(.*))?$")
_FILE_SECTION_RE = re.compile(r"^##\s+(.*)$")
def _fold_block(text: str) -> str:
"""Fold a raw markdown text block into paragraph text.
Blank-line-separated chunks become paragraphs (joined ``\n\n``); within
a chunk, physical lines join with a single space -- the same
hand-wrap-unwrapping contract ``parse_adrs`` documents above, restated
as a block operation instead of an incremental line-scan.
"""
stripped = text.strip("\n")
if not stripped:
return ""
chunks = re.split(r"\n\s*\n", stripped)
paragraphs = [
" ".join(line.strip() for line in chunk.splitlines() if line.strip())
for chunk in chunks
]
return "\n\n".join(p for p in paragraphs if p)
@dataclass(frozen=True)
class ParsedAdr:
"""One parsed ``## ADR-NNN: Title`` section.
``alternatives`` defaults to ``""`` -- not every recorded ADR names one.
ADR-0047: this project has TWO independent ADR registers that both
number their entries starting at 1 -- the factory register
(``docs/adr/NNNN-*.md``, always 4-digit) and the project register (the
wiki's own ``ADR-NNN`` sections, 1-3 digit). ``number`` alone cannot
disambiguate them (ADR-001 and ADR-0001 both have ``number == 1``), so
every ``ParsedAdr`` also carries ``register`` (``"factory"`` for a
4-digit heading id, ``"project"`` for a 1-3 digit one) and ``padded_id``
(the heading's digit string verbatim, e.g. ``"0001"`` / ``"001"``) --
together, ``(register, padded_id)`` is the real identity. ``number``
stays for callers that only need the integer.
"""
number: int
title: str
status: str
date: str
context: str
decision: str
alternatives: str
consequences: str
register: str
padded_id: str
def parse_adrs(markdown: str) -> list[ParsedAdr]:
"""Extract every ``## ADR-NNN: Title`` section from ``markdown``.
Deterministic and byte-stable: calling this twice on the same text
yields equal results. Non-ADR content (front matter, page banners, other
``##`` headings) is skipped over, never raising.
Raises:
ValueError: a line matches the ``## ADR-...:`` heading shape but the
``NNN`` portion is not a parseable integer -- names the offending
heading line.
"""
lines = markdown.splitlines()
adrs: list[ParsedAdr] = []
i = 0
n = len(lines)
while i < n:
heading = _HEADING_RE.match(lines[i])
if heading is None:
i += 1
continue
number_text, title = heading.group(1).strip(), heading.group(2).strip()
try:
number = int(number_text)
except ValueError as exc:
raise ValueError(
f"Malformed ADR heading (no parseable number): {lines[i]!r}"
) from exc
register = _classify_register(number_text)
i += 1
fields: dict[str, list[str]] = {name: [] for name in _BULLET_FIELDS}
current: str | None = None
meta_date: str | None = None
meta_status: str | None = None
# Prose paragraphs (the ADR-009..019 shape): list of folded
# paragraphs, each itself a list of physical lines.
prose: list[list[str]] = []
while i < n:
raw = lines[i]
if _HEADING_RE.match(raw) or raw.lstrip().startswith("## "):
break # next ADR section, or an unrelated ## heading
stripped = raw.strip()
# Newer bulleted entries write Date and Status on ONE dashed
# line ("- **Date:** … · **Status:** …"). The bullet branch would
# swallow it whole into the `date` field and, worse, leave
# `current="date"` so every following dashed prose line is folded
# into `date` too. Strip the dash and let the meta regex parse it
# (it expects exactly the undashed shape).
if stripped.startswith("- "):
meta = _META_LINE_RE.match(stripped[2:])
if meta is not None:
meta_date = meta.group("date").strip()
meta_status = meta.group("status").strip()
current = None
i += 1
continue
meta = _META_LINE_RE.match(stripped)
if meta is not None:
meta_date = meta.group("date").strip()
meta_status = meta.group("status").strip()
current = None
i += 1
continue
bullet = _BULLET_RE.match(raw)
if bullet is not None:
label = bullet.group(1).strip().lower()
if label in _BULLET_FIELDS:
current = label
fields[current].append(bullet.group(2).strip())
i += 1
continue
# Unknown-label bullet (e.g. "- **Full record:** …"): keep
# its text as body prose so pointer detection and the register
# summary stay intact; do not let it poison `current`.
current = None
if prose and i > 0 and lines[i - 1].strip() != "":
prose[-1].append(stripped)
else:
prose.append([stripped])
i += 1
continue
if stripped == "":
current = None
i += 1
continue
if current is not None:
# Continuation line of the current bullet's wrapped prose.
fields[current].append(stripped)
else:
# Free-prose body line (meta-line sections): start a new
# paragraph after a blank line, else fold into the current one.
if prose and i > 0 and lines[i - 1].strip() != "":
prose[-1].append(stripped)
else:
prose.append([stripped])
i += 1
status_raw = " ".join(fields["status"]) or (meta_status or "")
date = " ".join(fields["date"]) or (meta_date or "")
prose_text = "\n\n".join(" ".join(par) for par in prose) if prose else ""
decision = " ".join(fields["decision"])
context = " ".join(fields["context"])
if not decision and prose_text:
# Unstructured section: the prose IS the decision record.
decision = prose_text
elif prose_text:
# Mixed shape (bullet fields + free prose, e.g. "- **Decision:**"
# with "- **Measured:**" / "Full record:" narrative): the prose is
# context — never drop it, or pointer detection loses the
# "Full record:" line and the register summary vanishes.
context = f"{context} {prose_text}".strip() if context else prose_text
adrs.append(
ParsedAdr(
number=number,
title=title,
status=_normalize_status(status_raw),
date=date,
context=context,
decision=decision,
alternatives=" ".join(fields["alternatives"]),
consequences=" ".join(fields["consequences"]),
register=register,
padded_id=number_text,
)
)
return adrs
def parse_adr_file(markdown: str) -> ParsedAdr:
"""Parse ONE ``docs/adr/NNNN-kebab-title.md`` document into a
:class:`ParsedAdr`.
Shape (``docs/adr/README.md``)::
# ADR-0001: Title
- **Status**: Accepted
- **Date**: 2026-07-23
- **Spec**: SPEC-033 §3 (ignored -- not a ParsedAdr field)
- **Supersedes**: — (ignored here; the caller reads
the ADR's own PROSE for a real
supersession, per project doctrine
-- this bullet is not trusted alone)
## Context
...
## Decision
...
## Alternatives rejected
...
## Consequences
...
## Provenance
...
``status`` is normalized via :func:`_normalize_status`, exactly like
``parse_adrs`` -- an annotated status (e.g. ADR-0038's ``"Accepted (de
facto, by commit ...)"``) must still key the ruling-1 immutability guard
on the exact string ``"Accepted"``.
``date`` is kept VERBATIM (not normalized) -- this is intentional and is
where a reconstructed record's provenance nuance survives: ADR-0038's
date bullet reads ``"2026-07-06 (the decision, as executed) · this
record written 2026-08-19"``, and that full annotation is preserved in
the ``date`` field rather than collapsed to a single ISO date.
Any text between the metadata bullets and the first ``## `` heading
(e.g. ADR-0038's "This is a reconstructed ADR" callout) is prepended to
``context``. A ``## `` heading that is not Context/Decision/
Alternatives*/Consequences (e.g. ``## Provenance``, or a one-off custom
heading like ADR-0038's ``## What is recorded, and what is not``) has no
slot in :class:`ParsedAdr` -- rather than dropping it, this parser folds
it into ``consequences``, prefixed with ``### <Heading>`` so the origin
stays visible in the stored prose. This is a parser-level folding
choice, not a graph-schema change -- no new field is invented.
Raises:
ValueError: the first non-blank line is not a parseable
``# ADR-NNNN`` heading.
"""
lines = markdown.splitlines()
n = len(lines)
i = 0
while i < n and lines[i].strip() == "":
i += 1
if i >= n:
raise ValueError("Empty ADR document")
heading = _FILE_HEADING_RE.match(lines[i])
if heading is None:
raise ValueError(
f"Malformed ADR file (no '# ADR-NNNN: Title' heading): {lines[i]!r}"
)
try:
number = int(heading.group(1))
except ValueError as exc:
raise ValueError(
f"Malformed ADR heading (no parseable number): {lines[i]!r}"
) from exc
padded_id = heading.group(1).strip()
title = heading.group(2).strip()
i += 1
# A blank line separates the heading from the metadata block in every
# observed document -- skip it before Phase A starts, else the first
# blank-line check below would immediately (and wrongly) end the block.
while i < n and lines[i].strip() == "":
i += 1
# --- Phase A: metadata bullet block (Status/Date/... until blank/##) ---
status_parts: list[str] = []
date_parts: list[str] = []
current_label: str | None = None
while i < n:
raw = lines[i]
stripped = raw.strip()
if stripped == "":
i += 1
break
if _FILE_SECTION_RE.match(stripped):
break # no blank line before the first ## section
status_line = _FILE_STATUS_LINE_RE.match(stripped)
if status_line is not None:
current_label = "status"
status_parts.append(status_line.group(1).replace("**", "").strip())
if status_line.group(2):
date_parts.append(status_line.group(2).strip())
i += 1
continue
bullet = _FILE_LIST_BULLET_RE.match(raw) or _FILE_BARE_META_RE.match(stripped)
if bullet is not None:
label = bullet.group(1).strip().lower()
value = bullet.group(2).strip()
current_label = label
if label == "status":
status_parts.append(value)
elif label == "date":
date_parts.append(value)
i += 1
continue
# Continuation line of the currently-open bullet.
if current_label == "status":
status_parts.append(stripped)
elif current_label == "date":
date_parts.append(stripped)
# else: continuation of an unrecognized bullet (Spec/Supersedes/
# Amends/Relates to) -- discarded, matching parse_adrs' policy of
# silently ignoring non-body bullets.
i += 1
# --- Phase B: preamble prose before the first ## heading ---
preamble_start = i
while i < n and _FILE_SECTION_RE.match(lines[i].strip()) is None:
i += 1
preamble = _fold_block("\n".join(lines[preamble_start:i]))
# --- Phase C: ## sections ---
blocks: dict[str, list[str]] = {
"context": [],
"decision": [],
"alternatives": [],
"consequences": [],
}
while i < n:
# Phase B only stops here when lines[i] matches _FILE_SECTION_RE (or
# i == n, which the loop guard above already excludes), and every
# iteration below advances i to the same stopping condition before
# looping again -- lines[i] is guaranteed to be a "## " heading.
section = _FILE_SECTION_RE.match(lines[i].strip())
assert section is not None
heading_text = section.group(1).strip()
i += 1
body_start = i
while i < n and _FILE_SECTION_RE.match(lines[i].strip()) is None:
i += 1
body = "\n".join(lines[body_start:i])
lower = heading_text.lower()
if lower.startswith("context"):
target = "context"
elif lower.startswith("decision"):
target = "decision"
elif lower.startswith("alternatives"):
target = "alternatives"
elif lower.startswith("consequences"):
target = "consequences"
else:
# No dedicated slot (e.g. Provenance) -- fold into consequences,
# labeled so the origin is traceable.
target = "consequences"
blocks[target].append(f"### {heading_text}")
folded = _fold_block(body)
if folded:
blocks[target].append(folded)
context = "\n\n".join(p for p in [preamble] + blocks["context"] if p)
return ParsedAdr(
number=number,
title=title,
status=_normalize_status(" ".join(status_parts)),
date=" ".join(date_parts),
context=context,
decision="\n\n".join(blocks["decision"]),
alternatives="\n\n".join(blocks["alternatives"]),
consequences="\n\n".join(blocks["consequences"]),
register="factory",
padded_id=padded_id,
)