152 lines
5.5 KiB
Python
152 lines
5.5 KiB
Python
"""Meta-repo entry file shape per §2.1.
|
|
|
|
One markdown file per RFC under rfcs/<slug>.md, frontmatter on top, body
|
|
below. The frontmatter carries the canonical RFC state — id, repo,
|
|
owners, arbiters, graduation timestamps — and the body holds the pitch
|
|
(for super-drafts) or is empty (for graduated entries per §13.3 step 3).
|
|
|
|
This module contains the parser, the serializer, and a small validator
|
|
for the frontmatter shape. The parser is intentionally lenient about
|
|
unknown keys — future fields land in frontmatter without breaking older
|
|
readers.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from datetime import date
|
|
from typing import Any
|
|
|
|
import yaml
|
|
|
|
FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n?(.*)$", re.DOTALL)
|
|
|
|
SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
|
|
|
|
_ABSENT = object()
|
|
|
|
|
|
@dataclass
|
|
class Entry:
|
|
slug: str
|
|
title: str
|
|
state: str = "super-draft" # super-draft | active | withdrawn | retired (§3, §13.7)
|
|
id: str | None = None # 'RFC-NNNN' or None
|
|
repo: str | None = None
|
|
proposed_by: str = ""
|
|
proposed_at: str = "" # ISO date
|
|
graduated_at: str | None = None
|
|
graduated_by: str | None = None
|
|
owners: list[str] = field(default_factory=list)
|
|
arbiters: list[str] = field(default_factory=list)
|
|
tags: list[str] = field(default_factory=list)
|
|
# §6.6: per-RFC model availability. None means the key is absent
|
|
# from frontmatter (inherit the operator universe). An empty list
|
|
# means an explicit opt-out from AI on this RFC. A populated list
|
|
# narrows the picker to its intersection with the operator universe.
|
|
models: list[str] | None = None
|
|
# §6.7: optional gitea_login naming the user whose registered API
|
|
# credentials pay for AI calls on this RFC. None means absent —
|
|
# operator credentials per §18 are used. The binding is inert until
|
|
# the named user has a funder_consents row (the hybrid two-key rule).
|
|
funder: str | None = None
|
|
# §22.4c: an `active` entry that landed without a human review gate
|
|
# carries unreviewed=True until an owner clears it. Orthogonal to
|
|
# `state`; only meaningful for active entries. reviewed_at/reviewed_by
|
|
# are the provenance of the clear, paralleling graduated_at/by.
|
|
unreviewed: bool = False
|
|
reviewed_at: str | None = None
|
|
reviewed_by: str | None = None
|
|
body: str = ""
|
|
|
|
|
|
def parse(text: str) -> Entry:
|
|
match = FRONTMATTER_RE.match(text)
|
|
if not match:
|
|
raise ValueError("Entry file missing frontmatter")
|
|
fm = yaml.safe_load(match.group(1)) or {}
|
|
body = match.group(2).lstrip("\n")
|
|
raw_models = fm.get("models", _ABSENT)
|
|
if raw_models is _ABSENT or raw_models is None:
|
|
models: list[str] | None = None
|
|
else:
|
|
models = [str(m) for m in raw_models]
|
|
raw_funder = fm.get("funder")
|
|
funder = str(raw_funder).strip() if raw_funder else None
|
|
unreviewed = bool(fm.get("unreviewed") or False)
|
|
return Entry(
|
|
slug=str(fm.get("slug") or ""),
|
|
title=str(fm.get("title") or ""),
|
|
state=str(fm.get("state") or "super-draft"),
|
|
id=fm.get("id") or None,
|
|
repo=fm.get("repo") or None,
|
|
proposed_by=str(fm.get("proposed_by") or ""),
|
|
proposed_at=str(fm.get("proposed_at") or ""),
|
|
graduated_at=fm.get("graduated_at"),
|
|
graduated_by=fm.get("graduated_by"),
|
|
owners=list(fm.get("owners") or []),
|
|
arbiters=list(fm.get("arbiters") or []),
|
|
tags=list(fm.get("tags") or []),
|
|
models=models,
|
|
funder=funder,
|
|
unreviewed=unreviewed,
|
|
reviewed_at=fm.get("reviewed_at") or None,
|
|
reviewed_by=fm.get("reviewed_by") or None,
|
|
body=body,
|
|
)
|
|
|
|
|
|
def serialize(entry: Entry) -> str:
|
|
"""Emit canonical entry file text — frontmatter then body."""
|
|
fm: dict[str, Any] = {
|
|
"slug": entry.slug,
|
|
"title": entry.title,
|
|
"state": entry.state,
|
|
"id": entry.id,
|
|
"repo": entry.repo,
|
|
"proposed_by": entry.proposed_by,
|
|
"proposed_at": entry.proposed_at,
|
|
"graduated_at": entry.graduated_at,
|
|
"graduated_by": entry.graduated_by,
|
|
"owners": entry.owners,
|
|
"arbiters": entry.arbiters,
|
|
"tags": entry.tags,
|
|
}
|
|
# §6.6: emit `models:` only when set. Absent in the frontmatter
|
|
# is meaningfully different from `models: []` per §6.6.
|
|
if entry.models is not None:
|
|
fm["models"] = entry.models
|
|
# §6.7: emit `funder:` only when set. Empty / None means absent —
|
|
# operator credentials are used. There is no "explicit opt-out"
|
|
# second meaning here as with `models:`; one set of semantics.
|
|
if entry.funder:
|
|
fm["funder"] = entry.funder
|
|
# §22.4c: emit unreviewed only when True (a super-draft / reviewed
|
|
# active entry leaves the key absent → frontmatter stays minimal).
|
|
if entry.unreviewed:
|
|
fm["unreviewed"] = True
|
|
if entry.reviewed_at:
|
|
fm["reviewed_at"] = entry.reviewed_at
|
|
if entry.reviewed_by:
|
|
fm["reviewed_by"] = entry.reviewed_by
|
|
yaml_text = yaml.safe_dump(fm, sort_keys=False, default_flow_style=False).rstrip()
|
|
body = entry.body.lstrip("\n")
|
|
if body:
|
|
return f"---\n{yaml_text}\n---\n\n{body}\n"
|
|
return f"---\n{yaml_text}\n---\n"
|
|
|
|
|
|
def slugify(title: str) -> str:
|
|
"""Deterministic kebab-case per §9.1."""
|
|
s = title.lower().strip()
|
|
s = re.sub(r"[^a-z0-9]+", "-", s)
|
|
return s.strip("-")
|
|
|
|
|
|
def today() -> str:
|
|
return date.today().isoformat()
|
|
|
|
|
|
def is_valid_slug(slug: str) -> bool:
|
|
return bool(SLUG_RE.match(slug)) and len(slug) <= 80
|