From 2d9f4ec243c19fbd4e2a43e95f13e18e0cb8d1e4 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 23 Jul 2026 10:56:28 -0700 Subject: [PATCH] =?UTF-8?q?fix(extract):=20link-aware=20Source=20=E2=80=94?= =?UTF-8?q?=20Summary=20delimiter=20in=20timeline=20bullets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Takeover of #3060: a bare hyphen inside a markdown link target (e.g. ../people/alice-example.md) matched the old delimiter regex and shattered one timeline entry into two fragments that re-inserted on every sync. The delimiter search is now link-aware (whitespace-flanked dash outside bracket/paren spans); delimiterless bullets are kept whole under the 'markdown' source label. Repaired from the original PR: real-person fixtures in test/extract.test.ts renamed to privacy-rule placeholders. Co-authored-by: wright-io Co-Authored-By: Claude Fable 5 --- src/commands/extract.ts | 42 ++++++++++++++++++++++++-- test/extract.test.ts | 66 ++++++++++++++++++++++++++++++----------- 2 files changed, 89 insertions(+), 19 deletions(-) diff --git a/src/commands/extract.ts b/src/commands/extract.ts index 98176e317..d4dc3c722 100644 --- a/src/commands/extract.ts +++ b/src/commands/extract.ts @@ -469,15 +469,53 @@ export async function extractLinksFromFile( // --- Timeline extraction --- +/** + * Index of the first dash (—, –, -) that can serve as the Source — Summary + * delimiter: it must have whitespace on both sides and sit outside every + * markdown-link span. Hyphens inside link targets + * (`../people/alice-example.md`) and dashes inside link labels + * (`[Deals — Q1 Review](...)`) are content, not delimiters — splitting on + * them shatters one entry into two fragments whose halves re-insert on + * every sync (the (page_id, date, summary, source) uniqueness sees each + * fragment shape as a new row). Returns -1 when the line has no delimiter. + */ +function findDelimiterOutsideLinks(text: string): number { + let depth = 0; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (c === '[' || c === '(') depth++; + else if (c === ']' || c === ')') { if (depth > 0) depth--; } + else if ( + depth === 0 && + (c === '—' || c === '–' || c === '-') && + i > 0 && /\s/.test(text[i - 1]) && + i + 1 < text.length && /\s/.test(text[i + 1]) + ) { + return i; + } + } + return -1; +} + /** Extract timeline entries from markdown content */ export function extractTimelineFromContent(content: string, slug: string): ExtractedTimelineEntry[] { const entries: ExtractedTimelineEntry[] = []; // Format 1: Bullet — - **YYYY-MM-DD** | Source — Summary - const bulletPattern = /^-\s+\*\*(\d{4}-\d{2}-\d{2})\*\*\s*\|\s*(.+?)\s*[—–-]\s*(.+)$/gm; + // The delimiter search is link-aware (see findDelimiterOutsideLinks); a + // bullet with no delimiter (e.g. an auto-generated backlink line + // `- **date** | Referenced in [X](y.md)`) is kept whole as the summary + // rather than dropped or fragmented. + const bulletPattern = /^-\s+\*\*(\d{4}-\d{2}-\d{2})\*\*\s*\|\s*(.+)$/gm; let match; while ((match = bulletPattern.exec(content)) !== null) { - entries.push({ slug, date: match[1], source: match[2].trim(), summary: match[3].trim() }); + const rest = match[2].trim(); + const at = findDelimiterOutsideLinks(rest); + if (at >= 0) { + entries.push({ slug, date: match[1], source: rest.slice(0, at).trim(), summary: rest.slice(at + 1).trim() }); + } else { + entries.push({ slug, date: match[1], source: 'markdown', summary: rest }); + } } // Format 2: Header — ### YYYY-MM-DD — Title diff --git a/test/extract.test.ts b/test/extract.test.ts index 5764de52b..6b1ca242a 100644 --- a/test/extract.test.ts +++ b/test/extract.test.ts @@ -8,11 +8,11 @@ import { describe('extractMarkdownLinks', () => { it('extracts relative markdown links', () => { - const content = 'Check [Pedro](../people/pedro-franceschi.md) and [Brex](../../companies/brex.md).'; + const content = 'Check [Alice](../people/alice-example.md) and [Acme](../../companies/acme-example.md).'; const links = extractMarkdownLinks(content); expect(links).toHaveLength(2); - expect(links[0].name).toBe('Pedro'); - expect(links[0].relTarget).toBe('../people/pedro-franceschi.md'); + expect(links[0].name).toBe('Alice'); + expect(links[0].relTarget).toBe('../people/alice-example.md'); }); it('skips external URLs ending in .md', () => { @@ -34,12 +34,12 @@ describe('extractMarkdownLinks', () => { 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 content = '---\ntitle: Test\n---\nSee [Alice](../people/alice.md).'; + const allSlugs = new Set(['people/alice', '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'); + expect(links[0].to_slug).toBe('people/alice'); }); it('skips links to non-existent pages', async () => { @@ -50,15 +50,15 @@ describe('extractLinksFromFile', () => { }); it('extracts frontmatter company links (v0.13, includeFrontmatter opt-in)', async () => { - const content = '---\ncompany: brex\ntype: person\n---\nContent.'; + const content = '---\ncompany: acme-example\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']); + // Resolver needs companies/acme-example to exist in allSlugs to emit the edge. + const allSlugs = new Set(['people/test', 'companies/acme-example']); 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'); + expect(companyLinks[0].to_slug).toBe('companies/acme-example'); }); it('extracts frontmatter investors array (v0.13: incoming direction)', async () => { @@ -79,22 +79,22 @@ describe('extractLinksFromFile', () => { 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 content = '---\ncompany: acme-example\ntype: person\n---\nContent.'; + const allSlugs = new Set(['people/test', 'companies/acme-example']); 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); + const content = 'See [Acme](../companies/acme-example.md).'; + const allSlugs = new Set(['people/alice', 'companies/acme-example']); + const links = await extractLinksFromFile(content, 'people/alice.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 content = 'See [Acme](../companies/acme-example.md).'; + const allSlugs = new Set(['deals/seed', 'companies/acme-example']); const links = await extractLinksFromFile(content, 'deals/seed.md', allSlugs); expect(links[0].link_type).toBe('deal_for'); }); @@ -136,6 +136,38 @@ describe('extractTimelineFromContent', () => { expect(entries).toHaveLength(1); }); + it('does not split on hyphens inside markdown link targets', () => { + const content = `- **2025-03-18** | Referenced in [Alice](../people/alice-example.md)`; + const entries = extractTimelineFromContent(content, 'companies/acme-example'); + expect(entries).toHaveLength(1); + expect(entries[0].source).toBe('markdown'); + expect(entries[0].summary).toBe('Referenced in [Alice](../people/alice-example.md)'); + }); + + it('does not split on spaced dashes inside link labels', () => { + const content = `- **2025-03-18** | Referenced in [Deals — Q1 Review](../deals/q1-review.md)`; + const entries = extractTimelineFromContent(content, 'companies/acme-example'); + expect(entries).toHaveLength(1); + expect(entries[0].source).toBe('markdown'); + expect(entries[0].summary).toBe('Referenced in [Deals — Q1 Review](../deals/q1-review.md)'); + }); + + it('splits on the first spaced dash outside links', () => { + const content = `- **2025-03-18** | [Board notes](../meetings/2025-03-18-board.md) — Approved the hire`; + const entries = extractTimelineFromContent(content, 'test'); + expect(entries).toHaveLength(1); + expect(entries[0].source).toBe('[Board notes](../meetings/2025-03-18-board.md)'); + expect(entries[0].summary).toBe('Approved the hire'); + }); + + it('keeps delimiterless bullet lines whole instead of dropping them', () => { + const content = `- **2025-03-18** | Imported from legacy tracker`; + const entries = extractTimelineFromContent(content, 'test'); + expect(entries).toHaveLength(1); + expect(entries[0].source).toBe('markdown'); + expect(entries[0].summary).toBe('Imported from legacy tracker'); + }); + 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');