Compare commits

..
Author SHA1 Message Date
Garry TanandClaude Fable 5 72b9e9333f fix(extract): resolve cross-directory wikilinks via sync-consistent slugification (#1964)
Raw Obsidian wikilink paths ([[llm-wiki/entities/AI 3.0]]) never matched
sync-slugified slugs (llm-wiki/entities/ai-3.0) on either extraction path.

- FS path: resolveSlug now slugifies each candidate (slugifyPath, the same
  function sync uses) when the raw path-equality check misses, in both the
  direct join and the ancestor walk. Dir-qualified wikilinks resolve as
  typed edges without the global_basename flag.
- DB path: extractPageLinks resolves dir-qualified generic wikilinks by
  slugifying the path and matching by exact slug or path suffix against
  the basename index — regardless of global_basename (the flag guards
  bare-name cross-dir ambiguity, which doesn't apply to dir-qualified
  paths). A same-tail page in a different directory can no longer be the
  only candidate and is filtered out.

Fixes #1964

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:57:58 -07:00
11 changed files with 108 additions and 249 deletions
+1 -22
View File
@@ -365,12 +365,6 @@ async function main() {
if (def.required && params[key] === undefined) {
if (queryHasAlt && key === 'query') continue;
const cliName = op.cliHints?.name || op.name;
// #2822: when the missing param is the op's stdin-fed one, the usage
// line alone is misleading (the positionals may all be present — the
// pipe was just empty). Name the real problem.
if (op.cliHints?.stdin === key) {
console.error(`Error: required "${key}" is missing — stdin was empty or not piped. Pipe content on stdin or pass --${key.replace(/_/g, '-')}.`);
}
const positional = op.cliHints?.positional || [];
const usage = positional.map(p => `<${p}>`).join(' ');
console.error(`Usage: gbrain ${cliName} ${usage}`);
@@ -767,10 +761,6 @@ export function parseOpArgs(op: Operation, args: string[]): Record<string, unkno
const params: Record<string, unknown> = {};
const positional = op.cliHints?.positional || [];
let posIdx = 0;
// #2822: track which params came from positionals so a later flag that
// silently discards one (`gbrain put CONTENT --slug foo` — CONTENT was
// parsed as the slug) gets a stderr warning instead of vanishing.
const positionallySet = new Set<string>();
for (let i = 0; i < args.length; i++) {
const arg = args[i];
@@ -788,20 +778,13 @@ export function parseOpArgs(op: Operation, args: string[]): Record<string, unkno
if (paramDef?.type === 'boolean') {
params[key] = true;
} else if (i + 1 < args.length) {
if (positionallySet.has(key) && params[key] !== args[i + 1]) {
console.error(`Warning: ${arg} overrides the positional <${key}> value ${JSON.stringify(params[key])}.`);
}
params[key] = args[++i];
if (paramDef?.type === 'number') params[key] = Number(params[key]);
}
} else if (posIdx < positional.length) {
const key = positional[posIdx++];
const paramDef = op.params[key];
if (params[key] !== undefined && params[key] !== (paramDef?.type === 'number' ? Number(arg) : arg)) {
console.error(`Warning: positional <${key}> overrides the earlier --${key.replace(/_/g, '-')} value ${JSON.stringify(params[key])}.`);
}
params[key] = paramDef?.type === 'number' ? Number(arg) : arg;
positionallySet.add(key);
}
}
@@ -813,11 +796,7 @@ export function parseOpArgs(op: Operation, args: string[]): Record<string, unkno
console.error(`Error: stdin content exceeds ${MAX_STDIN} bytes. Split into smaller inputs.`);
process.exit(1);
}
// #2822: empty/whitespace-only stdin (cron with no input, broken pipe)
// stays UNSET so the required-param check rejects the call instead of
// silently writing an empty page (0 chunks, invisible to search and
// embed --stale).
if (stdinContent.trim().length > 0) params[op.cliHints.stdin] = stdinContent;
params[op.cliHints.stdin] = stdinContent;
}
return params;
+15 -5
View File
@@ -43,7 +43,7 @@ import {
} from '../core/link-extraction.ts';
import { createProgress } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
import { pathToSlug, pruneDir, isSyncable } from '../core/sync.ts';
import { pathToSlug, slugifyPath, pruneDir, isSyncable } from '../core/sync.ts';
// v0.41.18.0: withRetry + isRetryableConnError + WithRetryOpts moved to
// src/core/retry.ts as the canonical primitive. Engine methods
// (addLinksBatch/addTimelineEntriesBatch/upsertChunks) now self-retry via
@@ -269,14 +269,24 @@ export function extractMarkdownLinks(content: string): { name: string; relTarget
export function resolveSlug(fileDir: string, relTarget: string, allSlugs: Set<string>): string | null {
const targetNoExt = relTarget.endsWith('.md') ? relTarget.slice(0, -3) : relTarget;
const s1 = join(fileDir, targetNoExt);
if (allSlugs.has(s1)) return s1;
// Issue #1964: wikilinks carry raw Obsidian paths (`[[llm-wiki/entities/AI 3.0]]`)
// but allSlugs holds sync-slugified slugs (`llm-wiki/entities/ai-3.0`). Try the
// raw candidate first (back-compat), then the sync-consistent slugified form.
const hit = (candidate: string): string | null => {
if (allSlugs.has(candidate)) return candidate;
const slugified = slugifyPath(candidate);
if (slugified !== candidate && allSlugs.has(slugified)) return slugified;
return null;
};
const s1 = hit(join(fileDir, targetNoExt));
if (s1) return s1;
const parts = fileDir.split('/').filter(Boolean);
for (let strip = 1; strip <= parts.length; strip++) {
const ancestor = parts.slice(0, parts.length - strip).join('/');
const candidate = ancestor ? join(ancestor, targetNoExt) : targetNoExt;
if (allSlugs.has(candidate)) return candidate;
const candidate = hit(ancestor ? join(ancestor, targetNoExt) : targetNoExt);
if (candidate) return candidate;
}
return null;
-1
View File
@@ -48,7 +48,6 @@ const FRONTMATTER_RULE_NAMES: Record<ParseValidationCode, string> = {
NESTED_QUOTES: 'frontmatter-nested-quotes',
NON_STRING_FIELD: 'frontmatter-non-string-field',
EMPTY_FRONTMATTER: 'frontmatter-empty',
MULTI_FRONTMATTER: 'frontmatter-multi',
};
/** Codes whose lint findings are fixable by `gbrain frontmatter validate --fix`. */
+1 -22
View File
@@ -301,17 +301,6 @@ export async function importFromContent(
// silently fabricated a duplicate at (default, slug) — causing later
// bare-slug subqueries (getTags, deleteChunks, etc.) to crash with 21000.
const sourceId = opts.sourceId;
// #2822: reject empty/whitespace-only content before any work happens. An
// empty page writes 0 chunks — invisible to search AND to `embed --stale`
// (nothing to embed), so the mistake never surfaces. Empty content is
// always a caller bug (empty piped stdin, bad shell substitution). Thrown
// (not returned) so every wrapper site surfaces the message, matching the
// ContentSanityBlockError flow.
if (content.trim().length === 0) {
throw new Error(
`Content for "${slug}" is empty; refusing to write an empty page (0 chunks would be invisible to search and embed --stale).`,
);
}
// Reject oversized payloads before any parsing, chunking, or embedding happens.
// Uses Buffer.byteLength to count UTF-8 bytes the same way disk size would,
// so the network path behaves identically to the file path.
@@ -325,17 +314,7 @@ export async function importFromContent(
};
}
const parsed = parseMarkdown(content, slug + '.md', { activePack: opts.activePack, validate: true });
// #2743: reject stacked frontmatter (the double-put corruption class —
// already-serialized markdown re-wrapped in fresh frontmatter). gray-matter
// parses only the first block; the second would land verbatim in the body
// and poison every subsequent round-trip. Only MULTI_FRONTMATTER rejects
// here — the other validation codes keep their lint-only semantics.
const multiFm = parsed.errors?.find(e => e.code === 'MULTI_FRONTMATTER');
if (multiFm) {
throw new Error(`MULTI_FRONTMATTER: ${multiFm.message} (slug "${slug}", line ${multiFm.line})`);
}
const parsed = parseMarkdown(content, slug + '.md', { activePack: opts.activePack });
// v0.42 (#1699 trust boundary): strip gate-owned markers from UNTRUSTED
// input. parseMarkdown preserves every frontmatter key except type/title/
+20 -4
View File
@@ -14,6 +14,7 @@
import type { BrainEngine } from './engine.ts';
import type { PageType } from './types.ts';
import { ensureWellFormed } from './text-safe.ts';
import { slugifyPath } from './sync.ts';
/**
* v0.42.7 — link-extraction version stamp. Bump this ISO timestamp whenever the
@@ -482,14 +483,29 @@ export async function extractPageLinks(
// pre-v0.40.8.2 behavior of dropping bare wikilinks outside
// DIR_PATTERN.
if (ref.needsResolution) {
if (!opts.globalBasename || typeof resolver.resolveBasenameMatches !== 'function') {
continue;
}
if (typeof resolver.resolveBasenameMatches !== 'function') continue;
// Issue #972 (codex): resolve by the wikilink TARGET (ref.slug — the
// text inside `[[...]]` before any `|`), NOT the display alias
// (ref.name = match[2]). `[[struktura|the project]]` must resolve
// `struktura`, not "the project". The display text is for context only.
const matches = await resolver.resolveBasenameMatches(ref.slug);
//
// Issue #1964: a dir-qualified wikilink (`[[llm-wiki/entities/AI 3.0]]`)
// carries a raw Obsidian path while page slugs are sync-slugified
// (`llm-wiki/entities/ai-3.0`). Slugify the path the same way sync does,
// then match by exact slug or path-suffix (wiki-root-relative authoring).
// This runs regardless of global_basename — it's dir-qualified, so the
// cross-dir false-positive risk the flag guards against doesn't apply.
// Mirrors the FS path's resolveSlug ancestor walk. Bare `[[name]]`
// wikilinks still require the global_basename flag.
let matches: string[] = [];
const slugified = ref.slug.includes('/') ? slugifyPath(ref.slug) : '';
if (slugified.includes('/')) {
const tail = slugified.slice(slugified.lastIndexOf('/') + 1);
matches = (await resolver.resolveBasenameMatches(tail))
.filter(m => m === slugified || m.endsWith(`/${slugified}`));
} else if (opts.globalBasename) {
matches = await resolver.resolveBasenameMatches(ref.slug);
}
if (matches.length === 0) continue;
const idx = content.indexOf(ref.slug);
const context = idx >= 0 ? excerpt(content, idx, 240) : ref.name;
+1 -46
View File
@@ -11,8 +11,7 @@ export type ParseValidationCode =
| 'NULL_BYTES'
| 'NESTED_QUOTES'
| 'NON_STRING_FIELD'
| 'EMPTY_FRONTMATTER'
| 'MULTI_FRONTMATTER';
| 'EMPTY_FRONTMATTER';
export interface ParseValidationError {
code: ParseValidationCode;
@@ -332,50 +331,6 @@ function collectValidationErrors(
});
}
}
// 9. MULTI_FRONTMATTER (#2743) — a second ---…--- block right after the
// closing fence is stacked frontmatter (the double-put corruption class:
// already-serialized markdown re-wrapped in fresh frontmatter).
// gray-matter parses only the first block and silently leaves the second
// in the body. Heuristic: first non-empty line after the close is `---`,
// a later `---` closes it, EVERY line between is frontmatter-shaped
// (YAML `key:`, `- ` list item, `#` comment, indented continuation, or
// blank — the issue's "stop at the first non-frontmatter character"
// spec), and at least one is a `key:` line. A lone `---` stays a
// markdown horizontal rule, and an hrule followed by prose — even
// colon-prefixed prose like `Note: …` mixed with plain lines — is body
// content, not a stacked block.
let afterClose = closeLine + 1;
while (afterClose < lines.length && lines[afterClose].trim().length === 0) afterClose++;
if (afterClose < lines.length && lines[afterClose].trim() === '---') {
let secondClose = -1;
for (let i = afterClose + 1; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (trimmed === '---') {
secondClose = i;
break;
}
const yamlShaped =
trimmed.length === 0 ||
/^[A-Za-z_][\w-]*\s*:/.test(trimmed) ||
trimmed.startsWith('- ') ||
trimmed === '-' ||
trimmed.startsWith('#') ||
/^\s/.test(lines[i]);
if (!yamlShaped) break; // first non-frontmatter line → body prose, not a stacked block
}
if (
secondClose > afterClose + 1 &&
lines.slice(afterClose + 1, secondClose).some(l => /^\s*[A-Za-z_][\w-]*\s*:/.test(l))
) {
errors.push({
code: 'MULTI_FRONTMATTER',
message:
'Stacked frontmatter: a second ---…--- block follows the frontmatter (double-put corruption); merge into a single frontmatter block',
line: afterClose + 1,
});
}
}
}
/**
+1 -70
View File
@@ -1,8 +1,4 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'child_process';
import { join, resolve } from 'path';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { describe, expect, test } from 'bun:test';
import { parseOpArgs } from '../src/cli.ts';
import { operationsByName } from '../src/core/operations.ts';
@@ -24,70 +20,5 @@ describe('parseOpArgs', () => {
source_id: 'gstack-code-repo-0e4763c9',
});
});
describe('positional/flag overwrite warning (#2822)', () => {
const errors: string[] = [];
const origError = console.error;
const captureErrors = () => {
console.error = (...args: unknown[]) => errors.push(args.join(' '));
};
afterEach(() => {
console.error = origError;
errors.length = 0;
});
test('a flag that overwrites a positional value warns to stderr', () => {
captureErrors();
const params = parseOpArgs(operationsByName.query, ['positional text', '--query', 'flag text']);
expect(params.query).toBe('flag text');
expect(errors.some(e => e.includes('Warning') && e.includes('--query'))).toBe(true);
});
test('a positional that overwrites an earlier flag value warns to stderr', () => {
captureErrors();
const params = parseOpArgs(operationsByName.query, ['--query', 'flag text', 'positional text']);
expect(params.query).toBe('positional text');
expect(errors.some(e => e.includes('Warning') && e.includes('<query>'))).toBe(true);
});
test('no warning when flag and positional agree', () => {
captureErrors();
parseOpArgs(operationsByName.query, ['same', '--query', 'same']);
expect(errors).toEqual([]);
});
});
});
describe('gbrain put — empty non-TTY stdin rejects (#2822)', () => {
const REPO = resolve(import.meta.dir, '..');
const CLI = join(REPO, 'src', 'cli.ts');
const runPut = (input: string) => {
// Isolated HOME so a regression can never write into a real brain.
const home = mkdtempSync(join(tmpdir(), 'gbrain-put-empty-'));
try {
return spawnSync('bun', [CLI, 'put', 'inbox/empty-stdin-test'], {
stdio: ['pipe', 'pipe', 'pipe'],
input,
encoding: 'utf-8',
timeout: 60_000,
env: { ...process.env, HOME: home, GBRAIN_SKIP_STARTUP_HOOKS: '1' },
});
} finally {
rmSync(home, { recursive: true, force: true });
}
};
test('empty stdin exits 1 and names the missing content param', () => {
const res = runPut('');
expect(res.status).toBe(1);
expect(res.stderr).toContain('content');
expect(res.stderr).toContain('stdin');
}, 90_000);
test('whitespace-only stdin also exits 1', () => {
const res = runPut(' \n\t\n');
expect(res.status).toBe(1);
expect(res.stderr).toContain('stdin');
}, 90_000);
});
+27
View File
@@ -389,6 +389,33 @@ describe('resolveSlugAll', () => {
});
});
// ─── issue #1964: cross-directory wikilinks — slug/path mismatch ──────────
describe('issue #1964: raw Obsidian wikilink paths resolve to sync-slugified slugs', () => {
test('resolveSlug slugifies the candidate (sync-consistent), no flag needed', () => {
const all = new Set(['llm-wiki/entities/ai-3.0']);
// Wikilink literal `[[llm-wiki/entities/AI 3.0]]` — spaces + uppercase.
expect(resolveSlug('llm-wiki/notes', 'llm-wiki/entities/AI 3.0.md', all))
.toBe('llm-wiki/entities/ai-3.0');
});
test('resolveSlug slugifies raw (unslugified) fileDir too', () => {
const all = new Set(['llm-wiki/entities/ai-3.0']);
// fileDir comes from dirname(relPath) — the raw on-disk directory.
expect(resolveSlug('LLM Wiki/Notes', 'entities/AI 3.0.md', all))
.toBe('llm-wiki/entities/ai-3.0');
});
test('extractLinksFromFile resolves cross-directory wikilink with flag OFF as a typed edge', async () => {
const allSlugs = new Set(['llm-wiki/entities/ai-3.0', 'llm-wiki/notes/roadmap']);
const content = '---\ntitle: Roadmap\ntype: concept\n---\n\nSee [[llm-wiki/entities/AI 3.0]].\n';
const links = await extractLinksFromFile(content, 'llm-wiki/notes/roadmap.md', allSlugs);
expect(links.map(l => l.to_slug)).toEqual(['llm-wiki/entities/ai-3.0']);
// Dir-qualified path resolution is exact, NOT the basename fallback.
expect(links[0].link_type).not.toBe('wikilink_basename');
});
});
describe('issue #972 repro: bare wikilinks resolve when flag is on', () => {
// End-to-end: reproduces the issue's exact repro inside a tempdir +
// PGLite, then asserts edge count under both flag states.
-29
View File
@@ -708,32 +708,3 @@ body unchanged
expect(shortCircuited).toBe(true);
});
});
describe('importFromContent — empty content guard (#2822)', () => {
test('empty string throws instead of writing an invisible 0-chunk page', async () => {
const engine = mockEngine();
await expect(importFromContent(engine, 'inbox/empty', '', { noEmbed: true })).rejects.toThrow(/empty/i);
expect((engine as any)._calls.find((c: any) => c.method === 'putPage')).toBeUndefined();
});
test('whitespace-only content throws', async () => {
const engine = mockEngine();
await expect(importFromContent(engine, 'inbox/ws', ' \n\t \n', { noEmbed: true })).rejects.toThrow(/empty/i);
});
});
describe('importFromContent — stacked frontmatter rejection (#2743)', () => {
test('double-put shaped content (two ---…--- blocks) throws MULTI_FRONTMATTER', async () => {
const engine = mockEngine();
const md = '---\ntitle: outer\n---\n\n---\ntitle: inner\ntype: concept\n---\n\nreal body';
await expect(importFromContent(engine, 'inbox/double', md, { noEmbed: true })).rejects.toThrow(/MULTI_FRONTMATTER/);
expect((engine as any)._calls.find((c: any) => c.method === 'putPage')).toBeUndefined();
});
test('normal content with horizontal rules in the body still imports', async () => {
const engine = mockEngine();
const md = '---\ntitle: ok\ntype: concept\n---\n\nprose before\n\n---\n\nprose after the rule';
const result = await importFromContent(engine, 'inbox/hrule', md, { noEmbed: true });
expect(result.status).toBe('imported');
});
});
+42
View File
@@ -9,6 +9,8 @@ import {
parseTimelineEntries,
isAutoLinkEnabled,
FRONTMATTER_LINK_MAP,
buildBasenameIndex,
queryBasenameIndex,
type SlugResolver,
} from '../src/core/link-extraction.ts';
import type { BrainEngine } from '../src/core/engine.ts';
@@ -424,6 +426,46 @@ describe('extractPageLinks', () => {
expect(strk!.linkType).toBe('wikilink_basename');
});
// ─── issue #1964: dir-qualified wikilinks with raw Obsidian paths ────────
test('#1964: dir-qualified wikilink resolves via sync-consistent slugification (flag OFF)', async () => {
// `[[llm-wiki/entities/AI 3.0]]` is a raw Obsidian path; the page slug
// is the sync-slugified `llm-wiki/entities/ai-3.0`. Must resolve WITHOUT
// global_basename (it's dir-qualified) and must NOT leak to a same-tail
// page in a different directory.
const resolver: SlugResolver = {
resolve: async () => null,
resolveBasenameMatches: async (name) =>
name === 'ai-3.0' ? ['other/ai-3.0', 'llm-wiki/entities/ai-3.0'] : [],
};
const { candidates } = await extractPageLinks(
'llm-wiki/notes/roadmap',
'See [[llm-wiki/entities/AI 3.0]] for the model.',
{}, 'concept', resolver,
// opts.globalBasename omitted (= false) — path is dir-qualified
);
expect(candidates.map(c => c.targetSlug)).toEqual(['llm-wiki/entities/ai-3.0']);
expect(candidates[0].linkType).toBe('wikilink_basename');
expect(candidates[0].linkSource).toBe('wikilink-resolved');
});
test('#1964: path-suffix match resolves wiki-root-relative paths against a real index', async () => {
// Author writes `[[llm-wiki/entities/AI 3.0]]` but the brain nests the
// wiki under a vault dir. Suffix match rescues it; queried through the
// REAL basename index so the tail-key lookup is exercised end to end.
const idx = buildBasenameIndex(['vault/llm-wiki/entities/ai-3.0', 'people/ai-3.0']);
const resolver: SlugResolver = {
resolve: async () => null,
resolveBasenameMatches: async (name) => queryBasenameIndex(idx, name),
};
const { candidates } = await extractPageLinks(
'vault/llm-wiki/notes/roadmap',
'See [[llm-wiki/entities/AI 3.0]].',
{}, 'concept', resolver,
);
expect(candidates.map(c => c.targetSlug)).toEqual(['vault/llm-wiki/entities/ai-3.0']);
});
test('opts.skipFrontmatter suppresses the frontmatter pass', async () => {
// Real resolver shape that WOULD resolve frontmatter source: too,
// but skipFrontmatter blocks the path entirely.
-50
View File
@@ -256,56 +256,6 @@ body`;
});
});
describe('MULTI_FRONTMATTER (#2743)', () => {
test('stacked frontmatter immediately after the close fence', () => {
const md = `${fence}\ntitle: outer\n${fence}\n${fence}\ntitle: inner\ntype: concept\n${fence}\n\nbody`;
const parsed = parseMarkdown(md, undefined, { validate: true });
expect(parsed.errors!.map(e => e.code)).toContain('MULTI_FRONTMATTER');
});
test('stacked frontmatter with a blank line between blocks (serializeMarkdown shape)', () => {
const md = `${fence}\ntitle: outer\n${fence}\n\n${fence}\ntitle: inner\n${fence}\n\nbody`;
const parsed = parseMarkdown(md, undefined, { validate: true });
expect(parsed.errors!.map(e => e.code)).toContain('MULTI_FRONTMATTER');
});
test('horizontal rules in the body are NOT flagged', () => {
const md = `${fence}\ntitle: ok\n${fence}\n\nsome prose\n\n${fence}\n\nmore prose\n\n${fence}\n\nend`;
const parsed = parseMarkdown(md, undefined, { validate: true });
expect(parsed.errors!.map(e => e.code)).not.toContain('MULTI_FRONTMATTER');
});
test('hrule pair at body start without YAML-shaped lines is NOT flagged', () => {
const md = `${fence}\ntitle: ok\n${fence}\n\n${fence}\n\nplain prose between rules\n\n${fence}\n\nend`;
const parsed = parseMarkdown(md, undefined, { validate: true });
expect(parsed.errors!.map(e => e.code)).not.toContain('MULTI_FRONTMATTER');
});
test('timeline sentinel form is NOT flagged', () => {
const md = `${fence}\ntitle: ok\n${fence}\n\nbody text\n\n${fence}\n\n## Timeline\n- 2024-01-01: thing`;
const parsed = parseMarkdown(md, undefined, { validate: true });
expect(parsed.errors!.map(e => e.code)).not.toContain('MULTI_FRONTMATTER');
});
test('body hrule + colon-prefixed prose (`Note: …`) mixed with plain lines is NOT flagged', () => {
const md = `${fence}\ntitle: ok\ntype: concept\n${fence}\n\n${fence}\n\nNote: remember to follow up\n\nlots of plain prose here\n\n${fence}\n\nmore prose`;
const parsed = parseMarkdown(md, undefined, { validate: true });
expect(parsed.errors!.map(e => e.code)).not.toContain('MULTI_FRONTMATTER');
});
test('fence pairing stops at the first non-frontmatter line (no far-fence pairing across prose)', () => {
const md = `${fence}\ntitle: ok\n${fence}\n\n${fence}\n\n${'plain prose line\n'.repeat(40)}TODO: fix the widget\n${'more prose\n'.repeat(40)}${fence}\nend`;
const parsed = parseMarkdown(md, undefined, { validate: true });
expect(parsed.errors!.map(e => e.code)).not.toContain('MULTI_FRONTMATTER');
});
test('stacked block with list-valued keys is still flagged', () => {
const md = `${fence}\ntitle: outer\n${fence}\n\n${fence}\ntitle: inner\ntags:\n - a\n - b\n${fence}\n\nbody`;
const parsed = parseMarkdown(md, undefined, { validate: true });
expect(parsed.errors!.map(e => e.code)).toContain('MULTI_FRONTMATTER');
});
});
test('error.line is set for line-bearing errors', () => {
const md = `${fence}\ntype: concept\n${fence}\n# Heading inline\n\nbody\x00drop`;
const parsed = parseMarkdown(md, undefined, { validate: true });