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:
1 parent
6c5531da98
commit
f8329c8503
2 files changed
+80
-15
No files matched your search
+34
-15
@@ -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]:
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user