mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 14:59:47 +00:00
* fix(import): coerce non-string frontmatter title/slug/type (#1939) YAML `title: 2024-06-01` parses to a Date and `title: 1458` to a number; the old `(frontmatter.X as string)` cast was a compile-time lie, so downstream `.toLowerCase()` threw and (via the importer failure gate) could wedge sync indefinitely. parseMarkdown now coerces via coerceFrontmatterString (Date -> UTC ISO date, deterministic), and the pure assessContentSanity self-protects against a non-string title. * feat(sync): bounded auto-skip failure ledger; poison file can't wedge indexing (#1939) New src/core/sync-failure-ledger.ts owns the failure store + a crash-safe, multi-source, concurrent bounded auto-skip valve. A file that fails N consecutive syncs (GBRAIN_SYNC_AUTOSKIP_AFTER, default 3) auto-skips so it can't freeze all indexing forever, while fresh failures still fail-closed and a `<head>` history-rewrite sentinel hard-blocks even with --skip-failed. - (source_id, path) keying — failures never merge across sources - success clears a path so attempts are truly consecutive - advance-before-ack ordering (a crash can't mark a file skipped while wedged) - shared applySyncFailureGate used by BOTH the incremental and full-sync gates - legacy-row normalization + duplicate collapse on load - cross-process lock + atomic temp-rename, age-based stale-lock break sync.ts re-exports the ledger for existing callers; import.ts records source-scoped and defers the bookmark to the gate under managedBookmark. * fix(doctor): sync_failures severity via one shared decision on both surfaces (#1939) Local buildChecks and remote doctorReportRemote now both route through decideSyncFailureSeverity, so a stuck bookmark escalates WARN -> FAIL consistently (oldest-open age > fail cadence, or large unresolved count), auto-skipped pages stay visible (WARN, not hidden), and the acknowledged/acknowledged_at field-split that caused drift is gone. The remote surface stays subprocess-free (file read + Date.parse only). * chore(test): add trailing newline to e5-lease-cap-ab baseline fixture * fix(sync): address adversarial review findings on the failure ledger (#1939) - #1: a parse-failed file that is later deleted/renamed-away no longer leaves a permanent open ledger row. Removed paths (filtered.deleted, renamed-from, and the "gone from disk" forward-delete skip branch) are treated as resolved so the ledger self-heals instead of aging doctor to a stuck FAIL. - #3: decideSyncFailureSeverity escalates to FAIL on OPEN (blocking) failures only — auto_skipped rows already advanced the bookmark, so they stay WARN-visible regardless of count, matching the state-machine contract. * chore: bump version and changelog (v0.42.30.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: document sync-failure ledger + auto-skip valve for v0.42.30.0 KEY_FILES.md: new src/core/sync-failure-ledger.ts entry (bounded auto-skip state machine, decideGateAction/decideSyncFailureSeverity/applySyncFailureGate, GBRAIN_SYNC_AUTOSKIP_AFTER); update sync.ts (failure store moved to ledger, re-exported), doctor.ts (sync_failures severity via shared rule on both surfaces), markdown.ts (coerceFrontmatterString), import.ts (managedBookmark). live-sync.md: poison-file auto-skip tricky-spot. Regenerated llms-full.txt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: re-bump to v0.42.31.0 (queue collision on 0.42.30.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: re-bump to v0.42.32.0 (queue collision) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
346 lines
12 KiB
TypeScript
346 lines
12 KiB
TypeScript
import { describe, test, expect } from 'bun:test';
|
|
import { parseMarkdown, serializeMarkdown, splitBody } from '../src/core/markdown.ts';
|
|
|
|
describe('Markdown Parser', () => {
|
|
test('parses frontmatter + compiled_truth + timeline (explicit sentinel)', () => {
|
|
const md = `---
|
|
type: concept
|
|
title: Do Things That Don't Scale
|
|
tags: [startups, growth]
|
|
---
|
|
|
|
Paul Graham argues that startups should do unscalable things early on.
|
|
|
|
<!-- timeline -->
|
|
|
|
- 2013-07-01: Published on paulgraham.com
|
|
- 2024-11-15: Referenced in batch kickoff talk
|
|
`;
|
|
const parsed = parseMarkdown(md);
|
|
expect(parsed.type).toBe('concept');
|
|
expect(parsed.title).toBe("Do Things That Don't Scale");
|
|
expect(parsed.tags).toEqual(['startups', 'growth']);
|
|
expect(parsed.compiled_truth).toContain('unscalable things');
|
|
expect(parsed.timeline).toContain('Published on paulgraham.com');
|
|
expect(parsed.timeline).toContain('batch kickoff talk');
|
|
});
|
|
|
|
test('handles no timeline separator', () => {
|
|
const md = `---
|
|
type: concept
|
|
title: Superlinear Returns
|
|
---
|
|
|
|
Returns in many fields are superlinear.
|
|
Performance compounds over time.
|
|
`;
|
|
const parsed = parseMarkdown(md);
|
|
expect(parsed.compiled_truth).toContain('superlinear');
|
|
expect(parsed.timeline).toBe('');
|
|
});
|
|
|
|
test('handles empty body', () => {
|
|
const md = `---
|
|
type: concept
|
|
title: Empty Page
|
|
---
|
|
`;
|
|
const parsed = parseMarkdown(md);
|
|
expect(parsed.compiled_truth).toBe('');
|
|
expect(parsed.timeline).toBe('');
|
|
});
|
|
|
|
test('removes type, title, tags from frontmatter object', () => {
|
|
const md = `---
|
|
type: concept
|
|
title: Test
|
|
tags: [a, b]
|
|
custom_field: hello
|
|
---
|
|
|
|
Content
|
|
`;
|
|
const parsed = parseMarkdown(md);
|
|
expect(parsed.frontmatter).not.toHaveProperty('type');
|
|
expect(parsed.frontmatter).not.toHaveProperty('title');
|
|
expect(parsed.frontmatter).not.toHaveProperty('tags');
|
|
expect(parsed.frontmatter).toHaveProperty('custom_field', 'hello');
|
|
});
|
|
|
|
test('infers type from file path', () => {
|
|
const md = `---
|
|
title: Someone
|
|
---
|
|
Content
|
|
`;
|
|
const parsed = parseMarkdown(md, 'people/someone.md');
|
|
expect(parsed.type).toBe('person');
|
|
});
|
|
|
|
test('infers slug from file path', () => {
|
|
const md = `---
|
|
type: concept
|
|
title: Test
|
|
---
|
|
Content
|
|
`;
|
|
const parsed = parseMarkdown(md, 'concepts/do-things-that-dont-scale.md');
|
|
expect(parsed.slug).toBe('concepts/do-things-that-dont-scale');
|
|
});
|
|
|
|
// v0.20: BrainBench / native inbox-chat-calendar Page types. These 5 directory
|
|
// heuristics exercise PageType 'email | slack | calendar-event | note | meeting'
|
|
// which were added for amara-life-v1 ingest but are useful for any gbrain user
|
|
// ingesting an inbox dump, Slack export, iCal, meeting transcript, or daily notes.
|
|
test.each([
|
|
['emails/em-0001.md', 'email'],
|
|
['email/em-0001.md', 'email'],
|
|
['slack/sl-0037.md', 'slack'],
|
|
['cal/evt-0042.md', 'calendar-event'],
|
|
['calendar/evt-0042.md', 'calendar-event'],
|
|
['notes/2026-04-standup.md', 'note'],
|
|
['note/2026-04-standup.md', 'note'],
|
|
['meetings/mtg-0003.md', 'meeting'],
|
|
['meeting/mtg-0003.md', 'meeting'],
|
|
] as const)('infers type %s -> %s', (path, expectedType) => {
|
|
const md = `---\ntitle: Fixture\n---\nBody\n`;
|
|
const parsed = parseMarkdown(md, path);
|
|
expect(parsed.type).toBe(expectedType);
|
|
});
|
|
});
|
|
|
|
describe('splitBody', () => {
|
|
test('splits at <!-- timeline --> sentinel', () => {
|
|
const body = 'Above the line\n\n<!-- timeline -->\n\nBelow the line';
|
|
const { compiled_truth, timeline } = splitBody(body);
|
|
expect(compiled_truth).toContain('Above the line');
|
|
expect(timeline).toContain('Below the line');
|
|
});
|
|
|
|
test('splits at --- timeline --- sentinel', () => {
|
|
const body = 'Above the line\n\n--- timeline ---\n\nBelow the line';
|
|
const { compiled_truth, timeline } = splitBody(body);
|
|
expect(compiled_truth).toContain('Above the line');
|
|
expect(timeline).toContain('Below the line');
|
|
});
|
|
|
|
test('splits at --- when followed by ## Timeline heading', () => {
|
|
const body = 'Article content\n\n---\n\n## Timeline\n\n- 2024: Event happened';
|
|
const { compiled_truth, timeline } = splitBody(body);
|
|
expect(compiled_truth).toContain('Article content');
|
|
expect(timeline).toContain('## Timeline');
|
|
expect(timeline).toContain('Event happened');
|
|
});
|
|
|
|
test('splits at --- when followed by ## History heading', () => {
|
|
const body = 'Article content\n\n---\n\n## History\n\n- 2020: Founded';
|
|
const { compiled_truth, timeline } = splitBody(body);
|
|
expect(compiled_truth).toContain('Article content');
|
|
expect(timeline).toContain('## History');
|
|
});
|
|
|
|
test('does NOT split at plain --- (horizontal rule in article body)', () => {
|
|
const body = 'Above the line\n\n---\n\nBelow the line';
|
|
const { compiled_truth, timeline } = splitBody(body);
|
|
expect(compiled_truth).toBe(body);
|
|
expect(timeline).toBe('');
|
|
});
|
|
|
|
test('does NOT split on multiple plain --- horizontal rules', () => {
|
|
const body = 'Section 1\n\n---\n\nSection 2\n\n---\n\nSection 3';
|
|
const { compiled_truth, timeline } = splitBody(body);
|
|
expect(compiled_truth).toBe(body);
|
|
expect(timeline).toBe('');
|
|
});
|
|
|
|
test('returns all as compiled_truth if no sentinel', () => {
|
|
const body = 'Just some content\nWith multiple lines';
|
|
const { compiled_truth, timeline } = splitBody(body);
|
|
expect(compiled_truth).toBe(body);
|
|
expect(timeline).toBe('');
|
|
});
|
|
|
|
test('plain --- at end of content stays in compiled_truth', () => {
|
|
const body = 'Content here\n\n---\n';
|
|
const { compiled_truth, timeline } = splitBody(body);
|
|
expect(compiled_truth).toBe(body);
|
|
expect(timeline).toBe('');
|
|
});
|
|
|
|
test('<!-- timeline --> with content before and after', () => {
|
|
const body = '## Summary\n\nArticle summary here.\n\n---\n\nMore body content.\n\n<!-- timeline -->\n\n- 2024: Timeline entry';
|
|
const { compiled_truth, timeline } = splitBody(body);
|
|
expect(compiled_truth).toContain('## Summary');
|
|
expect(compiled_truth).toContain('More body content.');
|
|
expect(compiled_truth).not.toContain('Timeline entry');
|
|
expect(timeline).toContain('Timeline entry');
|
|
});
|
|
});
|
|
|
|
describe('serializeMarkdown', () => {
|
|
test('round-trips through parse and serialize (explicit sentinel)', () => {
|
|
const original = `---
|
|
type: concept
|
|
title: Do Things That Don't Scale
|
|
tags:
|
|
- startups
|
|
- growth
|
|
custom: value
|
|
---
|
|
|
|
Paul Graham argues that startups should do unscalable things early on.
|
|
|
|
<!-- timeline -->
|
|
|
|
- 2013-07-01: Published on paulgraham.com
|
|
`;
|
|
const parsed = parseMarkdown(original);
|
|
const serialized = serializeMarkdown(
|
|
parsed.frontmatter,
|
|
parsed.compiled_truth,
|
|
parsed.timeline,
|
|
{ type: parsed.type, title: parsed.title, tags: parsed.tags },
|
|
);
|
|
|
|
// Re-parse the serialized version
|
|
const reparsed = parseMarkdown(serialized);
|
|
expect(reparsed.type).toBe(parsed.type);
|
|
expect(reparsed.title).toBe(parsed.title);
|
|
expect(reparsed.compiled_truth).toBe(parsed.compiled_truth);
|
|
expect(reparsed.timeline).toBe(parsed.timeline);
|
|
expect(reparsed.frontmatter.custom).toBe('value');
|
|
});
|
|
});
|
|
|
|
describe('parseMarkdown edge cases', () => {
|
|
test('does NOT split on plain --- separators (horizontal rules stay in compiled_truth)', () => {
|
|
const md = `---
|
|
type: concept
|
|
title: Test
|
|
---
|
|
|
|
First section.
|
|
|
|
---
|
|
|
|
Second section.
|
|
|
|
---
|
|
|
|
Third section.`;
|
|
const parsed = parseMarkdown(md);
|
|
expect(parsed.compiled_truth).toContain('First section.');
|
|
expect(parsed.compiled_truth).toContain('Second section.');
|
|
expect(parsed.compiled_truth).toContain('Third section.');
|
|
expect(parsed.timeline).toBe('');
|
|
});
|
|
|
|
test('splits on <!-- timeline --> sentinel with horizontal rules in body', () => {
|
|
const md = `---
|
|
type: concept
|
|
title: Test
|
|
---
|
|
|
|
First section.
|
|
|
|
---
|
|
|
|
Second section.
|
|
|
|
<!-- timeline -->
|
|
|
|
- 2024: Timeline entry`;
|
|
const parsed = parseMarkdown(md);
|
|
expect(parsed.compiled_truth).toContain('First section.');
|
|
expect(parsed.compiled_truth).toContain('Second section.');
|
|
expect(parsed.compiled_truth).not.toContain('Timeline entry');
|
|
expect(parsed.timeline).toContain('Timeline entry');
|
|
});
|
|
|
|
test('handles frontmatter without type or title', () => {
|
|
const md = `---
|
|
custom_field: hello
|
|
---
|
|
|
|
Some content.`;
|
|
const parsed = parseMarkdown(md);
|
|
expect(parsed.type).toBeTruthy();
|
|
expect(parsed.compiled_truth.trim()).toBe('Some content.');
|
|
expect(parsed.frontmatter.custom_field).toBe('hello');
|
|
});
|
|
|
|
test('handles content with no frontmatter at all', () => {
|
|
const md = `Just plain text with no YAML.`;
|
|
const parsed = parseMarkdown(md);
|
|
expect(parsed.compiled_truth).toContain('Just plain text');
|
|
});
|
|
|
|
test('handles empty string', () => {
|
|
const parsed = parseMarkdown('');
|
|
expect(parsed.compiled_truth).toBe('');
|
|
expect(parsed.timeline).toBe('');
|
|
});
|
|
|
|
test('infers type from various directory paths', () => {
|
|
expect(parseMarkdown('', 'people/someone.md').type).toBe('person');
|
|
expect(parseMarkdown('', 'concepts/thing.md').type).toBe('concept');
|
|
expect(parseMarkdown('', 'companies/acme.md').type).toBe('company');
|
|
});
|
|
|
|
test('infers type from wiki subdirectory paths', () => {
|
|
expect(parseMarkdown('', 'tech/wiki/concepts/longevity-science.md').type).toBe('concept');
|
|
expect(parseMarkdown('', 'tech/wiki/guides/team-os-claude-code.md').type).toBe('guide');
|
|
expect(parseMarkdown('', 'tech/wiki/analysis/agi-timeline-debate.md').type).toBe('analysis');
|
|
expect(parseMarkdown('', 'tech/wiki/hardware/h100-vs-gb200-training-benchmarks.md').type).toBe('hardware');
|
|
expect(parseMarkdown('', 'tech/wiki/architecture/kb-infrastructure.md').type).toBe('architecture');
|
|
expect(parseMarkdown('', 'finance/wiki/analysis/polymarket-bot-automation-thesis.md').type).toBe('analysis');
|
|
expect(parseMarkdown('', 'personal/wiki/concepts/career-regrets-2026-framework.md').type).toBe('concept');
|
|
});
|
|
|
|
test('infers writing type from /writing/ paths', () => {
|
|
expect(parseMarkdown('', 'writing/post.md').type).toBe('writing');
|
|
expect(parseMarkdown('', 'projects/blog/writing/essay.md').type).toBe('writing');
|
|
});
|
|
});
|
|
|
|
// issue #1939 — js-yaml parses `title: 2024-06-01` as a Date and `title: 1458`
|
|
// as a number. The old `(frontmatter.title as string)` cast was a compile-time
|
|
// lie; at runtime downstream `.toLowerCase()` threw and wedged sync. Coercion
|
|
// must be non-throwing AND deterministic (UTC ISO for dates, no timezone drift).
|
|
describe('issue #1939 — non-string frontmatter coercion', () => {
|
|
test('date title coerces to its UTC ISO date string', () => {
|
|
const parsed = parseMarkdown('---\ntitle: 2024-06-01\n---\nbody\n', 'apple-notes/x.md');
|
|
expect(parsed.title).toBe('2024-06-01');
|
|
expect(typeof parsed.title).toBe('string');
|
|
});
|
|
|
|
test('number title coerces to its string form', () => {
|
|
const parsed = parseMarkdown('---\ntitle: 1458\n---\nbody\n', 'apple-notes/x.md');
|
|
expect(parsed.title).toBe('1458');
|
|
});
|
|
|
|
test('date title is timezone-independent (UTC) — repro file shape', () => {
|
|
// sources/apple-notes/YC/Talks YC/2023-04-25 1458.md style page.
|
|
const parsed = parseMarkdown('---\ntitle: 2023-04-25\n---\nnotes\n', 'apple-notes/2023-04-25 1458.md');
|
|
expect(parsed.title).toBe('2023-04-25'); // never "Mon Apr 24 2023 ...GMT-0700"
|
|
});
|
|
|
|
test('date/number slug + type coerce without throwing', () => {
|
|
const parsed = parseMarkdown('---\nslug: 2024-06-01\ntype: 2024\n---\nbody\n', 'x.md');
|
|
expect(typeof parsed.slug).toBe('string');
|
|
expect(parsed.slug).toBe('2024-06-01');
|
|
expect(typeof parsed.type).toBe('string');
|
|
});
|
|
|
|
test('missing/empty title falls back to inferred title (no throw)', () => {
|
|
const parsed = parseMarkdown('---\ntype: note\n---\nbody\n', 'people/alice-example.md');
|
|
expect(typeof parsed.title).toBe('string');
|
|
expect(parsed.title.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('string title still passes through unchanged', () => {
|
|
const parsed = parseMarkdown('---\ntitle: A Normal Title\n---\nbody\n', 'x.md');
|
|
expect(parsed.title).toBe('A Normal Title');
|
|
});
|
|
});
|