f05ee59763
§22.4a SLICE-1 of docs/design/2026-06-06-configurable-collection-metadata.md (§7.2). Entry metadata can live in a per-entry `<slug>.meta.yaml` sidecar with the `.md` kept as pure prose (INV-2). Additive and non-breaking — with no sidecars present every corpus stays on the legacy frontmatter path, byte-identical (N=1 unchanged). - Dual-read (app/metadata.py `read_entry`) — sidecar-else-legacy-frontmatter, identical records (INV-6); unknown/forward-compat keys ride along through parse→serialize and migration (INV-7, `Entry.extra`). A degenerate sidecar (malformed/empty/slug-less) never drops the entry — slug backstopped from the filename stem, flagged not lost (INV-3). - Migration tool (`metadata.migrate_collection`) — idempotent, one ChangeFiles commit per collection (new `gitea.change_files`). Tested as a function; its Owner-gated operator trigger is DEFERRED to SLICE-4 (write paths must become sidecar-aware first — see the design's SLICE-4 note + INV-8). No production trigger ships here, so no corpus is rewritten. - Malformed flag — migration 033 adds `cached_rfcs.metadata_malformed` (additive); the corpus mirror derives it; catalog list + entry-detail APIs surface `metadata_malformed`. - INV-7 at graduation — graduation now carries `Entry.extra` through the rebuild instead of dropping forward-compat keys. Gate: backend 575 passed (28 new: test_metadata / _migration / _cache + graduation extra-preservation). Frontend untouched. CHANGELOG 0.47.0 + upgrade-steps; VERSION + frontend/package.json -> 0.47.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
218 lines
7.7 KiB
Python
218 lines
7.7 KiB
Python
"""§22.4a configurable collection metadata — sidecar storage + dual-read.
|
|
|
|
SLICE-1 of docs/design/2026-06-06-configurable-collection-metadata.md.
|
|
|
|
Entry metadata is collection-configured and stored in a per-entry sidecar,
|
|
`<slug>.meta.yaml`, with the `.md` body kept as pure prose (INV-2). This module
|
|
is the storage/compat layer:
|
|
|
|
- the **dual-read** parser (`read_entry`) — read the sidecar if present, else
|
|
legacy top-of-document frontmatter, with identical resulting records
|
|
(INV-6);
|
|
- sidecar (de)serialization that preserves unknown / forward-compat keys
|
|
(INV-7), reusing `entry`'s canonical field semantics;
|
|
- lenient parsing that never hard-fails a read — a malformed sidecar surfaces
|
|
a flag, not an exception (INV-3).
|
|
|
|
The collection `fields:` schema and per-field validation are SLICE-2; faceted
|
|
filtering and the edit UIs are later slices. This module interprets no field
|
|
values — it only moves metadata between git and in-memory `Entry` records.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import yaml
|
|
|
|
from . import entry as entry_mod
|
|
from .entry import Entry
|
|
|
|
SIDECAR_SUFFIX = ".meta.yaml"
|
|
|
|
|
|
# ----- filename helpers -----
|
|
|
|
def sidecar_name(slug: str) -> str:
|
|
"""The sidecar filename for an entry whose markdown is `<slug>.md`."""
|
|
return f"{slug}{SIDECAR_SUFFIX}"
|
|
|
|
|
|
def is_sidecar(name: str) -> bool:
|
|
return name.endswith(SIDECAR_SUFFIX)
|
|
|
|
|
|
def slug_of_sidecar(name: str) -> str:
|
|
"""The entry stem for a `<slug>.meta.yaml` filename."""
|
|
return name[: -len(SIDECAR_SUFFIX)] if is_sidecar(name) else name
|
|
|
|
|
|
# ----- metadata <-> sidecar -----
|
|
|
|
def metadata_dict(entry: Entry) -> dict[str, Any]:
|
|
"""The full metadata mapping for an entry (known fields + `extra`)."""
|
|
return entry_mod.to_frontmatter_dict(entry)
|
|
|
|
|
|
def sidecar_yaml(entry: Entry) -> str:
|
|
"""Render an entry's metadata as standalone `<slug>.meta.yaml` text."""
|
|
return yaml.safe_dump(
|
|
metadata_dict(entry), sort_keys=False, default_flow_style=False
|
|
)
|
|
|
|
|
|
def parse_sidecar(text: str) -> tuple[dict[str, Any], bool]:
|
|
"""Parse sidecar YAML leniently → `(values, malformed)`.
|
|
|
|
`malformed` is True when the text is not a YAML mapping (a list, a scalar,
|
|
or a YAML syntax error). An empty / whitespace-only sidecar is an empty
|
|
mapping, not malformed. Never raises (INV-3).
|
|
"""
|
|
try:
|
|
raw = yaml.safe_load(text)
|
|
except yaml.YAMLError:
|
|
return {}, True
|
|
if raw is None:
|
|
return {}, False
|
|
if not isinstance(raw, dict):
|
|
return {}, True
|
|
return raw, False
|
|
|
|
|
|
# ----- frontmatter stripping (INV-2) -----
|
|
|
|
def strip_frontmatter(md_text: str) -> str:
|
|
"""Return the prose body of a `.md`, dropping a leading `---…---` block.
|
|
|
|
A migrated entry's `.md` is body-only and passes through unchanged. A
|
|
not-yet-migrated `.md` still carrying frontmatter yields just its body, so
|
|
the dual-read body is the same either way.
|
|
"""
|
|
match = entry_mod.FRONTMATTER_RE.match(md_text)
|
|
if not match:
|
|
return md_text
|
|
return match.group(2).lstrip("\n")
|
|
|
|
|
|
# ----- dual-read (INV-6) -----
|
|
|
|
def read_entry(
|
|
md_text: str, sidecar_text: str | None, *, fallback_slug: str | None = None
|
|
) -> tuple[Entry, bool]:
|
|
"""Read an entry from its `.md` and optional sidecar → `(Entry, malformed)`.
|
|
|
|
- **Sidecar present and well-formed (non-empty):** metadata comes from the
|
|
sidecar; the body is the `.md` stripped of any leading frontmatter. The
|
|
sidecar is the source of truth (INV-1) and wins over stale `.md`
|
|
frontmatter.
|
|
- **Sidecar present but malformed:** the entry still loads from the legacy
|
|
`.md` frontmatter (if any) and is flagged `malformed` (INV-3).
|
|
- **Sidecar present but empty:** it has nothing to override with, so fall
|
|
back to the `.md` frontmatter (not flagged).
|
|
- **No sidecar:** the legacy path — parse the `.md` frontmatter (INV-6).
|
|
|
|
`fallback_slug` (typically the filename stem) backstops the entry's slug
|
|
whenever the metadata source lacks one — so a degenerate sidecar never
|
|
yields a slug-less record the caller has to silently drop (INV-3).
|
|
"""
|
|
def _with_slug(entry: Entry) -> Entry:
|
|
if not entry.slug and fallback_slug:
|
|
entry.slug = fallback_slug
|
|
return entry
|
|
|
|
if sidecar_text is None:
|
|
return _with_slug(entry_mod.parse(md_text)), False
|
|
|
|
values, malformed = parse_sidecar(sidecar_text)
|
|
if malformed or not values:
|
|
# Malformed or empty sidecar: load from the legacy .md so the entry
|
|
# still loads; flag only when the sidecar was actually malformed.
|
|
try:
|
|
entry = entry_mod.parse(md_text)
|
|
except ValueError:
|
|
entry = entry_mod.from_frontmatter({}, strip_frontmatter(md_text))
|
|
return _with_slug(entry), malformed
|
|
|
|
body = strip_frontmatter(md_text)
|
|
return _with_slug(entry_mod.from_frontmatter(values, body)), False
|
|
|
|
|
|
# ----- frontmatter -> sidecar migration (PUC-5) -----
|
|
|
|
async def migrate_collection(
|
|
gitea: Any,
|
|
*,
|
|
org: str,
|
|
repo: str,
|
|
subfolder: str = "",
|
|
actor: Any = None,
|
|
branch: str = "main",
|
|
) -> dict[str, Any]:
|
|
"""Migrate a collection's legacy-frontmatter entries to sidecars.
|
|
|
|
Walks `<subfolder>/rfcs`; for each `<slug>.md` that has legacy frontmatter
|
|
and **no** `<slug>.meta.yaml` sibling yet, it stages two file changes —
|
|
create the sidecar (the entry's metadata, unknown keys preserved, INV-7)
|
|
and rewrite the `.md` to body-only (INV-2) — and commits all of them in a
|
|
single ChangeFiles commit (§6.5: one commit per collection).
|
|
|
|
Idempotent: an entry that already has a sidecar is skipped; a second run
|
|
with nothing left to migrate makes no commit. Returns
|
|
`{"migrated": [...], "skipped": [...], "committed": bool}`.
|
|
"""
|
|
rfcs_dir = f"{subfolder}/rfcs" if subfolder else "rfcs"
|
|
listing = await gitea.list_dir(org, repo, rfcs_dir, ref=branch)
|
|
names = {f.get("name") for f in listing if f.get("type") == "file"}
|
|
|
|
ops: list[dict[str, Any]] = []
|
|
migrated: list[str] = []
|
|
skipped: list[str] = []
|
|
for f in listing:
|
|
if f.get("type") != "file" or not f.get("name", "").endswith(".md"):
|
|
continue
|
|
slug = f["name"][:-len(".md")]
|
|
if sidecar_name(slug) in names:
|
|
skipped.append(slug) # already migrated
|
|
continue
|
|
result = await gitea.read_file(org, repo, f["path"], ref=branch)
|
|
if not result:
|
|
continue
|
|
text, sha = result
|
|
try:
|
|
e = entry_mod.parse(text)
|
|
except ValueError:
|
|
# No frontmatter to lift (e.g. an already-clean body without a
|
|
# sidecar) — nothing to migrate; leave it untouched.
|
|
skipped.append(slug)
|
|
continue
|
|
body = strip_frontmatter(text)
|
|
new_md = body if (body == "" or body.endswith("\n")) else body + "\n"
|
|
ops.append({
|
|
"operation": "create",
|
|
"path": f"{rfcs_dir}/{sidecar_name(slug)}",
|
|
"content": sidecar_yaml(e),
|
|
})
|
|
ops.append({
|
|
"operation": "update",
|
|
"path": f["path"],
|
|
"content": new_md,
|
|
"sha": sha,
|
|
})
|
|
migrated.append(slug)
|
|
|
|
committed = False
|
|
if ops:
|
|
n = len(migrated)
|
|
message = f"Migrate {n} entr{'y' if n == 1 else 'ies'} to metadata sidecars (§22.4a)"
|
|
kwargs: dict[str, Any] = {}
|
|
if actor is not None:
|
|
kwargs = {
|
|
"author_name": actor.display_name,
|
|
"author_email": actor.email or f"{actor.gitea_login}@users.noreply",
|
|
}
|
|
await gitea.change_files(
|
|
org, repo, files=ops, message=message, branch=branch, **kwargs
|
|
)
|
|
committed = True
|
|
|
|
return {"migrated": migrated, "skipped": skipped, "committed": committed}
|