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>
136 lines
4.0 KiB
Python
136 lines
4.0 KiB
Python
"""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()
|