From 4c89f6b5c9e4af142f821bb42f4351a259aa6634 Mon Sep 17 00:00:00 2001 From: Chris Newport Date: Sat, 18 Jul 2026 22:07:43 +0100 Subject: [PATCH 1/2] fix(vault): parse block-style YAML lists in frontmatter (not just inline arrays) parseFrontmatterText only recognised inline arrays (tags: [a, b]); block sequences (tags:\n - a\n - b) emitted by Obsidian's Properties editor and several writers were silently dropped. That made required list fields such as tags vanish, so o2b brain doctor reported spurious 'signal missing field: tags' errors on otherwise-valid sig-*.md files. Teach the parser to assemble dash-item blocks under an empty-value key into an array, while preserving the original empty-string behaviour for genuine null scalars (key: with no following dash list). Inline arrays are unchanged. Adds regression tests covering block lists, quoted items, null-scalar disambiguation, and interleaving with inline arrays. --- src/core/vault.ts | 51 ++++++++++++++++++++++++++++++++++++++-- tests/core/vault.test.ts | 45 +++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/src/core/vault.ts b/src/core/vault.ts index 8db475b0..1a2e4885 100644 --- a/src/core/vault.ts +++ b/src/core/vault.ts @@ -81,14 +81,61 @@ export function parseFrontmatterText(text: string): readonly [FrontmatterMap, st const body = text.slice(match[0].length).trim(); const metadata: FrontmatterMap = {}; - for (const rawLine of fmBlock.split("\n")) { - const line = rawLine.trim(); + // Block-sequence state: when a key is opened with an empty value + // (`key:`) and the following lines are dash items (`- a`), we assemble + // them into an array. This mirrors standard YAML block lists, which is + // what Obsidian's Properties editor and several writers emit — the + // original inline-array-only parser silently dropped such lists and + // surfaced spurious "missing field" errors in `o2b brain doctor`. + const lines = fmBlock.split("\n"); + let blockKey: string | null = null; + + const isDashItem = (s: string): RegExpExecArray | null => /^-\s+(.*?)\s*$/.exec(s); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!.trim(); if (!line || line.startsWith("#")) continue; + + const dash = isDashItem(line); + if (dash && blockKey !== null) { + const arr = (metadata[blockKey] as string[] | undefined) ?? []; + arr.push(stripQuotes(dash[1]!)); + metadata[blockKey] = arr; + continue; + } + + // Any non-dash line terminates an open block sequence. + blockKey = null; + const kv = KEY_VALUE_RE.exec(line); if (!kv) continue; const key = kv[1]!; let value = kv[2]!.trim(); + if (value === "") { + // Empty value. Peek at the next meaningful line: if it is a dash + // item this is a block-sequence header, otherwise keep the original + // empty-string behaviour so a genuine null scalar round-trips. + let j = i + 1; + let nextMeaningful: string | null = null; + while (j < lines.length) { + const cand = lines[j]!.trim(); + if (!cand || cand.startsWith("#")) { + j++; + continue; + } + nextMeaningful = cand; + break; + } + if (nextMeaningful !== null && isDashItem(nextMeaningful)) { + metadata[key] = []; + blockKey = key; + } else { + metadata[key] = ""; + } + continue; + } + if (value.startsWith("[") && value.endsWith("]")) { const inner = value.slice(1, -1).trim(); metadata[key] = inner ? splitInlineArray(inner) : []; diff --git a/tests/core/vault.test.ts b/tests/core/vault.test.ts index b9e1c6e9..277a36fd 100644 --- a/tests/core/vault.test.ts +++ b/tests/core/vault.test.ts @@ -39,6 +39,51 @@ describe("parseFrontmatter", () => { expect(body).toBe("Just a note."); }); + test("parses block-style YAML list into an array", () => { + // Regression: the frontmatter reader only understood inline arrays + // (`tags: [a, b]`) and silently dropped block sequences + // (`tags:\n - a\n - b`). Block lists are what Obsidian's Properties + // editor and several writers emit, so the dropped `tags` surfaced as + // spurious `signal missing field: tags` errors in `o2b brain doctor`. + const path = join(tmp, "note.md"); + writeFileSync( + path, + "---\nkind: brain-signal\ntags:\n - brain\n - brain/signal\n - brain/topic/foo\ntopic: foo\nsignal: positive\n---\n\nBody.\n", + ); + const [meta, body] = parseFrontmatter(path); + expect(meta["kind"]).toBe("brain-signal"); + expect(meta["tags"]).toEqual(["brain", "brain/signal", "brain/topic/foo"]); + expect(meta["topic"]).toBe("foo"); + expect(meta["signal"]).toBe("positive"); + expect(body).toBe("Body."); + }); + + test("block-style list with quoted items parses as strings", () => { + const path = join(tmp, "note.md"); + writeFileSync(path, '---\ntags:\n - plain\n - "needs, comma"\ntitle: Mixed\n---\n\nBody.\n'); + const [meta] = parseFrontmatter(path); + expect(meta["tags"]).toEqual(["plain", "needs, comma"]); + }); + + test("empty scalar key (no following dash list) round-trips as empty string", () => { + // A genuine null scalar (`key:` with no block list after it) must + // stay an empty string, not be misread as a block-sequence header. + const path = join(tmp, "note.md"); + writeFileSync(path, "---\ntitle: Hello\nsummary:\nbody: A note\n---\n\nBody.\n"); + const [meta] = parseFrontmatter(path); + expect(meta["summary"]).toBe(""); + expect(meta["body"]).toBe("A note"); + }); + + test("block list and inline array interleave correctly", () => { + const path = join(tmp, "note.md"); + writeFileSync(path, "---\ninline: [x, y]\nblock:\n - a\n - b\ntail: end\n---\n\nBody.\n"); + const [meta] = parseFrontmatter(path); + expect(meta["inline"]).toEqual(["x", "y"]); + expect(meta["block"]).toEqual(["a", "b"]); + expect(meta["tail"]).toBe("end"); + }); + test("handles missing file gracefully", () => { const [meta, body] = parseFrontmatter("/nonexistent/file.md"); expect(meta).toEqual({}); From 496b7858fd0f6ed29abae70bc3a40aef9925b65a Mon Sep 17 00:00:00 2001 From: Chris Newport Date: Sun, 19 Jul 2026 07:17:57 +0100 Subject: [PATCH 2/2] fix(vault): handle empty/-prefixed list items in block sequences Per CodeRabbit review on #142: a bare '-' (or '- ') list item failed the dash regex (which required >=1 space) and silently reset blockKey, dropping the remainder of the block list. Move the matcher to module scope as DASH_ITEM_RE = /^-(?:\s+(.*))?$/ (matches '- foo', '- ', and bare '-' but NOT '-foo', which has no whitespace), and push dash[1] ?? '' so empty items surface as '' instead of aborting the list. Update the lookahead to reuse DASH_ITEM_RE. Adds regression tests: empty-item is not dropped, and '-foo' is not parsed as a list item. --- src/core/vault.ts | 12 +++++++----- tests/core/vault.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/core/vault.ts b/src/core/vault.ts index 1a2e4885..e8694978 100644 --- a/src/core/vault.ts +++ b/src/core/vault.ts @@ -18,6 +18,10 @@ import type { FrontmatterMap, FrontmatterValue, VaultPage } from "./types.ts"; const FRONTMATTER_RE = /^---\s*\n([\s\S]*?)\n---\s*\n?/; const KEY_VALUE_RE = /^([a-zA-Z_][a-zA-Z0-9_-]*)\s*:\s*(.*?)\s*$/; +// Block-sequence dash item: `- item` or a bare `-` (empty item). The +// leading whitespace is required so `-foo` (a dash-prefixed string, not a +// list item) is NOT matched. Captures the item text after the whitespace. +const DASH_ITEM_RE = /^-(?:\s+(.*))?$/; const PLAIN_SCALAR_RE = /^[A-Za-z0-9_./-](?:[A-Za-z0-9_./ -]*[A-Za-z0-9_./-])?$/; const CODE_BLOCK_RE = /```[\s\S]*?```|`[^`]+`/g; const SLUG_INVALID_RE = /[^a-z0-9]+/g; @@ -90,16 +94,14 @@ export function parseFrontmatterText(text: string): readonly [FrontmatterMap, st const lines = fmBlock.split("\n"); let blockKey: string | null = null; - const isDashItem = (s: string): RegExpExecArray | null => /^-\s+(.*?)\s*$/.exec(s); - for (let i = 0; i < lines.length; i++) { const line = lines[i]!.trim(); if (!line || line.startsWith("#")) continue; - const dash = isDashItem(line); + const dash = DASH_ITEM_RE.exec(line); if (dash && blockKey !== null) { const arr = (metadata[blockKey] as string[] | undefined) ?? []; - arr.push(stripQuotes(dash[1]!)); + arr.push(stripQuotes(dash[1] ?? "")); metadata[blockKey] = arr; continue; } @@ -127,7 +129,7 @@ export function parseFrontmatterText(text: string): readonly [FrontmatterMap, st nextMeaningful = cand; break; } - if (nextMeaningful !== null && isDashItem(nextMeaningful)) { + if (nextMeaningful !== null && DASH_ITEM_RE.test(nextMeaningful)) { metadata[key] = []; blockKey = key; } else { diff --git a/tests/core/vault.test.ts b/tests/core/vault.test.ts index 277a36fd..a9cdc78c 100644 --- a/tests/core/vault.test.ts +++ b/tests/core/vault.test.ts @@ -58,6 +58,29 @@ describe("parseFrontmatter", () => { expect(body).toBe("Body."); }); + test("block list with a bare empty dash item is not silently dropped", () => { + // Regression for the CodeRabbit edge case: an isolated `-` (or `- `) + // must NOT reset blockKey and drop the rest of the list. The empty + // item surfaces as an empty-string element, and subsequent items are + // still captured. + const path = join(tmp, "note.md"); + writeFileSync(path, "---\ntags:\n - brain\n -\n - brain/signal\n---\n\nBody.\n"); + const [meta] = parseFrontmatter(path); + expect(meta["tags"]).toEqual(["brain", "", "brain/signal"]); + }); + + test("dash-prefixed string without whitespace is not a list item", () => { + // `-foo` has no space after the dash, so it must NOT be parsed as a + // block-list item (only `- foo` / bare `-` count). As a lone line it + // is simply ignored as a non-key/value line. + const path = join(tmp, "note.md"); + writeFileSync(path, "---\nkey: value\n-foo\nother: end\n---\n\nBody.\n"); + const [meta] = parseFrontmatter(path); + expect(meta["key"]).toBe("value"); + expect(meta["other"]).toBe("end"); + expect(meta["-foo"]).toBeUndefined(); + }); + test("block-style list with quoted items parses as strings", () => { const path = join(tmp, "note.md"); writeFileSync(path, '---\ntags:\n - plain\n - "needs, comma"\ntitle: Mixed\n---\n\nBody.\n');