From 1a9ab6a95f340e8a66ed8baf861da233080ab53c Mon Sep 17 00:00:00 2001 From: alessioalionco Date: Thu, 23 Jul 2026 06:08:58 -0300 Subject: [PATCH] fix(frontmatter): derive validate slug from brain root, not absolute path (#2340) Single-file `frontmatter validate` derived the expected slug from the absolute path: relative(resolve(target), file) is empty when target IS the file, so it fell back to `|| file` (the full path), yielding "root/" slugs and a false SLUG_MISMATCH. The pre-commit hook from install-hook validates staged files one-by-one, so this rejected every commit in a markdown brain (only bypassable with --no-verify). Walk up to the brain root (nearest .git) and use relative(brainRoot, file) || basename(file), matching runAudit/runGenerate and sync/extract. Files above the root fall back to basename instead of a ../-prefixed slug. Reopens #565. Present since v0.32.0; reproduced on v0.42.51. Co-authored-by: Claude Opus 4.8 (1M context) --- src/commands/frontmatter.ts | 29 +++++++++- test/frontmatter-validate-slug-565.test.ts | 65 ++++++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 test/frontmatter-validate-slug-565.test.ts diff --git a/src/commands/frontmatter.ts b/src/commands/frontmatter.ts index 951e35905..3e8e93d2a 100644 --- a/src/commands/frontmatter.ts +++ b/src/commands/frontmatter.ts @@ -17,7 +17,7 @@ import { readFileSync, writeFileSync, existsSync, lstatSync, readdirSync } from 'fs'; import { setCliExitVerdict } from '../core/cli-force-exit.ts'; -import { join, relative, resolve } from 'path'; +import { join, relative, resolve, basename, dirname } from 'path'; import type { BrainEngine } from '../core/engine.ts'; import { loadConfig, toEngineConfig } from '../core/config.ts'; import { createEngine } from '../core/engine-factory.ts'; @@ -155,6 +155,27 @@ interface FileValidation { backupPath?: string; } +/** + * Walk up from `start` (file or dir) to the brain root — the nearest ancestor + * containing a `.git` marker — so slug derivation is brain-root-relative, + * matching how sync/extract compute slugs. Falls back to the start's own + * directory when no marker is found. Fixes #565: for a single-file target, + * `relative(resolve(target), file)` was empty (target === file) and fell back + * to the ABSOLUTE path, yielding bogus "root/brain/..." slugs and false + * SLUG_MISMATCH — which the install-hook pre-commit hook hits on every commit. + */ +function findBrainRoot(start: string): string { + const startDir = lstatSync(start).isDirectory() ? start : dirname(start); + let candidate = startDir; + for (let i = 0; i < 40; i++) { + if (existsSync(join(candidate, '.git'))) return candidate; + const parent = resolve(candidate, '..'); + if (parent === candidate) break; + candidate = parent; + } + return startDir; +} + async function runValidate(rest: string[]): Promise { const flags: ValidateFlags = { json: false, fix: false, dryRun: false }; let target: string | null = null; @@ -177,13 +198,17 @@ async function runValidate(rest: string[]): Promise { return; } + const brainRoot = findBrainRoot(resolved); const files = collectFiles(resolved); const results: FileValidation[] = []; const backupRunId = makeFrontmatterBackupRunId(); for (const file of files) { const content = readFileSync(file, 'utf8'); - const expectedSlug = slugifyPath(relative(resolve(target), file) || file); + const rel = relative(brainRoot, file); + // Files above/outside the brain root fall back to basename rather than + // emitting a "../"-prefixed slug for non-brain files. + const expectedSlug = slugifyPath(rel && !rel.startsWith('..') ? rel : basename(file)); const parsed = parseMarkdown(content, file, { validate: true, expectedSlug }); const errs = parsed.errors ?? []; const result: FileValidation = { diff --git a/test/frontmatter-validate-slug-565.test.ts b/test/frontmatter-validate-slug-565.test.ts new file mode 100644 index 000000000..e6289146a --- /dev/null +++ b/test/frontmatter-validate-slug-565.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { spawnSync } from 'child_process'; + +const fence = '---'; + +function runValidate(path: string): { stdout: string; code: number } { + const r = spawnSync(process.execPath, ['run', 'src/cli.ts', 'frontmatter', 'validate', path], { + encoding: 'utf8', + cwd: process.cwd(), + env: process.env, + }); + return { stdout: r.stdout ?? '', code: r.status ?? -1 }; +} + +// Regression for #565. Single-file `frontmatter validate` derived the expected +// slug from the ABSOLUTE path: `relative(resolve(target), file)` is empty when +// the target IS the file, so it fell back to `|| file` (the full path), +// yielding bogus "root/" slugs and a false SLUG_MISMATCH. The hook +// installed by `frontmatter install-hook` validates staged files one-by-one, +// so this rejected every commit in a markdown brain. The expected slug must be +// derived relative to the brain root (nearest `.git`). +describe('frontmatter validate single-file slug (#565)', () => { + let brain: string; + + beforeEach(() => { + brain = mkdtempSync(join(tmpdir(), 'fm-565-')); + mkdirSync(join(brain, '.git'), { recursive: true }); // brain-root marker + }); + + afterEach(() => { + rmSync(brain, { recursive: true, force: true }); + }); + + test('single file with a correct nested slug validates clean', () => { + mkdirSync(join(brain, 'companies'), { recursive: true }); + const f = join(brain, 'companies', 'readme.md'); + writeFileSync(f, `${fence}\ntype: company\ntitle: Readme\nslug: companies/readme\n${fence}\n\nbody`); + const { stdout, code } = runValidate(f); + expect(stdout).not.toContain('SLUG_MISMATCH'); + expect(code).toBe(0); + }); + + test('directory validation still derives brain-root-relative slugs', () => { + mkdirSync(join(brain, 'people'), { recursive: true }); + writeFileSync( + join(brain, 'people', 'alice.md'), + `${fence}\ntype: person\ntitle: Alice\nslug: people/alice\n${fence}\n\nbody`, + ); + const { stdout, code } = runValidate(join(brain, 'people')); + expect(stdout).not.toContain('SLUG_MISMATCH'); + expect(code).toBe(0); + }); + + test('file with no .git ancestor falls back to basename (no crash, no abs-path slug)', () => { + rmSync(join(brain, '.git'), { recursive: true, force: true }); + const f = join(brain, 'note.md'); + writeFileSync(f, `${fence}\ntype: note\ntitle: Note\nslug: note\n${fence}\n\nbody`); + const { stdout, code } = runValidate(f); + expect(stdout).not.toContain('SLUG_MISMATCH'); + expect(code).toBe(0); + }); +});