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,199 @@
|
||||
"""SLICE-1 unit tests — sidecar metadata: dual-read, unknown-key preservation,
|
||||
frontmatter stripping, malformed detection.
|
||||
|
||||
Pure functions only (no DB / no Gitea). Per
|
||||
docs/design/2026-06-06-configurable-collection-metadata.md §7.2 (SLICE-1) and
|
||||
INV-6 (dual-read), INV-7 (unknown keys ride along), INV-2 (clean body).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import yaml
|
||||
|
||||
from app import entry as entry_mod
|
||||
from app import metadata
|
||||
|
||||
|
||||
LEGACY_MD = """---
|
||||
slug: view-metrics
|
||||
title: View today's metrics
|
||||
state: active
|
||||
owners:
|
||||
- ben.stull
|
||||
tags:
|
||||
- dashboard
|
||||
- analytics
|
||||
priority: P1
|
||||
owner: hasan
|
||||
---
|
||||
|
||||
This is the prose body.
|
||||
|
||||
Second paragraph.
|
||||
"""
|
||||
|
||||
|
||||
# ---- INV-7: unknown keys ride along ----
|
||||
|
||||
def test_parse_preserves_unknown_keys_in_extra():
|
||||
e = entry_mod.parse(LEGACY_MD)
|
||||
assert e.extra == {"priority": "P1", "owner": "hasan"}
|
||||
|
||||
|
||||
def test_serialize_round_trip_preserves_unknown_keys():
|
||||
e = entry_mod.parse(LEGACY_MD)
|
||||
text = entry_mod.serialize(e)
|
||||
e2 = entry_mod.parse(text)
|
||||
assert e2.extra == {"priority": "P1", "owner": "hasan"}
|
||||
assert e2.tags == ["dashboard", "analytics"]
|
||||
assert e2.owners == ["ben.stull"]
|
||||
|
||||
|
||||
def test_known_keys_never_leak_into_extra():
|
||||
e = entry_mod.parse(LEGACY_MD)
|
||||
for known in ("slug", "title", "state", "owners", "tags"):
|
||||
assert known not in e.extra
|
||||
|
||||
|
||||
# ---- metadata_dict / sidecar_yaml ----
|
||||
|
||||
def test_metadata_dict_merges_known_and_extra():
|
||||
e = entry_mod.parse(LEGACY_MD)
|
||||
d = metadata.metadata_dict(e)
|
||||
assert d["slug"] == "view-metrics"
|
||||
assert d["title"] == "View today's metrics"
|
||||
assert d["state"] == "active"
|
||||
assert d["tags"] == ["dashboard", "analytics"]
|
||||
# forward-compat keys present
|
||||
assert d["priority"] == "P1"
|
||||
assert d["owner"] == "hasan"
|
||||
|
||||
|
||||
def test_sidecar_yaml_is_parseable_and_has_no_frontmatter_fences():
|
||||
e = entry_mod.parse(LEGACY_MD)
|
||||
sc = metadata.sidecar_yaml(e)
|
||||
assert "---" not in sc.splitlines()[0]
|
||||
loaded = yaml.safe_load(sc)
|
||||
assert loaded["slug"] == "view-metrics"
|
||||
assert loaded["priority"] == "P1"
|
||||
|
||||
|
||||
# ---- strip_frontmatter (INV-2) ----
|
||||
|
||||
def test_strip_frontmatter_removes_leading_block():
|
||||
body = metadata.strip_frontmatter(LEGACY_MD)
|
||||
assert body.startswith("This is the prose body.")
|
||||
assert "slug:" not in body
|
||||
assert "priority:" not in body
|
||||
|
||||
|
||||
def test_strip_frontmatter_passthrough_when_no_frontmatter():
|
||||
plain = "Just a body.\n\nNo frontmatter here.\n"
|
||||
assert metadata.strip_frontmatter(plain).strip() == plain.strip()
|
||||
|
||||
|
||||
# ---- parse_sidecar (malformed detection, INV-3) ----
|
||||
|
||||
def test_parse_sidecar_good():
|
||||
values, malformed = metadata.parse_sidecar("slug: a\ntitle: A\npriority: P0\n")
|
||||
assert malformed is False
|
||||
assert values == {"slug": "a", "title": "A", "priority": "P0"}
|
||||
|
||||
|
||||
def test_parse_sidecar_non_mapping_is_malformed():
|
||||
values, malformed = metadata.parse_sidecar("- just\n- a\n- list\n")
|
||||
assert malformed is True
|
||||
assert values == {}
|
||||
|
||||
|
||||
def test_parse_sidecar_invalid_yaml_is_malformed():
|
||||
values, malformed = metadata.parse_sidecar("slug: : : not yaml\n bad: [unclosed\n")
|
||||
assert malformed is True
|
||||
assert values == {}
|
||||
|
||||
|
||||
def test_parse_sidecar_empty_is_empty_not_malformed():
|
||||
values, malformed = metadata.parse_sidecar("")
|
||||
assert malformed is False
|
||||
assert values == {}
|
||||
|
||||
|
||||
# ---- read_entry dual-read equivalence (INV-6) ----
|
||||
|
||||
def test_dual_read_sidecar_matches_legacy():
|
||||
legacy_entry, legacy_bad = metadata.read_entry(LEGACY_MD, None)
|
||||
|
||||
# The migrated form: body-only .md + a sidecar holding the metadata.
|
||||
migrated_md = metadata.strip_frontmatter(LEGACY_MD)
|
||||
sidecar_text = metadata.sidecar_yaml(legacy_entry)
|
||||
sidecar_entry, sidecar_bad = metadata.read_entry(migrated_md, sidecar_text)
|
||||
|
||||
assert legacy_bad is False
|
||||
assert sidecar_bad is False
|
||||
# Identical resulting records (INV-6).
|
||||
assert sidecar_entry.slug == legacy_entry.slug
|
||||
assert sidecar_entry.title == legacy_entry.title
|
||||
assert sidecar_entry.state == legacy_entry.state
|
||||
assert sidecar_entry.owners == legacy_entry.owners
|
||||
assert sidecar_entry.tags == legacy_entry.tags
|
||||
assert sidecar_entry.extra == legacy_entry.extra
|
||||
assert sidecar_entry.body.strip() == legacy_entry.body.strip()
|
||||
|
||||
|
||||
def test_read_entry_sidecar_takes_precedence_over_md_frontmatter():
|
||||
# A not-yet-migrated .md still carrying frontmatter, plus a sidecar that
|
||||
# disagrees: the sidecar wins for metadata; the body comes from the .md.
|
||||
md_with_fm = "---\nslug: old\ntitle: Old Title\nstate: super-draft\n---\n\nBody.\n"
|
||||
sidecar = "slug: new\ntitle: New Title\nstate: active\n"
|
||||
e, malformed = metadata.read_entry(md_with_fm, sidecar)
|
||||
assert malformed is False
|
||||
assert e.title == "New Title"
|
||||
assert e.state == "active"
|
||||
assert e.body.strip() == "Body."
|
||||
|
||||
|
||||
def test_read_entry_malformed_sidecar_still_loads_entry():
|
||||
# INV-3: a malformed sidecar never hard-fails the read. The entry loads
|
||||
# (from the .md frontmatter if present) and is flagged malformed.
|
||||
md = "---\nslug: x\ntitle: X\nstate: active\n---\n\nBody.\n"
|
||||
e, malformed = metadata.read_entry(md, "- not a mapping\n")
|
||||
assert malformed is True
|
||||
assert e.slug == "x"
|
||||
assert e.title == "X"
|
||||
assert e.body.strip() == "Body."
|
||||
|
||||
|
||||
# ---- dual-read robustness: degenerate sidecars never drop the entry ----
|
||||
|
||||
def test_empty_sidecar_falls_back_to_md_frontmatter():
|
||||
# An empty sidecar has no metadata to override with — keep the .md's.
|
||||
md = "---\nslug: keep\ntitle: Keep Me\nstate: active\n---\n\nBody.\n"
|
||||
e, malformed = metadata.read_entry(md, "", fallback_slug="keep")
|
||||
assert malformed is False
|
||||
assert e.slug == "keep"
|
||||
assert e.title == "Keep Me"
|
||||
|
||||
|
||||
def test_malformed_sidecar_on_body_only_md_loads_with_fallback_slug():
|
||||
# The .md is already body-only (migrated) and the sidecar is corrupt:
|
||||
# the entry must still load (INV-3), taking its slug from the filename stem.
|
||||
e, malformed = metadata.read_entry("Just a body.\n", "- a\n- list\n", fallback_slug="foo")
|
||||
assert malformed is True
|
||||
assert e.slug == "foo"
|
||||
|
||||
|
||||
def test_slugless_sidecar_uses_fallback_slug():
|
||||
md = "Body only.\n"
|
||||
sidecar = "title: No Slug Here\nstate: active\n"
|
||||
e, malformed = metadata.read_entry(md, sidecar, fallback_slug="bar")
|
||||
assert malformed is False
|
||||
assert e.slug == "bar"
|
||||
assert e.title == "No Slug Here"
|
||||
|
||||
|
||||
# ---- sidecar filename helpers ----
|
||||
|
||||
def test_sidecar_filename_helpers():
|
||||
assert metadata.sidecar_name("view-metrics") == "view-metrics.meta.yaml"
|
||||
assert metadata.is_sidecar("view-metrics.meta.yaml") is True
|
||||
assert metadata.is_sidecar("view-metrics.md") is False
|
||||
assert metadata.slug_of_sidecar("view-metrics.meta.yaml") == "view-metrics"
|
||||
Reference in New Issue
Block a user