Files
freemo c65e8a5285
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 16s
CI / lint (pull_request) Successful in 17s
CI / quality (pull_request) Successful in 29s
CI / typecheck (pull_request) Successful in 59s
CI / security (pull_request) Successful in 59s
CI / unit_tests (pull_request) Successful in 3m9s
CI / integration_tests (pull_request) Successful in 3m34s
CI / e2e_tests (pull_request) Successful in 3m54s
CI / docker (pull_request) Successful in 55s
CI / coverage (pull_request) Successful in 6m1s
CI / build (push) Successful in 15s
CI / lint (push) Successful in 16s
CI / quality (push) Successful in 27s
CI / typecheck (push) Successful in 38s
CI / benchmark-regression (push) Has been skipped
CI / security (push) Successful in 48s
CI / unit_tests (push) Successful in 3m14s
CI / integration_tests (push) Successful in 3m30s
CI / docker (push) Successful in 56s
CI / e2e_tests (push) Successful in 4m43s
CI / coverage (push) Successful in 6m1s
CI / benchmark-publish (push) Successful in 19m50s
CI / benchmark-regression (pull_request) Successful in 37m17s
feat(resource): add cloud infrastructure resources
Implement cloud resource types (aws, gcp, azure) with credential
fields, region/tenant metadata, and stubbed sandbox strategies.
Credential resolution uses environment variables and profile names
with no secrets logged.

Key changes:
- Add CloudResourceHandler with aws/gcp/azure type definitions
- Add credential resolution from env vars and profile names
- Add stubbed sandbox strategies (validate config, raise NotImplementedError)
- Register cloud types in bootstrap_builtin_types
- Credential masking via existing redaction patterns
- Add Behave BDD tests, Robot integration tests, ASV benchmarks

ISSUES CLOSED: #343
2026-03-17 13:14:37 -04:00

724 lines
24 KiB
Python

"""MkDocs hook for ADR page custom rendering.
Injects a custom header (dates, status badge, tier) and a hidden timeline
element into every ADR page. The companion ``adr-page.js`` script moves
the timeline into the left sidebar at runtime.
Also auto-generates the ADR inventory tables on the ``adr/index.md`` page
by scanning all ADR source files for their YAML front-matter.
"""
from __future__ import annotations
import logging
import re
from datetime import date, datetime
from pathlib import Path
from typing import Any
import yaml
log = logging.getLogger("mkdocs.hooks.adr")
# ── status definitions ──────────────────────────────────────────────────
VALID_STATUSES = {
"draft",
"proposed",
"accepted",
"rejected",
"superseded",
"deprecated",
}
STATUS_LABELS = {
"draft": "Draft",
"proposed": "Proposed",
"accepted": "Accepted",
"rejected": "Rejected",
"superseded": "Superseded",
"deprecated": "Deprecated",
}
# ── SVG icons (Material Design, same as log4brains) ────────────────────
ICON_CALENDAR = (
'<svg class="adr-icon" viewBox="0 0 24 24" aria-hidden="true">'
'<path d="M17 12h-5v5h5v-5zM16 1v2H8V1H6v2H5c-1.11 0-1.99.9-1.99 2'
"L3 19c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2h-1V1h-2z"
'm3 18H5V8h14v11z"/></svg>'
)
ICON_PERSON = (
'<svg class="adr-icon" viewBox="0 0 24 24" aria-hidden="true">'
'<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 '
'4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>'
)
# ── regex to match the old inline metadata block ────────────────────────
# Matches lines like: **Status:** Accepted \n**Date:** ... up to the
# blank line before ## Context (or the next heading).
_OLD_META_RE = re.compile(
r"^(\*\*Status:\*\*.*?\n)" # first metadata line
r"((?:\*\*[A-Za-z()]+:\*\*.*?\n)*)" # subsequent metadata lines
r"\s*\n", # trailing blank line
re.MULTILINE,
)
# ── helpers ─────────────────────────────────────────────────────────────
def _normalise_date(raw: Any) -> str:
"""Return a YYYY-MM-DD string from various input types."""
if isinstance(raw, date):
return raw.isoformat()
if isinstance(raw, datetime):
return raw.date().isoformat()
return str(raw).strip()
def _format_date_display(iso: str) -> str:
"""Convert '2026-02-16' to 'Feb 16, 2026'."""
try:
d = datetime.strptime(iso, "%Y-%m-%d")
return d.strftime("%b %d, %Y")
except (ValueError, TypeError):
return iso
def _normalise_people(raw: Any) -> str:
"""Return a comma-separated string of people from various input."""
if raw is None:
return ""
if isinstance(raw, list):
return ", ".join(str(p) for p in raw)
return str(raw).strip()
def _status_key(status: str) -> str:
"""Lowercase status key for CSS class names."""
return status.strip().lower()
def _is_adr_page(page: Any) -> bool:
"""Return True if the page is an individual ADR (not the index)."""
src = page.file.src_path # e.g. "adr/ADR-001-layered-architecture.md"
return src.startswith("adr/ADR-") and src.endswith(".md") and "index" not in src
def _is_adr_index(page: Any) -> bool:
"""Return True if the page is the ADR index page."""
return page.file.src_path == "adr/index.md"
def _extract_adr_number(page: Any) -> str | None:
"""Extract '001' from 'adr/ADR-001-layered-architecture.md'."""
m = re.search(r"ADR-(\d+)", page.file.src_path)
return m.group(1) if m else None
# ── HTML builders ───────────────────────────────────────────────────────
def _build_status_badge(status: str, extra_class: str = "") -> str:
key = _status_key(status)
label = STATUS_LABELS.get(key, status)
cls = f"adr-status-badge adr-status-{key}"
if extra_class:
cls += f" {extra_class}"
return f'<span class="{cls}">{label}</span>'
def _resolve_adr_link(adr_number: int, files: Any) -> str | None:
"""Find the relative URL for an ADR by its number."""
prefix = f"adr/ADR-{adr_number:03d}"
for f in files:
if f.src_path.startswith(prefix) and f.src_path.endswith(".md"):
return f.src_path.replace("adr/", "", 1) # relative to current adr/ dir
return None
def _build_header(
earliest_date: str,
last_date: str,
last_status: str,
tier: int | None,
tier_title: str,
superseded_by: int | None,
authors: str,
files: Any,
) -> str:
"""Build the subtitle-style header injected after the H1 title.
Line 1: Tier + Drafted date (earliest entry in status_history)
Line 2: Current status badge + date (+ superseded-by link when applicable)
Line 3: Authors (optional, from explicit front-matter field)
"""
earliest_display = _format_date_display(earliest_date)
last_display = _format_date_display(last_date)
badge = _build_status_badge(last_status)
status_key = _status_key(last_status)
# ── line 1: tier + drafted date ─────────────────────────────────
tier_part = ""
if tier is not None and tier_title:
tier_part = (
f'<span class="adr-tier-badge">Tier {tier}</span>'
f'<span class="adr-tier-title">{tier_title}</span>'
f'<span class="adr-label"> · </span>'
)
line1 = (
f'<div class="adr-header-line">'
f"{tier_part}"
f'<span class="adr-label">Drafted:</span> '
f'<span class="adr-value">{earliest_display}</span>'
f"</div>"
)
# ── line 2: current status + date (+ superseded-by link) ────────
superseded_part = ""
if superseded_by is not None and status_key == "superseded":
link = _resolve_adr_link(superseded_by, files)
if link:
superseded_part = (
f' <span class="adr-label">—</span> '
f'<span class="adr-label">superseded by</span> '
f'<a href="{link}">ADR-{superseded_by:03d}</a>'
)
else:
superseded_part = (
f' <span class="adr-label">'
f"— superseded by ADR-{superseded_by:03d}</span>"
)
line2 = (
f'<div class="adr-header-line">'
f'<span class="adr-label">Status:</span> '
f"{badge}"
f'<span class="adr-label"> as of </span>'
f'<span class="adr-value">{last_display}</span>'
f"{superseded_part}"
f"</div>"
)
# ── line 3: authors (optional) ──────────────────────────────────
line3 = ""
if authors:
line3 = (
f'<div class="adr-header-line">'
f'<span class="adr-label">Authors:</span> '
f'<span class="adr-value">{authors}</span>'
f"</div>"
)
return (
f'\n<div class="adr-header" markdown="0">\n'
f" {line1}\n"
f" {line2}\n"
f" {line3}\n"
f"</div>\n\n"
)
def _build_timeline(status_history: list) -> str:
"""Build the hidden timeline HTML that JS moves into the sidebar."""
entries_html = []
total = len(status_history)
for i, entry in enumerate(status_history):
entry_date = _normalise_date(entry[0])
entry_status = str(entry[1]).strip()
entry_people = _normalise_people(entry[2] if len(entry) > 2 else None)
status_key = _status_key(entry_status)
date_display = _format_date_display(entry_date)
is_last = i == total - 1
dot_cls = f"adr-timeline-dot adr-dot-{status_key}"
if is_last:
dot_cls += " adr-dot-current"
people_html = ""
if entry_people:
people_html = (
f'<div class="adr-timeline-person">{ICON_PERSON}{entry_people}</div>'
)
connector_line = '<div class="adr-timeline-line"></div>' if not is_last else ""
badge = _build_status_badge(entry_status)
entries_html.append(
f'<div class="adr-timeline-entry">\n'
f' <div class="adr-timeline-date-col">\n'
f' <span class="adr-timeline-date">{date_display}</span>\n'
f" </div>\n"
f' <div class="adr-timeline-connector">\n'
f' <div class="{dot_cls}"></div>\n'
f" {connector_line}\n"
f" </div>\n"
f' <div class="adr-timeline-content">\n'
f" {badge}\n"
f" {people_html}\n"
f" </div>\n"
f"</div>"
)
inner = "\n".join(entries_html)
return (
f'\n<div class="adr-timeline" style="display:none" markdown="0">\n'
f"{inner}\n"
f"</div>\n\n"
)
# ── Generated tail sections (Related ADRs + Acceptance) ────────────────
# Regex to strip leftover ## Related ADRs / ## Acceptance from the body
# (defensive — the migration should have removed them already).
_RELATED_SECTION_RE = re.compile(r"\n## Related ADRs\s*\n.*", re.DOTALL)
_ACCEPTANCE_SECTION_RE = re.compile(r"\n## Acceptance\s*\n.*", re.DOTALL)
def _build_related_adrs_section(
related_adrs: list[dict],
files: Any,
) -> str:
"""Generate the ``## Related ADRs`` markdown section from front-matter.
Each entry in *related_adrs* has ``number``, ``title``, and
``relationship``. The ADR link is resolved via the MkDocs *files*
collection so that MkDocs can validate and rewrite the URL.
"""
if not related_adrs:
return ""
lines = [
"## Related ADRs",
"",
"| ADR | Title | Relationship |",
"|-----|-------|-------------|",
]
for entry in related_adrs:
num = entry.get("number")
title = entry.get("title", "")
relationship = entry.get("relationship", "")
if num is None:
continue
# Resolve the file link via the files collection
link_path = _resolve_adr_link(int(num), files)
if link_path:
adr_col = f"[ADR-{int(num):03d}]({link_path})"
else:
adr_col = f"ADR-{int(num):03d}"
lines.append(f"| {adr_col} | {title} | {relationship} |")
return "\n".join(lines) + "\n"
def _build_acceptance_section(acceptance: dict) -> str:
"""Generate the ``## Acceptance`` markdown section from front-matter.
*acceptance* contains ``votes_for``, ``votes_against``, and
``abstentions`` — each a list of ``{voter, comment}`` dicts.
"""
if not acceptance:
return ""
def _votes_table(entries: list[dict]) -> str:
rows = [
"| Voter | Comment |",
"|-------|---------|",
]
for e in entries:
voter = e.get("voter", "")
comment = e.get("comment", "")
rows.append(f"| {voter} | {comment} |")
return "\n".join(rows)
votes_for = acceptance.get("votes_for", [])
votes_against = acceptance.get("votes_against", [])
abstentions = acceptance.get("abstentions", [])
parts = [
"## Acceptance",
"",
"### Votes For",
"",
_votes_table(votes_for),
"",
f"**Total: {len(votes_for)}**",
"",
"### Votes Against",
"",
_votes_table(votes_against),
"",
f"**Total: {len(votes_against)}**",
"",
"### Abstentions",
"",
_votes_table(abstentions),
"",
f"**Total: {len(abstentions)}**",
]
return "\n".join(parts) + "\n"
# ── ADR inventory (for the index page) ─────────────────────────────────
_INVENTORY_PLACEHOLDER = "<!-- ADR_INVENTORY -->"
_TIMELINE_PLACEHOLDER = "<!-- ADR_TIMELINE -->"
def _collect_adr_inventory(files: Any) -> list[dict]:
"""Scan all ADR source files and collect front-matter metadata.
Returns a list of dicts sorted by ``adr_number``, each containing
``number``, ``title``, ``tier``, ``status``, ``status_badge`` (HTML),
``filename``, and ``status_history`` (the raw list of transitions).
"""
adrs: list[dict] = []
for f in files:
src = f.src_path
if not src.startswith("adr/ADR-") or not src.endswith(".md"):
continue
try:
content = Path(f.abs_src_path).read_text(encoding="utf-8")
except OSError:
continue
fm_match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
if not fm_match:
continue
try:
meta = yaml.safe_load(fm_match.group(1)) or {}
except yaml.YAMLError:
continue
adr_number = meta.get("adr_number")
title = meta.get("title", "")
tier = meta.get("tier")
status_history = meta.get("status_history", [])
if adr_number is None or not status_history:
continue
last_status = str(status_history[-1][1]).strip()
# Relative link from adr/index.md to adr/ADR-NNN-xxx.md
filename = src.replace("adr/", "", 1)
adrs.append(
{
"number": int(adr_number),
"title": title,
"tier": tier,
"status": last_status,
"status_badge": _build_status_badge(last_status),
"filename": filename,
"status_history": status_history,
}
)
adrs.sort(key=lambda a: a["number"])
return adrs
def _generate_inventory_markdown(adrs: list[dict], tiers_config: dict) -> str:
"""Generate tier-grouped inventory tables as markdown.
Each tier produces an ``### Tier N — Title`` sub-heading followed by
the tier description and a table of ADRs with their number (linked),
title, and current status rendered as a coloured badge.
"""
# Group by tier
grouped: dict[int | None, list[dict]] = {}
for adr in adrs:
t = adr.get("tier")
grouped.setdefault(t, []).append(adr)
sections: list[str] = []
def _tier_table(tier_adrs: list[dict]) -> list[str]:
rows = [
"| ADR | Title | Status |",
"|-----|-------|--------|",
]
for adr in tier_adrs:
num_str = f"ADR-{adr['number']:03d}"
link = f"[{num_str}]({adr['filename']})"
rows.append(f"| {link} | {adr['title']} | {adr['status_badge']} |")
return rows
# Emit tiers in numeric order
for tier_num in sorted(t for t in grouped if t is not None):
tier_info = tiers_config.get(tier_num, {})
tier_title = (
tier_info.get("title", f"Tier {tier_num}")
if isinstance(tier_info, dict)
else str(tier_info)
)
tier_desc = (
tier_info.get("description", "") if isinstance(tier_info, dict) else ""
)
lines: list[str] = [f"### Tier {tier_num}{tier_title}", ""]
if tier_desc:
lines += [tier_desc, ""]
lines += _tier_table(grouped[tier_num])
sections.append("\n".join(lines))
# Handle ADRs without a tier (should not happen, but be defensive)
if None in grouped:
lines = ["### Uncategorized", ""]
lines += _tier_table(grouped[None])
sections.append("\n".join(lines))
return "\n\n".join(sections)
# ── SVG icon for ADR link in combined timeline ─────────────────────────
ICON_ADR = (
'<svg class="adr-icon" viewBox="0 0 24 24" aria-hidden="true">'
'<path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 '
"2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 "
'9H13z"/></svg>'
)
def _md_to_url(filename: str) -> str:
"""Convert ``ADR-001-layered-architecture.md`` to a directory URL.
MkDocs builds each ``.md`` file into ``<stem>/index.html``, so
relative links in raw HTML must use ``<stem>/`` rather than the
source ``.md`` extension.
"""
if filename.endswith(".md"):
return filename[:-3] + "/"
return filename
def _build_combined_timeline(adrs: list[dict]) -> str:
"""Build a unified timeline of every status transition across all ADRs.
Entries are sorted chronologically (then by ADR number for ties).
Instead of the person column used on individual pages, each entry
shows a clickable link to the originating ADR.
"""
# Flatten every status_history entry with its owning ADR metadata.
flat: list[dict] = []
for adr in adrs:
num = adr["number"]
title = adr["title"]
url = _md_to_url(adr["filename"])
label = f"ADR-{num:03d}: {title}"
for entry in adr.get("status_history", []):
entry_date = _normalise_date(entry[0])
entry_status = str(entry[1]).strip()
flat.append(
{
"date_iso": entry_date,
"status": entry_status,
"adr_number": num,
"adr_label": label,
"adr_url": url,
}
)
# Sort chronologically, then by ADR number for same-date entries.
flat.sort(key=lambda e: (e["date_iso"], e["adr_number"]))
total = len(flat)
entries_html: list[str] = []
for i, item in enumerate(flat):
status_key = _status_key(item["status"])
date_display = _format_date_display(item["date_iso"])
is_last = i == total - 1
dot_cls = f"adr-timeline-dot adr-dot-{status_key}"
if is_last:
dot_cls += " adr-dot-current"
badge = _build_status_badge(item["status"])
adr_link_html = (
f'<div class="adr-timeline-adr-link">'
f'{ICON_ADR}<a href="{item["adr_url"]}">{item["adr_label"]}</a>'
f"</div>"
)
connector_line = '<div class="adr-timeline-line"></div>' if not is_last else ""
entries_html.append(
f'<div class="adr-timeline-entry">\n'
f' <div class="adr-timeline-date-col">\n'
f' <span class="adr-timeline-date">{date_display}</span>\n'
f" </div>\n"
f' <div class="adr-timeline-connector">\n'
f' <div class="{dot_cls}"></div>\n'
f" {connector_line}\n"
f" </div>\n"
f' <div class="adr-timeline-content">\n'
f" {badge}\n"
f" {adr_link_html}\n"
f" </div>\n"
f"</div>"
)
inner = "\n".join(entries_html)
return (
f'\n<div class="adr-timeline adr-combined-timeline" markdown="0">\n'
f"{inner}\n"
f"</div>\n\n"
)
# ── MkDocs hook entry-point ────────────────────────────────────────────
def on_page_markdown(
markdown: str,
page: Any,
config: Any,
files: Any,
**kwargs: Any,
) -> str:
"""Transform ADR page markdown.
- **Index page** (``adr/index.md``): replaces the ``<!-- ADR_INVENTORY -->``
placeholder with auto-generated tier-grouped tables.
- **Individual ADR pages**: strips old metadata, injects header + timeline.
"""
# ── ADR index: auto-generate inventory + combined timeline ─────
if _is_adr_index(page):
needs_inventory = _INVENTORY_PLACEHOLDER in markdown
needs_timeline = _TIMELINE_PLACEHOLDER in markdown
if needs_inventory or needs_timeline:
tiers_config = config.get("extra", {}).get("adr_tiers", {})
adrs = _collect_adr_inventory(files)
if needs_inventory:
inventory = _generate_inventory_markdown(adrs, tiers_config)
markdown = markdown.replace(_INVENTORY_PLACEHOLDER, inventory)
if needs_timeline:
timeline = _build_combined_timeline(adrs)
markdown = markdown.replace(_TIMELINE_PLACEHOLDER, timeline)
return markdown
if not _is_adr_page(page):
return markdown
meta = page.meta or {}
status_history = meta.get("status_history")
if not status_history or not isinstance(status_history, list):
log.warning(
"ADR %s has no valid status_history in front-matter", page.file.src_path
)
return markdown
# ── derive dates and status ─────────────────────────────────────
# Earliest date = first entry in the status history
earliest_date = _normalise_date(status_history[0][0])
last_entry = status_history[-1]
last_date = _normalise_date(last_entry[0])
last_status = str(last_entry[1]).strip()
# ── authors (explicit optional front-matter field) ──────────────
authors = _normalise_people(meta.get("authors"))
# ── tier info ───────────────────────────────────────────────────
tier = meta.get("tier")
tier_title = ""
if tier is not None:
tiers_config = config.get("extra", {}).get("adr_tiers", {})
tier_info = tiers_config.get(tier, {})
if isinstance(tier_info, dict):
tier_title = tier_info.get("title", "")
# ── superseded_by ───────────────────────────────────────────────
superseded_by = meta.get("superseded_by")
# ── strip old inline metadata block ─────────────────────────────
markdown = _OLD_META_RE.sub("", markdown, count=1)
# ── generate the H1 from adr_number + title front-matter ────────
adr_number = meta.get("adr_number")
adr_title = meta.get("title")
if adr_number is not None and adr_title:
# Remove any existing H1 line from the markdown body
markdown = re.sub(r"^#\s+.+?\n+", "", markdown, count=1)
generated_h1 = f"# ADR-{int(adr_number):03d}: {adr_title}\n"
else:
# Fallback: keep whatever H1 already exists
h1_match = re.match(r"(#\s+.+?\n)", markdown)
generated_h1 = "" # leave existing H1 or use empty
# ── build header + timeline ─────────────────────────────────────
header_html = _build_header(
earliest_date,
last_date,
last_status,
tier,
tier_title,
superseded_by,
authors,
files,
)
timeline_html = _build_timeline(status_history)
if adr_number is not None and adr_title:
# H1 was stripped; prepend generated H1 + header + timeline
markdown = generated_h1 + "\n" + header_html + timeline_html + markdown
else:
# Insert after the existing H1 line
h1_match = re.match(r"(#\s+.+?\n)", markdown)
if h1_match:
insert_pos = h1_match.end()
markdown = (
markdown[:insert_pos]
+ "\n"
+ header_html
+ timeline_html
+ markdown[insert_pos:]
)
else:
markdown = header_html + timeline_html + markdown
# ── generate Related ADRs + Acceptance from front-matter ────────
# Defensively strip any leftover hand-written sections first.
markdown = _RELATED_SECTION_RE.sub("", markdown)
markdown = _ACCEPTANCE_SECTION_RE.sub("", markdown)
markdown = markdown.rstrip() + "\n"
related_adrs = meta.get("related_adrs", [])
acceptance = meta.get("acceptance", {})
tail_sections: list[str] = []
if related_adrs:
tail_sections.append(_build_related_adrs_section(related_adrs, files))
if acceptance:
tail_sections.append(_build_acceptance_section(acceptance))
if tail_sections:
markdown += "\n" + "\n".join(tail_sections)
return markdown