mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 06:23:01 +00:00
* 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>
256 lines
12 KiB
TypeScript
256 lines
12 KiB
TypeScript
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');
|
||
});
|
||
});
|