fix(vault): never mint an id for unparseable frontmatter

index.py's read_and_fix() treated "no valid ULID in frontmatter" as
"hand-written note", but notes.parse() is lenient and returns an empty
map for broken YAML. A YAML typo in an already-indexed note therefore
minted a fresh ULID, rewrote the file under a new name, unlinked the
original and dropped its index row.

read_and_fix() now re-parses strictly before treating a missing id as
"assign one": broken frontmatter raises and is left untouched on disk
(the scan reports it as an error), while frontmatter that parsed
cleanly but genuinely lacks an id first tries ids.id_from_filename()
before minting a new one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139vHnNpS2sLNELjrAHX5Cu
This commit is contained in:
MarkusandClaude Opus 5 committed 2026-09-12 14:33:13 +00:00
1 parent 6c5531da98
commit f8329c8503
2 files changed
+80 -15

No files matched your search

+34 -15
View File
@@ -11,8 +11,9 @@ from pathlib import Path
from mdmem.chunker import chunk_note, embed_text
from mdmem.config import NOTE_TYPES
from mdmem.db import Database
from mdmem.ids import is_ulid, new_id, slugify
from mdmem.ids import id_from_filename, is_ulid, new_id, slugify
from mdmem.notes import Note
from mdmem.notes import parse as parse_note
from mdmem.vaultio import Vault, sha256_text
log = logging.getLogger("mdmem.index")
@@ -243,21 +244,39 @@ class Indexer:
return stats
def read_and_fix(self, path: Path) -> Note:
"""Read a note, assigning a ULID and filename if a human created the file by hand."""
"""Read a note, assigning a ULID and filename if a human created the file by hand.
Unparseable frontmatter must NEVER cause an id to be minted or a file to be
renamed/unlinked (finding 1): `notes.parse()` is lenient and returns an empty
map for broken YAML, which would otherwise look identical to "no frontmatter
at all" and trigger the same repair path as a genuinely hand-written note,
destroying the original file's identity. So before treating a missing id as
"assign one", we re-parse strictly to tell "broken YAML" (raise, skip, leave
the file untouched) apart from "parsed cleanly, just has no id" (safe to fix).
"""
note = self.vault.read(path)
if not is_ulid(note.id):
note.set("id", new_id())
if not note.title:
note.set("title", path.stem)
new_path = self.vault.write(note, state=note.state)
if new_path != path:
# the file had no ULID in its name, so write() could not find the original
path.unlink(missing_ok=True)
log.info(
"assigned id to hand-written note",
extra={"fields": {"id": note.id, "path": str(new_path)}},
)
note = self.vault.read(new_path)
if is_ulid(note.id):
return note
raw = path.read_text(encoding="utf-8")
try:
parse_note(raw, strict=True)
except Exception as exc:
raise ValueError(f"note at {path} has unparseable frontmatter: {exc}") from exc
fname_id = id_from_filename(path.name)
note.set("id", fname_id or new_id())
if not note.title:
note.set("title", path.stem)
new_path = self.vault.write(note, state=note.state)
if new_path != path:
# the file had no ULID in its name, so write() could not find the original
path.unlink(missing_ok=True)
log.info(
"assigned id to hand-written note",
extra={"fields": {"id": note.id, "path": str(new_path)}},
)
note = self.vault.read(new_path)
return note
def pending_embeds(self) -> list[PendingEmbed]:
+46
View File
@@ -148,6 +148,52 @@ def test_handwritten_note_without_id_gets_one(idx):
assert not p.exists() # renamed to slug--ULID.md
def test_unparseable_frontmatter_on_an_indexed_note_is_never_reminted(idx):
"""A YAML typo in an already-indexed note must not mint a fresh id, rename the
file or unlink the original (finding 1). It is an error the scan reports, and the
file is left exactly as it is on disk."""
indexer, vault, db = idx
n = Note.new(title="wichtig", body="x", type="fact", tags=["homelab"])
vault.write(n)
indexer.index_note(vault.read(vault.find(n.id)))
original_path = vault.find(n.id)
assert original_path is not None
text = original_path.read_text(encoding="utf-8")
broken = text.replace("tags:\n - homelab", "tags: [unbalanced")
assert broken != text
original_path.write_text(broken, encoding="utf-8")
stats = indexer.scan()
assert len(stats.errors) == 1
assert stats.indexed == 0
# the file on disk must be untouched: same path, same (broken) content
assert original_path.exists()
assert original_path.read_text(encoding="utf-8") == broken
# no new file was minted anywhere in the vault
assert [p.name for p, _ in vault.iter_files()] == [original_path.name]
# the id is unaffected -- vault.find still resolves the original note
assert vault.find(n.id) == original_path
def test_clean_frontmatter_without_id_adopts_id_from_filename(idx):
"""When frontmatter parses cleanly but has no usable id, and the filename
already carries a valid ULID, that id must be adopted rather than minting a
new one (finding 1)."""
indexer, vault, db = idx
ulid = "01HZZZZZZZZZZZZZZZZZZZZZZZ"
p = vault.root / "active" / f"wichtig--{ulid}.md"
p.write_text("---\ntitle: Wichtig\ntype: fact\n---\nInhalt\n", encoding="utf-8")
stats = indexer.scan()
assert stats.indexed == 1 and not stats.errors
row = db.read().execute("SELECT id, title FROM notes").fetchone()
assert row["id"] == ulid
assert row["title"] == "Wichtig"
def test_broken_yaml_is_skipped_not_fatal(idx):
indexer, vault, db = idx
ok = Note.new(title="Fine", body="x", type="fact")