v0.47.0 — SLICE-1: metadata sidecars — storage + dual-read + migration tool
§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>
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
"""SLICE-1 integration — frontmatter→sidecar migration tool (PUC-5).
|
||||
|
||||
Per docs/design/2026-06-06-configurable-collection-metadata.md §6.5 / §7.2:
|
||||
a tool walks a collection; for each entry with legacy frontmatter it writes
|
||||
`<slug>.meta.yaml` and rewrites `<slug>.md` to the body only — one commit per
|
||||
collection, idempotent, preserving unknown keys (INV-7).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import yaml
|
||||
|
||||
from app import gitea as gitea_mod, metadata
|
||||
from app.config import load_config
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401 (fixtures)
|
||||
app_with_fake_gitea,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
ALPHA_MD = """---
|
||||
slug: alpha
|
||||
title: Alpha
|
||||
state: active
|
||||
owners:
|
||||
- ben.stull
|
||||
tags:
|
||||
- one
|
||||
priority: P1
|
||||
---
|
||||
|
||||
Alpha body prose.
|
||||
"""
|
||||
|
||||
BETA_MD = """---
|
||||
slug: beta
|
||||
title: Beta
|
||||
state: super-draft
|
||||
---
|
||||
|
||||
Beta body prose.
|
||||
"""
|
||||
|
||||
|
||||
def _seed_entries(fake):
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = {
|
||||
"content": ALPHA_MD, "sha": "a0001"}
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/beta.md")] = {
|
||||
"content": BETA_MD, "sha": "b0001"}
|
||||
|
||||
|
||||
def _run_migration(subfolder=""):
|
||||
cfg = load_config()
|
||||
gitea = gitea_mod.Gitea(cfg)
|
||||
return asyncio.run(
|
||||
metadata.migrate_collection(
|
||||
gitea, org="wiggleverse", repo="meta", subfolder=subfolder
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_migration_writes_sidecars_and_strips_bodies(app_with_fake_gitea):
|
||||
_app, fake = app_with_fake_gitea
|
||||
_seed_entries(fake)
|
||||
|
||||
summary = _run_migration()
|
||||
|
||||
assert sorted(summary["migrated"]) == ["alpha", "beta"]
|
||||
|
||||
# Sidecars now exist.
|
||||
alpha_sc = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")]["content"]
|
||||
beta_sc = fake.files[("wiggleverse", "meta", "main", "rfcs/beta.meta.yaml")]["content"]
|
||||
alpha_vals = yaml.safe_load(alpha_sc)
|
||||
assert alpha_vals["slug"] == "alpha"
|
||||
assert alpha_vals["title"] == "Alpha"
|
||||
assert alpha_vals["tags"] == ["one"]
|
||||
# INV-7: the unknown key rides along into the sidecar.
|
||||
assert alpha_vals["priority"] == "P1"
|
||||
|
||||
# .md bodies are stripped of frontmatter (INV-2).
|
||||
alpha_md = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"]
|
||||
assert "---" not in alpha_md
|
||||
assert "priority:" not in alpha_md
|
||||
assert alpha_md.strip() == "Alpha body prose."
|
||||
assert beta_sc # beta got a sidecar too
|
||||
|
||||
|
||||
def test_migration_is_idempotent(app_with_fake_gitea):
|
||||
_app, fake = app_with_fake_gitea
|
||||
_seed_entries(fake)
|
||||
|
||||
first = _run_migration()
|
||||
assert sorted(first["migrated"]) == ["alpha", "beta"]
|
||||
assert first["committed"] is True
|
||||
|
||||
commits_after_first = fake._commit_counter
|
||||
alpha_md_after_first = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"]
|
||||
|
||||
second = _run_migration()
|
||||
assert second["migrated"] == []
|
||||
assert second["committed"] is False
|
||||
# No new commit; files untouched.
|
||||
assert fake._commit_counter == commits_after_first
|
||||
assert fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"] == alpha_md_after_first
|
||||
|
||||
|
||||
def test_migration_one_commit_for_whole_collection(app_with_fake_gitea):
|
||||
_app, fake = app_with_fake_gitea
|
||||
_seed_entries(fake)
|
||||
before = fake._commit_counter
|
||||
_run_migration()
|
||||
# Two entries migrated in exactly one commit (ChangeFiles batch).
|
||||
assert fake._commit_counter == before + 1
|
||||
|
||||
|
||||
def test_migration_dual_read_equivalence_after_migrate(app_with_fake_gitea):
|
||||
"""An entry reads identically before and after migration (INV-6)."""
|
||||
_app, fake = app_with_fake_gitea
|
||||
_seed_entries(fake)
|
||||
|
||||
before, _ = metadata.read_entry(ALPHA_MD, None)
|
||||
_run_migration()
|
||||
md = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"]
|
||||
sc = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")]["content"]
|
||||
after, malformed = metadata.read_entry(md, sc)
|
||||
|
||||
assert malformed is False
|
||||
assert after.slug == before.slug
|
||||
assert after.title == before.title
|
||||
assert after.state == before.state
|
||||
assert after.tags == before.tags
|
||||
assert after.extra == before.extra
|
||||
assert after.body.strip() == before.body.strip()
|
||||
Reference in New Issue
Block a user