mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
Aligns the auto-fix engine, the inferred-frontmatter serializer, and the agent-facing skill on a single canonical YAML shape for tag arrays. v0.37.5.0 fixed the validator (it stopped flagging valid YAML); this release lines up everything else with that fix. Layer 1 (brain-writer.ts step 3a): allow-listed to `tags:` / `aliases:` keys. Rewrites `tags: ["yc"]` to `tags: ['yc']`; apostrophe fallback for `"Men's Fashion"`. Shares a NESTED_QUOTES dedup gate with the existing step 3 so one file with both rewrites surfaces as one audit entry, not two. Layer 4 (frontmatter-inference.ts): serializer emits the same canonical single-quote form by default. Inferred frontmatter on import and `--fix` output now match byte-for-byte. Layer 5 (frontmatter-guard SKILL.md): new "Prevention" section showing canonical vs JSON-style arrays + the JSON.stringify trap that produces the non-canonical form. Future agent writes start canonical. Parity test added to markdown-validation.test.ts pinning agreement between per-value safeLoad parsing and gray-matter full-document parse on the load-bearing inputs. PR #1238's "Layer 3" (put_page auto-normalization) was dropped during plan review: put_page parses YAML into typed fields and hashes them, so single-quoted vs double-quoted arrays are functionally identical in storage. The fix lives where the writes happen, not on the read path. Source PRs absorbed: #1217 (closed, serializer fix) + #1238 (closed, four-layer defense-in-depth narrowed to three layers). PR #1229 already merged as v0.37.5.0. Co-authored-by: garrytan-agents <garrytan-agents@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
315 lines
11 KiB
TypeScript
315 lines
11 KiB
TypeScript
/**
|
|
* Tests for frontmatter-inference.ts — the zero-friction ingest pipeline.
|
|
*
|
|
* Validates that files without frontmatter get correct type, title, date,
|
|
* source, and tags inferred from their filesystem path and content.
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import {
|
|
inferFrontmatter,
|
|
extractDateFromFilename,
|
|
extractTitleFromFilename,
|
|
extractTitleFromHeading,
|
|
serializeFrontmatter,
|
|
applyInference,
|
|
DIRECTORY_RULES,
|
|
} from '../src/core/frontmatter-inference.ts';
|
|
|
|
// ── Date extraction ──────────────────────────────────────────────────
|
|
|
|
describe('extractDateFromFilename', () => {
|
|
test('extracts YYYY-MM-DD from date-prefixed filename', () => {
|
|
expect(extractDateFromFilename('2010-04-13 Apr 13 founders mtg.md')).toBe('2010-04-13');
|
|
});
|
|
|
|
test('extracts date with dash separator', () => {
|
|
expect(extractDateFromFilename('2024-01-30-therapy-session.md')).toBe('2024-01-30');
|
|
});
|
|
|
|
test('extracts date with underscore separator', () => {
|
|
expect(extractDateFromFilename('2023-06-15_meeting-notes.md')).toBe('2023-06-15');
|
|
});
|
|
|
|
test('returns null for no-date filename', () => {
|
|
expect(extractDateFromFilename('README.md')).toBe(null);
|
|
});
|
|
|
|
test('returns null for filename with numbers but no date', () => {
|
|
expect(extractDateFromFilename('chapter-1-intro.md')).toBe(null);
|
|
});
|
|
});
|
|
|
|
// ── Title extraction ─────────────────────────────────────────────────
|
|
|
|
describe('extractTitleFromFilename', () => {
|
|
test('strips date prefix and cleans up', () => {
|
|
expect(extractTitleFromFilename('2010-04-13 Apr 13 founders mtg.md')).toBe('Apr 13 founders mtg');
|
|
});
|
|
|
|
test('strips YYYY-MM-DD- prefix', () => {
|
|
expect(extractTitleFromFilename('2024-01-30-therapy-session.md')).toBe('Therapy Session');
|
|
});
|
|
|
|
test('handles filename without date', () => {
|
|
expect(extractTitleFromFilename('cognitive-distortions.md')).toBe('Cognitive Distortions');
|
|
});
|
|
|
|
test('preserves mixed case', () => {
|
|
expect(extractTitleFromFilename('YC presidency.md')).toBe('YC presidency');
|
|
});
|
|
|
|
test('returns Untitled for empty result', () => {
|
|
expect(extractTitleFromFilename('.md')).toBe('Untitled');
|
|
});
|
|
});
|
|
|
|
describe('extractTitleFromHeading', () => {
|
|
test('extracts first # heading', () => {
|
|
expect(extractTitleFromHeading('# Dhravya Shah\n\n> Founder of Supermemory')).toBe('Dhravya Shah');
|
|
});
|
|
|
|
test('ignores ## headings', () => {
|
|
expect(extractTitleFromHeading('Some text\n## Not this\n# This one')).toBe('This one');
|
|
});
|
|
|
|
test('returns null when no heading found', () => {
|
|
expect(extractTitleFromHeading('Just some text\nwithout headings')).toBe(null);
|
|
});
|
|
|
|
test('looks within first 20 lines only', () => {
|
|
const lines = Array(25).fill('text').join('\n') + '\n# Too Late';
|
|
expect(extractTitleFromHeading(lines)).toBe(null);
|
|
});
|
|
});
|
|
|
|
// ── Core inference ───────────────────────────────────────────────────
|
|
|
|
describe('inferFrontmatter', () => {
|
|
test('skips files that already have frontmatter', () => {
|
|
const result = inferFrontmatter('people/alice.md', '---\ntitle: Alice\n---\n# Alice');
|
|
expect(result.skipped).toBe(true);
|
|
});
|
|
|
|
test('Apple Notes: infers type, date, title, source', () => {
|
|
const result = inferFrontmatter(
|
|
'Apple Notes/2010-04-13 Apr 13 founders mtg.md',
|
|
'<span style="color:#000ff;">Top priority</span>',
|
|
);
|
|
expect(result.type).toBe('apple-note');
|
|
expect(result.date).toBe('2010-04-13');
|
|
expect(result.title).toBe('Apr 13 founders mtg');
|
|
expect(result.source).toBe('apple-notes');
|
|
});
|
|
|
|
test('Apple Notes/YC: adds yc tag', () => {
|
|
const result = inferFrontmatter(
|
|
'Apple Notes/YC/2022-08-04 Project 1783Y.md',
|
|
'Some content',
|
|
);
|
|
expect(result.type).toBe('apple-note');
|
|
expect(result.tags).toContain('yc');
|
|
expect(result.date).toBe('2022-08-04');
|
|
});
|
|
|
|
test('Apple Notes/Politics: adds politics tag', () => {
|
|
const result = inferFrontmatter(
|
|
'Apple Notes/Politics/2023-11-15 DA race notes.md',
|
|
'Some content',
|
|
);
|
|
expect(result.tags).toContain('politics');
|
|
});
|
|
|
|
test('people/ directory: type person, title from heading', () => {
|
|
const result = inferFrontmatter(
|
|
'people/dhravya-shah.md',
|
|
'# Dhravya Shah\n\n> Founder of Supermemory',
|
|
);
|
|
expect(result.type).toBe('person');
|
|
expect(result.title).toBe('Dhravya Shah');
|
|
});
|
|
|
|
test('people/ directory: falls back to filename when no heading', () => {
|
|
const result = inferFrontmatter(
|
|
'people/john-doe.md',
|
|
'Some text without a heading',
|
|
);
|
|
expect(result.type).toBe('person');
|
|
expect(result.title).toBe('John Doe');
|
|
});
|
|
|
|
test('personal/therapy: infers therapy-session type with date', () => {
|
|
const result = inferFrontmatter(
|
|
'personal/therapy/jan/2024-01-30.md',
|
|
'Session notes...',
|
|
);
|
|
expect(result.type).toBe('therapy-session');
|
|
expect(result.date).toBe('2024-01-30');
|
|
expect(result.source).toBe('therapy');
|
|
});
|
|
|
|
test('personal/reflections: infers reflection type, title from heading', () => {
|
|
const result = inferFrontmatter(
|
|
'personal/reflections/cognitive-distortions.md',
|
|
'# Cognitive Distortions\n\nA list of common...',
|
|
);
|
|
expect(result.type).toBe('reflection');
|
|
expect(result.title).toBe('Cognitive Distortions');
|
|
});
|
|
|
|
test('writing/essays: infers essay type', () => {
|
|
const result = inferFrontmatter(
|
|
'writing/essays/2024-03-15-on-being-remembered.md',
|
|
'# On Being Remembered Forever\n\nSome thoughts...',
|
|
);
|
|
expect(result.type).toBe('essay');
|
|
expect(result.title).toBe('On Being Remembered Forever');
|
|
expect(result.date).toBe('2024-03-15');
|
|
});
|
|
|
|
test('daily/calendar: infers calendar-index type', () => {
|
|
const result = inferFrontmatter(
|
|
'daily/calendar/2026-01-15-yc-office-hours.md',
|
|
'# Calendar Index\nSome calendar data',
|
|
);
|
|
expect(result.type).toBe('calendar-index');
|
|
expect(result.source).toBe('calendar');
|
|
});
|
|
|
|
test('docs/runbooks: infers guide instead of catch-all note', () => {
|
|
const result = inferFrontmatter(
|
|
'docs/runbooks/cron-management-runbook.md',
|
|
'# Cron Management Runbook\n\nUse this when changing schedules.',
|
|
);
|
|
expect(result.type).toBe('guide');
|
|
expect(result.tags).toContain('runbook');
|
|
expect(result.title).toBe('Cron Management Runbook');
|
|
});
|
|
|
|
test('docs/projects: infers project type', () => {
|
|
const result = inferFrontmatter(
|
|
'docs/projects/eva-brain.md',
|
|
'# Eva Brain\n\nProject notes.',
|
|
);
|
|
expect(result.type).toBe('project');
|
|
expect(result.tags).toContain('project');
|
|
});
|
|
|
|
test('companies/ directory: type company', () => {
|
|
const result = inferFrontmatter(
|
|
'companies/stripe.md',
|
|
'# Stripe\n\n> Online payments infrastructure',
|
|
);
|
|
expect(result.type).toBe('company');
|
|
expect(result.title).toBe('Stripe');
|
|
});
|
|
|
|
test('unknown directory: catch-all remains note when explicitly used by callers', () => {
|
|
const result = inferFrontmatter(
|
|
'random/some-file.md',
|
|
'# My Random Notes\n\nStuff here',
|
|
);
|
|
expect(result.type).toBe('note');
|
|
expect(result.title).toBe('My Random Notes');
|
|
});
|
|
|
|
test('handles empty content', () => {
|
|
const result = inferFrontmatter('notes/empty.md', '');
|
|
expect(result.type).toBe('note');
|
|
expect(result.title).toBe('Empty');
|
|
});
|
|
});
|
|
|
|
// ── Serialization ────────────────────────────────────────────────────
|
|
|
|
describe('serializeFrontmatter', () => {
|
|
test('generates valid YAML frontmatter', () => {
|
|
const fm = serializeFrontmatter({
|
|
title: 'Apr 13 founders mtg',
|
|
type: 'apple-note',
|
|
date: '2010-04-13',
|
|
source: 'apple-notes',
|
|
tags: ['yc'],
|
|
});
|
|
expect(fm).toContain('---');
|
|
expect(fm).toContain('title: Apr 13 founders mtg');
|
|
expect(fm).toContain('type: apple-note');
|
|
expect(fm).toContain('date: "2010-04-13"');
|
|
expect(fm).toContain('source: apple-notes');
|
|
// v0.37.9.0 — canonical single-quoted YAML flow. Aligns with
|
|
// brain-writer's step 3a auto-fix output. Was double-quoted pre-v0.37.9.0.
|
|
expect(fm).toContain(`tags: ['yc']`);
|
|
});
|
|
|
|
test('tags with apostrophe fall back to double quotes', () => {
|
|
const fm = serializeFrontmatter({
|
|
title: 'fashion note',
|
|
type: 'note',
|
|
tags: ["Men's Fashion", 'yc'],
|
|
});
|
|
// Apostrophe item keeps double quotes (JSON.stringify); clean item uses single.
|
|
expect(fm).toContain(`tags: ["Men's Fashion", 'yc']`);
|
|
});
|
|
|
|
test('quotes title with special chars', () => {
|
|
const fm = serializeFrontmatter({
|
|
title: 'What\'s the deal: a "primer"',
|
|
type: 'note',
|
|
});
|
|
expect(fm).toContain('title: "What\'s the deal: a \\"primer\\""');
|
|
});
|
|
|
|
test('returns empty string for skipped files', () => {
|
|
expect(serializeFrontmatter({ title: '', type: '', skipped: true })).toBe('');
|
|
});
|
|
|
|
test('omits optional fields when absent', () => {
|
|
const fm = serializeFrontmatter({ title: 'Test', type: 'note' });
|
|
expect(fm).not.toContain('date');
|
|
expect(fm).not.toContain('source');
|
|
expect(fm).not.toContain('tags');
|
|
});
|
|
});
|
|
|
|
// ── Integration ──────────────────────────────────────────────────────
|
|
|
|
describe('applyInference', () => {
|
|
test('prepends frontmatter to content without it', () => {
|
|
const { content, inferred } = applyInference(
|
|
'people/alice-smith.md',
|
|
'# Alice Smith\n\n> Founder of FooBar',
|
|
);
|
|
expect(content).toMatch(/^---\n/);
|
|
expect(content).toContain('type: person');
|
|
expect(content).toContain('title: Alice Smith');
|
|
expect(content).toContain('# Alice Smith');
|
|
expect(inferred.skipped).toBeUndefined();
|
|
});
|
|
|
|
test('returns original content for files with frontmatter', () => {
|
|
const original = '---\ntitle: Bob\n---\n# Bob';
|
|
const { content, inferred } = applyInference('people/bob.md', original);
|
|
expect(content).toBe(original);
|
|
expect(inferred.skipped).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ── Rules coverage ───────────────────────────────────────────────────
|
|
|
|
describe('DIRECTORY_RULES', () => {
|
|
test('has a catch-all rule with empty prefix', () => {
|
|
const catchAll = DIRECTORY_RULES.find(r => r.pathPrefix === '');
|
|
expect(catchAll).toBeDefined();
|
|
expect(catchAll!.type).toBe('note');
|
|
});
|
|
|
|
test('Apple Notes rules are more specific than the catch-all', () => {
|
|
const appleRules = DIRECTORY_RULES.filter(r => r.pathPrefix.startsWith('apple notes/'));
|
|
expect(appleRules.length).toBeGreaterThan(1); // subfolder rules + catch-all
|
|
// Subfolder rules should come before the generic apple notes/ rule
|
|
const ycIdx = DIRECTORY_RULES.findIndex(r => r.pathPrefix === 'apple notes/yc/');
|
|
const genericIdx = DIRECTORY_RULES.findIndex(r => r.pathPrefix === 'apple notes/');
|
|
expect(ycIdx).toBeLessThan(genericIdx);
|
|
});
|
|
});
|