Files
gbrain/test/markdown.test.ts
5a06af5a57 v0.42.32.0 fix(sync): coerce non-string frontmatter titles + bounded auto-skip failure ledger (#1939) (#1956)
* 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>
2026-06-07 19:22:33 -07:00

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');
});
});