Files
gbrain/test/extract.test.ts
5e8816e7d6 fix(links): resolve [[wikilink]] + slug-path frontmatter values; frontmatter-fresh incremental extract (#3087)
* fix(links): resolve [[wikilink]] + slug-path frontmatter values; keep frontmatter links fresh on the incremental cycle

Takeover/rebase of two community PRs:

PR #1983 — frontmatter link fields never resolved Obsidian-style values:
- makeResolver step 1's strict slug regex rejected digit-leading folders
  (90-people/nicolai) and nested paths (a/b/c); broadened to any slug-shaped
  value with an EXACT getPage match only (no fuzzy, no false positives).
- extractFrontmatterLinks resolved "[[dir/slug]]" verbatim; new anchored
  unwrapWikilink() strips wholly-wrapped [[...]] (and |alias/#heading/^block)
  before resolution. Bare values pass through unchanged.
- Same broadened slug-shape applied to the fs-path synthetic resolver in
  extractLinksFromFile (exact Set membership guards it), so the fs
  frontmatter path resolves PARA-numbered slugs too.

PR #2434 — the cycle's incremental extract (extractForSlugs) extracted body
links only, so externally-edited YAML (sources:/related:) edges drifted
stale. Adds an includeFrontmatter opt (threaded as a param after sourceId,
which master added in #1747/#1503 after the PR was cut), gated by the new
config key autopilot.incremental_extract_include_frontmatter (default off,
preserves body-only behavior).

Tests: unwrapWikilink unit coverage, broadened-resolver + end-to-end
frontmatter cases in test/link-extraction.test.ts; fs-resolver digit-leading
case in test/extract.test.ts; incremental gate off/on cases in
test/extract-incremental.test.ts.

Co-authored-by: spiky02plateau <spiky02plateau@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cycle): honor DB-plane config for incremental_extract_include_frontmatter

The gate read loadConfig() (file/env plane) only, but the documented enable
command — gbrain config set autopilot.incremental_extract_include_frontmatter
true — writes the DB plane via engine.setConfig, so the feature could never be
turned on the documented way (silent no-op, #2120 class). Now the file plane
wins when the key is present there; otherwise the DB plane is consulted,
matching the autopilot.auto_drain.* read pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Sinabina <sinabina@Sinabinas-MacBook-Pro-4.local>
Co-authored-by: spiky02plateau <spiky02plateau@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
2026-07-23 16:45:27 -07:00

256 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from 'bun:test';
import {
extractMarkdownLinks,
extractLinksFromFile,
extractTimelineFromContent,
walkMarkdownFiles,
} from '../src/commands/extract.ts';
describe('extractMarkdownLinks', () => {
it('extracts relative markdown links', () => {
const content = 'Check [Pedro](../people/pedro-franceschi.md) and [Brex](../../companies/brex.md).';
const links = extractMarkdownLinks(content);
expect(links).toHaveLength(2);
expect(links[0].name).toBe('Pedro');
expect(links[0].relTarget).toBe('../people/pedro-franceschi.md');
});
it('skips external URLs ending in .md', () => {
const content = 'See [readme](https://example.com/readme.md) for details.';
const links = extractMarkdownLinks(content);
expect(links).toHaveLength(0);
});
it('handles links with no matches', () => {
const content = 'No links here.';
expect(extractMarkdownLinks(content)).toHaveLength(0);
});
it('extracts multiple links from same line', () => {
const content = '[A](a.md) and [B](b.md)';
expect(extractMarkdownLinks(content)).toHaveLength(2);
});
});
describe('extractLinksFromFile', () => {
it('resolves relative paths to slugs', async () => {
const content = '---\ntitle: Test\n---\nSee [Pedro](../people/pedro.md).';
const allSlugs = new Set(['people/pedro', 'deals/test-deal']);
const links = await extractLinksFromFile(content, 'deals/test-deal.md', allSlugs);
expect(links.length).toBeGreaterThanOrEqual(1);
expect(links[0].from_slug).toBe('deals/test-deal');
expect(links[0].to_slug).toBe('people/pedro');
});
it('skips links to non-existent pages', async () => {
const content = 'See [Ghost](../people/ghost.md).';
const allSlugs = new Set(['deals/test']);
const links = await extractLinksFromFile(content, 'deals/test.md', allSlugs);
expect(links).toHaveLength(0);
});
it('extracts frontmatter company links (v0.13, includeFrontmatter opt-in)', async () => {
const content = '---\ncompany: brex\ntype: person\n---\nContent.';
// v0.13 canonical: person page with company: X → person → company works_at (outgoing).
// Resolver needs companies/brex to exist in allSlugs to emit the edge.
const allSlugs = new Set(['people/test', 'companies/brex']);
const links = await extractLinksFromFile(content, 'people/test.md', allSlugs, { includeFrontmatter: true });
const companyLinks = links.filter(l => l.link_type === 'works_at');
expect(companyLinks.length).toBeGreaterThanOrEqual(1);
expect(companyLinks[0].from_slug).toBe('people/test');
expect(companyLinks[0].to_slug).toBe('companies/brex');
});
it('extracts frontmatter investors array (v0.13: incoming direction)', async () => {
// v0.13: deal page with investors:[yc, threshold] emits INCOMING edges:
// companies/yc → deals/seed invested_in and same for threshold.
const content = '---\ninvestors: [yc, threshold]\ntype: deal\n---\nContent.';
const allSlugs = new Set(['deals/seed', 'companies/yc', 'companies/threshold']);
const links = await extractLinksFromFile(content, 'deals/seed.md', allSlugs, { includeFrontmatter: true });
const investorLinks = links.filter(l => l.link_type === 'invested_in');
expect(investorLinks).toHaveLength(2);
// Incoming: from = resolved investor, to = deal page.
for (const l of investorLinks) {
expect(l.to_slug).toBe('deals/seed');
expect(l.from_slug).toMatch(/^companies\/(yc|threshold)$/);
}
});
it('resolves wrapped [[wikilink]] digit-leading slug-path in frontmatter (fs resolver, broadened step 1)', async () => {
// Same bug class as makeResolver step 1 (#1983): the fs resolver's strict
// `^[a-z]…` slug regex rejected digit-leading / nested paths, so a PARA-vault
// `related: "[[90-people/nicolai]]"` never resolved even though the page exists.
const content = '---\nrelated: "[[90-people/nicolai]]"\ntype: concept\n---\nContent.';
const allSlugs = new Set(['wiki/note', '90-people/nicolai']);
const links = await extractLinksFromFile(content, 'wiki/note.md', allSlugs, { includeFrontmatter: true });
const related = links.filter(l => l.link_type === 'related_to');
expect(related).toHaveLength(1);
expect(related[0].to_slug).toBe('90-people/nicolai');
});
it('frontmatter extraction is default OFF (back-compat)', async () => {
// Without includeFrontmatter, fs-source no longer auto-extracts frontmatter.
// Matches db-source behavior. User opts in with --include-frontmatter flag.
const content = '---\ncompany: brex\ntype: person\n---\nContent.';
const allSlugs = new Set(['people/test', 'companies/brex']);
const links = await extractLinksFromFile(content, 'people/test.md', allSlugs);
expect(links).toEqual([]);
});
it('infers link type from directory structure', async () => {
const content = 'See [Brex](../companies/brex.md).';
const allSlugs = new Set(['people/pedro', 'companies/brex']);
const links = await extractLinksFromFile(content, 'people/pedro.md', allSlugs);
expect(links[0].link_type).toBe('works_at');
});
it('infers deal_for type for deals -> companies', async () => {
const content = 'See [Brex](../companies/brex.md).';
const allSlugs = new Set(['deals/seed', 'companies/brex']);
const links = await extractLinksFromFile(content, 'deals/seed.md', allSlugs);
expect(links[0].link_type).toBe('deal_for');
});
});
describe('extractTimelineFromContent', () => {
it('extracts bullet format entries', () => {
const content = `## Timeline\n- **2025-03-18** | Meeting — Discussed partnership`;
const entries = extractTimelineFromContent(content, 'people/test');
expect(entries).toHaveLength(1);
expect(entries[0].date).toBe('2025-03-18');
expect(entries[0].source).toBe('Meeting');
expect(entries[0].summary).toBe('Discussed partnership');
});
it('extracts header format entries', () => {
const content = `### 2025-03-28 — Round Closed\n\nAll docs signed. Marcus joins the board.`;
const entries = extractTimelineFromContent(content, 'deals/seed');
expect(entries).toHaveLength(1);
expect(entries[0].date).toBe('2025-03-28');
expect(entries[0].summary).toBe('Round Closed');
expect(entries[0].detail).toContain('Marcus joins the board');
});
it('returns empty for no timeline content', () => {
const content = 'Just plain text without dates.';
expect(extractTimelineFromContent(content, 'test')).toHaveLength(0);
});
it('extracts multiple bullet entries', () => {
const content = `- **2025-01-01** | Source1 — Summary1\n- **2025-02-01** | Source2 — Summary2`;
const entries = extractTimelineFromContent(content, 'test');
expect(entries).toHaveLength(2);
});
it('handles em dash and en dash in bullet format', () => {
const content = `- **2025-03-18** | Meeting Discussed partnership`;
const entries = extractTimelineFromContent(content, 'test');
expect(entries).toHaveLength(1);
});
it('extracts inline citation format entries', () => {
const content = `Closed the seed round with fund-a leading. [Source: board meeting notes, 2025-04-02]`;
const entries = extractTimelineFromContent(content, 'deals/acme-seed');
expect(entries).toHaveLength(1);
expect(entries[0].date).toBe('2025-04-02');
expect(entries[0].source).toBe('board meeting notes');
expect(entries[0].summary).toBe('Closed the seed round with fund-a leading.');
});
it('keeps commas inside the citation source', () => {
const content = `Alice joined as CTO. [Source: email from alice-example re: offer, signed, 2025-05-10]`;
const entries = extractTimelineFromContent(content, 'people/alice-example');
expect(entries).toHaveLength(1);
expect(entries[0].date).toBe('2025-05-10');
expect(entries[0].source).toBe('email from alice-example re: offer, signed');
});
it('extracts one entry per citation when a line carries several', () => {
const content = `Both sides confirmed the partnership. [Source: call with widget-co, 2025-06-01] [Source: follow-up email, 2025-06-03]`;
const entries = extractTimelineFromContent(content, 'companies/widget-co');
expect(entries).toHaveLength(2);
expect(entries[0].date).toBe('2025-06-01');
expect(entries[1].date).toBe('2025-06-03');
expect(entries[0].summary).toBe(entries[1].summary);
});
it('does not double-extract a timeline bullet that carries its own citation', () => {
const content = `- **2025-03-18** | Meeting — Discussed partnership [Source: meeting notes, 2025-03-18]`;
const entries = extractTimelineFromContent(content, 'test');
expect(entries).toHaveLength(1); // Format 1 only
expect(entries[0].source).toBe('Meeting');
});
it('skips a bare citation with no surrounding text', () => {
const content = `[Source: import batch, 2025-07-01]`;
expect(extractTimelineFromContent(content, 'test')).toHaveLength(0);
});
it('ignores citations without a date', () => {
const content = `Some claim here. [Source: undated memo]`;
expect(extractTimelineFromContent(content, 'test')).toHaveLength(0);
});
it('strips list markers from the citation summary', () => {
const content = `- Landed the enterprise pilot with acme-example. [Source: CRM update, 2025-08-15]`;
const entries = extractTimelineFromContent(content, 'companies/acme-example');
expect(entries).toHaveLength(1);
expect(entries[0].summary).toBe('Landed the enterprise pilot with acme-example.');
});
});
describe('walkMarkdownFiles', () => {
it('is a function', () => {
expect(typeof walkMarkdownFiles).toBe('function');
});
});
describe('extractLinksFromFile — slug normalization (T-OBS-1 regression)', () => {
// Regression coverage for the bug where CAPS-named files (ETHOS.md, AGENTS.md)
// generated CAPS slugs from `relPath.replace('.md', '')` while the DB stores
// pages.slug lowercase via pathToSlug() in core/sync.ts. The mismatch caused
// INSERT ... JOIN pages ON pages.slug = v.from_slug to silently drop links.
// Fix: extractor now uses pathToSlug() consistently for from_slug AND allSlugs.
it('lowercases from_slug when relPath has CAPS filename', async () => {
// Note: link targets are kept lowercase (the convention used by the
// wikilink migration); this test focuses on from_slug derivation.
const content = 'See [agents](agents.md) for the matrix.';
const allSlugs = new Set(['ethos', 'agents']);
const links = await extractLinksFromFile(content, 'ETHOS.md', allSlugs);
expect(links.length).toBeGreaterThanOrEqual(1);
// Critical: from_slug must be lowercase regardless of the source file casing.
expect(links[0].from_slug).toBe('ethos');
});
it('lowercases from_slug for mixed-case filename', async () => {
const content = 'Reference [hermes](hermes_nest.md).';
const allSlugs = new Set(['hermes_nest', 'foo']);
const links = await extractLinksFromFile(content, 'Foo.md', allSlugs);
expect(links.length).toBeGreaterThanOrEqual(1);
expect(links[0].from_slug).toBe('foo');
});
it('is idempotent for already-lowercase filenames', async () => {
const content = 'See [bar](bar.md).';
const allSlugs = new Set(['foo', 'bar']);
const links = await extractLinksFromFile(content, 'foo.md', allSlugs);
expect(links.length).toBeGreaterThanOrEqual(1);
expect(links[0].from_slug).toBe('foo');
});
it('lowercases nested path slug with mixed-case segment', async () => {
// relPath has mixed-case directory + filename. Link target is in the same
// directory (no .. traversal) so resolveSlug can hit allSlugs cleanly.
const content = 'See [other](other.md).';
const allSlugs = new Set(['decisions/0001-living-repo-pattern', 'decisions/other']);
const links = await extractLinksFromFile(
content,
'decisions/0001-Living-Repo-Pattern.md',
allSlugs,
);
expect(links.length).toBeGreaterThanOrEqual(1);
expect(links[0].from_slug).toBe('decisions/0001-living-repo-pattern');
});
});