Files
gbrain/test/link-extraction.test.ts
T
c22ca84772 feat: v0.13 frontmatter relationship indexing — YAML becomes typed graph edges (#231)
* feat(schema): links provenance + engine plumbing (v0.13)

Adds link_source, origin_page_id, origin_field columns with
UNIQUE NULLS NOT DISTINCT constraint + CHECK constraint. New indexes
on link_source + origin_page_id.

migrate.ts v11 handles idempotent upgrade path for existing brains.
Both engines: addLink/addLinksBatch threads new columns (4→7 col
unnest). removeLink gains linkSource filter. getLinks/getBacklinks
return new columns.

New engine method findByTitleFuzzy(name, dirPrefix?, minSim?) uses
pg_trgm % operator + similarity(). Drives the v0.13 resolver's
fuzzy-match step with zero LLM/embedding cost.

* feat(graph): frontmatter edge extraction + slug resolver (v0.13)

Canonical FRONTMATTER_LINK_MAP: field → type + direction + dir-hint
for 10 frontmatter patterns (company/companies, key_people, investors,
attendees, partner, lead, founded, sources, source, related/see_also).

Direction semantics: "incoming" means resolved value is the FROM side
so subject-of-verb reads naturally (pedro → meeting, not backwards).

makeResolver(engine, {mode}) — two-mode resolver:
  batch (migration): slug → dir-hint → pg_trgm. NEVER hits search.
  live (put_page):   + optional search fallback with expand=false
                     (dodges hidden Haiku per operations-query learning).
Per-run cache: same name → single DB lookup.

extractFrontmatterLinks handles arrays-of-objects (investors:
[{name: 'Sequoia', role: 'lead'}]), skips bad types silently,
tracks unresolved names for the summary report.

extractPageLinks is now async. LinkCandidate gains fromSlug,
linkSource, originSlug, originField. Returns {candidates, unresolved}.

22 new tests: field-map coverage, direction semantics, source vs
sources, resolver fallback chain (batch + live), cache hit, bad
types skipped, context enrichment, FRONTMATTER_LINK_MAP integrity.

* feat(auto-link): bidirectional reconciliation + unresolved response

put_page auto-link post-hook now handles incoming-direction frontmatter
edges. Reconciliation splits candidates into out (fromSlug === slug)
and in (fromSlug !== slug — frontmatter fields like key_people on a
company page emit person → company edges).

Safe reconciliation via origin_page_id scoping: we only touch
link_source='frontmatter' edges where origin_slug = the page being
written. Markdown + manual edges survive untouched. Edges created
by OTHER pages' frontmatter also survive.

put_page response extends auto_links with unresolved: Array<{field,
name}>. Agents writing attendees: [Pedro, Alex] where Alex doesn't
resolve see it in the response and can queue for enrichment.
Additive — existing agents unaffected.

extract.ts: delete the local 5-field extractFrontmatterLinks + local
inferLinkType. FS-source now calls canonical link-extraction.ts via
a synthetic resolver backed by the allSlugs Set. --include-frontmatter
flag (default OFF in v0.13 for back-compat; migration explicitly
enables for the one-time backfill). Top-20 unresolved names summary
when active.

* feat(migration): v0.13.0 orchestrator

3-phase orchestrator (schema → backfill → verify → record) follows
the v0_12_2.ts pattern. Phase A triggers migrate.ts v11 via
gbrain init --migrate-only. Phase B runs:

  gbrain extract links --source db --include-frontmatter

to backfill frontmatter edges for every existing page. Uses the
batch-mode resolver (pg_trgm only, no LLM calls, zero API cost).
Ignores auto_link=false config — migration is canonical, the
auto_link flag controls per-write post-hook not one-time schema
work.

Idempotent + resumable via ON CONFLICT DO NOTHING + origin_page_id
scoping. Wall-clock budget: 2-5 min on 46K-page brains.

Registered in migrations/index.ts. apply-migrations test updated
to include v0.13.0 in skippedFuture for older installed versions.

* feat(release): upgrade-errors.jsonl trail + doctor surfacing

upgrade.ts catches post-upgrade subprocess failures as best-effort
today (line 65 comment: "post-upgrade is best-effort, don't fail
the upgrade"). When that chain silently fails, users end up with
half-upgraded brains and no signal.

v0.13: on post-upgrade failure, append a structured record to
~/.gbrain/upgrade-errors.jsonl with ts, phase, versions, error
message, and a paste-ready recovery hint.

doctor.ts reads the jsonl and surfaces the latest entry with a
warn-status check. User runs gbrain doctor, sees exactly what
failed, pastes the recovery command, files an issue if needed.

Applies to every future release — doctor grows with the codebase
without per-release edits. The CHANGELOG pattern ("To take advantage
of v[version]" block) mirrors this in user-facing form.

* chore: bump version and changelog (v0.13.0)

v0.13.0 — Frontmatter Relationship Indexing.

Adds the "To take advantage of v[version]" block pattern to
CHANGELOG format (CLAUDE.md documents the requirement going
forward). Pairs with the upgrade-errors.jsonl + doctor surfacing
to close the "half-upgraded brain, no signal" loop.

UPGRADING_DOWNSTREAM_AGENTS.md gets a v0.13 section: no-action-
required verdict for most skills, optional diffs for meeting-
ingestion / enrich / idea-ingest if they want to consume
auto_links.unresolved.

skills/migrations/v0.13.0.md is the user-facing upgrade skill.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(v0.13): adversarial review P0s

Codex + Claude adversarial review caught 4 critical issues in the
v0.13 implementation. Fixing before ship.

1. findByTitleFuzzy SET LOCAL was a no-op. postgres.js auto-commits
   each sql`` so SET LOCAL pg_trgm.similarity_threshold committed
   before the `%` operator ran against it. Resolver used server
   default (0.3, not 0.55) → way too many fuzzy matches, wrong
   links on a 46K-page brain. Switched to inline
   `similarity(title, $1) >= $N` which has no transaction scoping.
   Added `ORDER BY sim DESC, slug ASC` for deterministic
   tie-breaking (prevents reconciliation churn on re-runs).

2. v11 migration now checks Postgres ≥ 15 before applying
   UNIQUE NULLS NOT DISTINCT. Old Supabase projects on PG14 would
   have dropped the old unique constraint and failed to add the
   new one, corrupting the uniqueness invariant. The check raises
   a clear error with the actual PG version, leaving the old
   constraint in place.

3. v11 migration now backfills NULL link_source → 'markdown' for
   pre-v0.13 legacy rows. Without this, reconciliation's existKey
   comparison treats NULL and 'markdown' as equivalent but the
   unique constraint sees them as distinct (NULLS NOT DISTINCT
   only collapses NULL with NULL, not NULL with 'markdown'). Result
   was duplicate edges accumulating forever. Treating legacy as
   markdown is the accurate best-guess — pre-v0.13 auto-link only
   emitted markdown edges.

4. v0_13_0.ts orchestrator now uses process.execPath, not a bare
   `gbrain` on PATH. After `gbrain upgrade` rewrites the binary,
   alias shadowing / PATH caching / multiple installs could
   resolve a stale `gbrain` binary. process.execPath is always
   the binary that loaded this migration module.

Phase C verify clarified: reports page + link counts and points to
Phase B's own stdout as the authoritative signal for backfill
results (extract.ts already prints `Links: created N from M pages`).

* docs: scrub real names from public docs + add privacy rule to CLAUDE.md

Public artifacts (CHANGELOG, skills, docs) should never reveal real
contacts, companies, funds, or private agent-fork names from any
user's brain. When a doc copies a query like `gbrain graph diana-hu`
or names a fork like `Wintermute`, that real name gets indexed,
cross-referenced, and distributed with every release.

CLAUDE.md gains a "Privacy rule: scrub real names from public docs"
section with:
- What counts as public (CHANGELOG, README, docs/, skills/, PR bodies,
  commit messages, code comments)
- Name mapping table (agent forks → your agent fork; example person →
  alice-example; example fund → fund-a; etc.)
- Distinction between illustrative API examples with household brands
  (Stripe, Brex) and queries that reveal real relationships

Applied the rule to v0.13 scope:
- CHANGELOG v0.13 entry: Pedro/Diana/Wintermute/Sequoia/Benchmark/a16z
  all replaced with alice/charlie/fund-a/acme/agent-fork placeholders
- skills/migrations/v0.13.0.md: same
- docs/UPGRADING_DOWNSTREAM_AGENTS.md: Wintermute references scrubbed
  throughout (pre-v0.13 and v0.13 sections)
- CLAUDE.md: "Brain skills (from Wintermute)" → "(ported from an
  upstream agent fork)", internal Wintermute provenance notes
  genericized, "Garry finds fragile upgrade paths" → "the gbrain
  maintainers find fragile upgrade paths" in the template

Pre-v0.13 historical CHANGELOG entries (v0.10-v0.12) left alone —
those are shipped releases; rewriting changes public history.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:05:27 +08:00

612 lines
23 KiB
TypeScript

import { describe, test, expect } from 'bun:test';
import {
extractEntityRefs,
extractPageLinks,
extractFrontmatterLinks,
inferLinkType,
makeResolver,
parseTimelineEntries,
isAutoLinkEnabled,
FRONTMATTER_LINK_MAP,
type SlugResolver,
} from '../src/core/link-extraction.ts';
import type { BrainEngine } from '../src/core/engine.ts';
// ─── extractEntityRefs ─────────────────────────────────────────
describe('extractEntityRefs', () => {
test('extracts filesystem-relative refs ([Name](../people/slug.md))', () => {
const refs = extractEntityRefs('Met with [Alice Chen](../people/alice-chen.md) at the office.');
expect(refs.length).toBe(1);
expect(refs[0]).toEqual({ name: 'Alice Chen', slug: 'people/alice-chen', dir: 'people' });
});
test('extracts engine-style slug refs ([Name](people/slug))', () => {
const refs = extractEntityRefs('See [Alice Chen](people/alice-chen) for context.');
expect(refs.length).toBe(1);
expect(refs[0]).toEqual({ name: 'Alice Chen', slug: 'people/alice-chen', dir: 'people' });
});
test('extracts company refs', () => {
const refs = extractEntityRefs('We invested in [Acme AI](companies/acme-ai).');
expect(refs.length).toBe(1);
expect(refs[0].dir).toBe('companies');
expect(refs[0].slug).toBe('companies/acme-ai');
});
test('extracts multiple refs in same content', () => {
const refs = extractEntityRefs('[Alice](people/alice) and [Bob](people/bob) met at [Acme](companies/acme).');
expect(refs.length).toBe(3);
expect(refs.map(r => r.slug)).toEqual(['people/alice', 'people/bob', 'companies/acme']);
});
test('handles ../../ deep paths', () => {
const refs = extractEntityRefs('[Alice](../../people/alice.md)');
expect(refs.length).toBe(1);
expect(refs[0].slug).toBe('people/alice');
});
test('handles unicode names', () => {
const refs = extractEntityRefs('Met [Héctor García](people/hector-garcia)');
expect(refs.length).toBe(1);
expect(refs[0].name).toBe('Héctor García');
});
test('returns empty array on no matches', () => {
expect(extractEntityRefs('No links here.')).toEqual([]);
});
test('skips malformed markdown (unclosed bracket)', () => {
expect(extractEntityRefs('[Alice(people/alice)')).toEqual([]);
});
test('skips non-entity dirs (notes/, ideas/ stay if added later but are accepted now)', () => {
// Current regex targets entity dirs explicitly. Notes/ shouldn't match.
const refs = extractEntityRefs('See [random](notes/random).');
expect(refs).toEqual([]);
});
test('extracts meeting refs', () => {
const refs = extractEntityRefs('See [Standup](meetings/2026-01-15-standup).');
expect(refs.length).toBe(1);
expect(refs[0].dir).toBe('meetings');
});
});
// ─── extractPageLinks ──────────────────────────────────────────
// Resolver that always returns whatever the caller asks for (pretend every
// page exists). Used by tests that only want to exercise the non-resolver
// paths (markdown + bare-slug + frontmatter.source).
const allowAllResolver = {
resolve: async (name: string) => {
if (/^[a-z][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/.test(name)) return name;
return null;
},
};
// Resolver that never resolves. Used to test that the non-frontmatter
// paths still produce candidates even when no fuzzy matching is possible.
const nullResolver = { resolve: async () => null };
describe('extractPageLinks', () => {
test('returns LinkCandidate[] with inferred types', async () => {
const { candidates } = await extractPageLinks(
'docs/x',
'[Alice](people/alice) is the CEO of Acme.',
{},
'concept',
allowAllResolver,
);
expect(candidates.length).toBeGreaterThan(0);
const aliceLink = candidates.find(c => c.targetSlug === 'people/alice');
expect(aliceLink).toBeDefined();
expect(aliceLink!.linkType).toBe('works_at');
});
test('dedups multiple mentions of same entity (within-page dedup)', async () => {
const content = '[Alice](people/alice) said this. Later, [Alice](people/alice) said that.';
const { candidates } = await extractPageLinks('docs/x', content, {}, 'concept', allowAllResolver);
const aliceLinks = candidates.filter(c => c.targetSlug === 'people/alice');
expect(aliceLinks.length).toBe(1);
});
test('extracts frontmatter source as source-type link', async () => {
const { candidates } = await extractPageLinks(
'docs/x', 'Some content.', { source: 'meetings/2026-01-15' }, 'person', allowAllResolver,
);
const sourceLink = candidates.find(c => c.linkType === 'source');
expect(sourceLink).toBeDefined();
expect(sourceLink!.targetSlug).toBe('meetings/2026-01-15');
});
test('extracts bare slug references in text', async () => {
const { candidates } = await extractPageLinks(
'docs/x', 'See companies/acme for details.', {}, 'concept', nullResolver,
);
const acme = candidates.find(c => c.targetSlug === 'companies/acme');
expect(acme).toBeDefined();
});
test('returns empty when no refs found', async () => {
const { candidates } = await extractPageLinks(
'docs/x', 'Plain text with no links.', {}, 'concept', nullResolver,
);
expect(candidates).toEqual([]);
});
test('meeting page references default to attended type', async () => {
const { candidates } = await extractPageLinks(
'meetings/x', 'Attendees: [Alice](people/alice), [Bob](people/bob).',
{}, 'meeting' as never, nullResolver,
);
const aliceLink = candidates.find(c => c.targetSlug === 'people/alice');
expect(aliceLink!.linkType).toBe('attended');
});
});
// ─── inferLinkType ─────────────────────────────────────────────
describe('inferLinkType', () => {
test('meeting + person ref -> attended', () => {
expect(inferLinkType('meeting', 'Attendees: Alice')).toBe('attended');
});
test('CEO of -> works_at', () => {
expect(inferLinkType('person', 'Alice is CEO of Acme.')).toBe('works_at');
});
test('VP at -> works_at', () => {
expect(inferLinkType('person', 'Bob, VP at Stripe, said.')).toBe('works_at');
});
test('invested in -> invested_in', () => {
expect(inferLinkType('person', 'YC invested in Acme.')).toBe('invested_in');
});
test('founded -> founded', () => {
expect(inferLinkType('person', 'Alice founded NovaPay.')).toBe('founded');
});
test('co-founded -> founded', () => {
expect(inferLinkType('person', 'Bob co-founded Beta Health.')).toBe('founded');
});
test('advises -> advises', () => {
expect(inferLinkType('person', 'Emily advises Acme on go-to-market.')).toBe('advises');
});
test('"board member" alone is too ambiguous (investors also hold board seats) -> mentions', () => {
// Tightened in v0.10.4 after BrainBench rich-prose surfaced that partner
// bios ("She sits on the boards of [portfolio company]") were classified
// as advises. Generic board language now requires explicit advisor/advise
// rooting to count.
expect(inferLinkType('person', 'Jane is a board member at Beta Health.')).toBe('mentions');
});
test('explicit advisor language -> advises', () => {
expect(inferLinkType('person', 'Jane is an advisor to Beta Health.')).toBe('advises');
expect(inferLinkType('person', 'Joined the advisory board at Beta Health.')).toBe('advises');
});
test('investment narrative variants -> invested_in', () => {
expect(inferLinkType('person', 'Wendy led the Series A for Cipher Labs.')).toBe('invested_in');
expect(inferLinkType('person', 'Bob is an early investor in Acme.')).toBe('invested_in');
expect(inferLinkType('person', 'She invests in fintech startups.')).toBe('invested_in');
expect(inferLinkType('person', 'Acme is a portfolio company of Founders Fund.')).toBe('invested_in');
expect(inferLinkType('person', 'Sequoia led the seed round for Vox.')).toBe('invested_in');
});
test('default -> mentions', () => {
expect(inferLinkType('person', 'Random context with no relationship verbs.')).toBe('mentions');
});
test('precedence: founded beats works_at', () => {
// "founded" appears first in regex precedence
expect(inferLinkType('person', 'Alice founded Acme and is the CEO of it.')).toBe('founded');
});
test('media page -> mentions (not attended)', () => {
expect(inferLinkType('media', 'Alice attended the workshop.')).toBe('mentions');
});
});
// ─── parseTimelineEntries ──────────────────────────────────────
describe('parseTimelineEntries', () => {
test('parses standard format: - **YYYY-MM-DD** | summary', () => {
const entries = parseTimelineEntries('- **2026-01-15** | Met with Alice');
expect(entries.length).toBe(1);
expect(entries[0]).toEqual({ date: '2026-01-15', summary: 'Met with Alice', detail: '' });
});
test('parses dash variant: - **YYYY-MM-DD** -- summary', () => {
const entries = parseTimelineEntries('- **2026-01-15** -- Met with Bob');
expect(entries.length).toBe(1);
expect(entries[0].summary).toBe('Met with Bob');
});
test('parses single dash: - **YYYY-MM-DD** - summary', () => {
const entries = parseTimelineEntries('- **2026-01-15** - Met with Carol');
expect(entries.length).toBe(1);
expect(entries[0].summary).toBe('Met with Carol');
});
test('parses without leading dash: **YYYY-MM-DD** | summary', () => {
const entries = parseTimelineEntries('**2026-01-15** | Standalone entry');
expect(entries.length).toBe(1);
});
test('parses multiple entries', () => {
const content = `## Timeline
- **2026-01-15** | First event
- **2026-02-20** | Second event
- **2026-03-10** | Third event`;
const entries = parseTimelineEntries(content);
expect(entries.length).toBe(3);
expect(entries.map(e => e.date)).toEqual(['2026-01-15', '2026-02-20', '2026-03-10']);
});
test('skips invalid dates (2026-13-45)', () => {
const entries = parseTimelineEntries('- **2026-13-45** | Bad date');
expect(entries.length).toBe(0);
});
test('skips invalid dates (2026-02-30)', () => {
const entries = parseTimelineEntries('- **2026-02-30** | Feb 30 doesnt exist');
expect(entries.length).toBe(0);
});
test('returns empty when no timeline lines found', () => {
expect(parseTimelineEntries('Just some plain text.')).toEqual([]);
});
test('handles mixed content (timeline lines interspersed with prose)', () => {
const content = `Some intro paragraph.
- **2026-01-15** | An event happened
More prose here.
- **2026-02-20** | Another event`;
const entries = parseTimelineEntries(content);
expect(entries.length).toBe(2);
});
});
// ─── isAutoLinkEnabled ─────────────────────────────────────────
function makeFakeEngine(configMap: Map<string, string | null>): BrainEngine {
return {
getConfig: async (key: string) => configMap.get(key) ?? null,
} as unknown as BrainEngine;
}
describe('isAutoLinkEnabled', () => {
test('null/undefined -> true (default on)', async () => {
const engine = makeFakeEngine(new Map());
expect(await isAutoLinkEnabled(engine)).toBe(true);
});
test('"false" -> false', async () => {
const engine = makeFakeEngine(new Map([['auto_link', 'false']]));
expect(await isAutoLinkEnabled(engine)).toBe(false);
});
test('"FALSE" (case-insensitive) -> false', async () => {
const engine = makeFakeEngine(new Map([['auto_link', 'FALSE']]));
expect(await isAutoLinkEnabled(engine)).toBe(false);
});
test('"0" -> false', async () => {
const engine = makeFakeEngine(new Map([['auto_link', '0']]));
expect(await isAutoLinkEnabled(engine)).toBe(false);
});
test('"no" -> false', async () => {
const engine = makeFakeEngine(new Map([['auto_link', 'no']]));
expect(await isAutoLinkEnabled(engine)).toBe(false);
});
test('"off" -> false', async () => {
const engine = makeFakeEngine(new Map([['auto_link', 'off']]));
expect(await isAutoLinkEnabled(engine)).toBe(false);
});
test('"true" -> true', async () => {
const engine = makeFakeEngine(new Map([['auto_link', 'true']]));
expect(await isAutoLinkEnabled(engine)).toBe(true);
});
test('"1" -> true', async () => {
const engine = makeFakeEngine(new Map([['auto_link', '1']]));
expect(await isAutoLinkEnabled(engine)).toBe(true);
});
test('whitespace and case: " False " -> false', async () => {
const engine = makeFakeEngine(new Map([['auto_link', ' False ']]));
expect(await isAutoLinkEnabled(engine)).toBe(false);
});
test('garbage value -> true (fail-safe to default)', async () => {
const engine = makeFakeEngine(new Map([['auto_link', 'garbage']]));
expect(await isAutoLinkEnabled(engine)).toBe(true);
});
});
// ─── Frontmatter link extraction (v0.13) ────────────────────────
/**
* In-memory resolver for frontmatter tests. Maps names to slugs via an
* explicit fixture map; returns null for anything missing. Mirrors what
* the real resolver does on a production brain but with deterministic
* inputs (no pg_trgm, no searchPages).
*/
function makeFixtureResolver(pages: Record<string, string>): SlugResolver {
return {
async resolve(name: string, dirHint?: string | string[]) {
const hints = Array.isArray(dirHint) ? dirHint : (dirHint ? [dirHint] : []);
// Already a slug — check if present.
if (/^[a-z][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/.test(name)) {
return pages[name] ?? null;
}
const slugified = name.toLowerCase().replace(/\s+/g, '-');
for (const hint of hints) {
if (!hint) continue;
const candidate = `${hint}/${slugified}`;
if (pages[candidate]) return candidate;
}
return null;
},
};
}
describe('extractFrontmatterLinks — field-map coverage', () => {
const pages = {
'people/pedro': 'people/pedro',
'people/garry': 'people/garry',
'people/diana-hu': 'people/diana-hu',
'companies/stripe': 'companies/stripe',
'companies/brex': 'companies/brex',
'companies/sequoia': 'companies/sequoia',
'companies/benchmark': 'companies/benchmark',
'meetings/2026-04-03': 'meetings/2026-04-03',
'deal/riveter-seed': 'deal/riveter-seed',
};
const resolver = makeFixtureResolver(pages);
test('person.company → outgoing works_at', async () => {
const { candidates } = await extractFrontmatterLinks(
'people/pedro', 'person' as never, { company: 'Stripe' }, resolver,
);
expect(candidates).toHaveLength(1);
expect(candidates[0]).toMatchObject({
fromSlug: 'people/pedro',
targetSlug: 'companies/stripe',
linkType: 'works_at',
linkSource: 'frontmatter',
originSlug: 'people/pedro',
originField: 'company',
});
});
test('person.companies (array alias) → multiple works_at edges', async () => {
const { candidates } = await extractFrontmatterLinks(
'people/pedro', 'person' as never, { companies: ['Stripe', 'Brex'] }, resolver,
);
expect(candidates).toHaveLength(2);
for (const c of candidates) {
expect(c.fromSlug).toBe('people/pedro');
expect(c.linkType).toBe('works_at');
expect(c.targetSlug).toMatch(/^companies\/(stripe|brex)$/);
}
});
test('company.key_people → INCOMING works_at (person → company)', async () => {
const { candidates } = await extractFrontmatterLinks(
'companies/stripe', 'company' as never, { key_people: ['Pedro', 'Garry'] }, resolver,
);
expect(candidates).toHaveLength(2);
for (const c of candidates) {
// Incoming: from = resolved person, to = the page being written.
expect(c.targetSlug).toBe('companies/stripe');
expect(c.fromSlug).toMatch(/^people\/(pedro|garry)$/);
expect(c.linkType).toBe('works_at');
expect(c.originSlug).toBe('companies/stripe');
expect(c.originField).toBe('key_people');
}
});
test('meeting.attendees → INCOMING attended (person → meeting)', async () => {
const { candidates } = await extractFrontmatterLinks(
'meetings/2026-04-03', 'meeting' as never, { attendees: ['Pedro', 'Garry'] }, resolver,
);
expect(candidates).toHaveLength(2);
for (const c of candidates) {
expect(c.targetSlug).toBe('meetings/2026-04-03');
expect(c.linkType).toBe('attended');
expect(c.fromSlug).toMatch(/^people\/(pedro|garry)$/);
}
});
test('deal.investors (multi-dir hint) → INCOMING invested_in', async () => {
const { candidates } = await extractFrontmatterLinks(
'deal/riveter-seed', 'deal' as never,
{ investors: ['Sequoia', 'Benchmark'] }, resolver,
);
expect(candidates).toHaveLength(2);
for (const c of candidates) {
expect(c.targetSlug).toBe('deal/riveter-seed');
expect(c.linkType).toBe('invested_in');
expect(c.fromSlug).toMatch(/^companies\/(sequoia|benchmark)$/);
}
});
test('source field → outgoing source edge', async () => {
const { candidates } = await extractFrontmatterLinks(
'people/pedro', 'person' as never, { source: 'meetings/2026-04-03' }, resolver,
);
const src = candidates.find(c => c.linkType === 'source');
expect(src).toBeDefined();
expect(src!.fromSlug).toBe('people/pedro');
expect(src!.targetSlug).toBe('meetings/2026-04-03');
});
test('unresolvable name goes to unresolved list, not candidates', async () => {
const { candidates, unresolved } = await extractFrontmatterLinks(
'meetings/x', 'meeting' as never,
{ attendees: ['Pedro', 'Unknown Person'] }, resolver,
);
expect(candidates).toHaveLength(1);
expect(unresolved).toHaveLength(1);
expect(unresolved[0]).toEqual({ field: 'attendees', name: 'Unknown Person' });
});
test('bad types (number, null, empty) skipped silently', async () => {
const { candidates, unresolved } = await extractFrontmatterLinks(
'meetings/x', 'meeting' as never,
{ attendees: [42, null, '', 'Pedro', { nothing: true }] }, resolver,
);
// Only 'Pedro' produces a candidate. 42/null/'' silently skipped.
// Object without name/slug/title is skipped. No unresolved entry for skipped.
expect(candidates).toHaveLength(1);
expect(candidates[0].fromSlug).toBe('people/pedro');
expect(unresolved).toHaveLength(0);
});
test('array of objects: uses .name, carries role into context', async () => {
const { candidates } = await extractFrontmatterLinks(
'deal/riveter-seed', 'deal' as never,
{ investors: [{ name: 'Sequoia', role: 'lead' }] }, resolver,
);
expect(candidates).toHaveLength(1);
expect(candidates[0].context).toContain('Sequoia');
expect(candidates[0].context).toContain('lead');
});
test('context enrichment — not bare field name', async () => {
const { candidates } = await extractFrontmatterLinks(
'companies/stripe', 'company' as never, { key_people: ['Pedro'] }, resolver,
);
// Per plan Finding 7: context must include field + value, not bare 'frontmatter.key_people'.
expect(candidates[0].context).toBe('frontmatter.key_people: Pedro');
});
test('pageType filter — field ignored on non-matching page', async () => {
// `company` field only fires on person pages. On a concept page it's ignored.
const { candidates } = await extractFrontmatterLinks(
'concepts/x', 'concept' as never, { company: 'Stripe' }, resolver,
);
expect(candidates).toHaveLength(0);
});
});
describe('makeResolver — fallback chain', () => {
// Minimal engine fake with controlled pages + findByTitleFuzzy.
function makeFakeEngine(
slugs: string[],
fuzzyMap: Map<string, { slug: string; similarity: number }> = new Map(),
): BrainEngine {
const lookup = new Set(slugs);
let getPageCalls = 0;
let fuzzyCalls = 0;
let searchCalls = 0;
const engine = {
async getPage(slug: string) {
getPageCalls++;
return lookup.has(slug) ? { slug } as any : null;
},
async findByTitleFuzzy(name: string) {
fuzzyCalls++;
return fuzzyMap.get(name) ?? null;
},
async searchKeyword() {
searchCalls++;
return [];
},
} as unknown as BrainEngine;
(engine as any)._counts = () => ({ getPageCalls, fuzzyCalls, searchCalls });
return engine;
}
test('step 1: slug passthrough', async () => {
const engine = makeFakeEngine(['people/pedro']);
const r = makeResolver(engine);
expect(await r.resolve('people/pedro')).toBe('people/pedro');
});
test('step 2: dir-hint construction', async () => {
const engine = makeFakeEngine(['companies/stripe']);
const r = makeResolver(engine);
expect(await r.resolve('Stripe', 'companies')).toBe('companies/stripe');
});
test('step 3: pg_trgm fuzzy hit', async () => {
const engine = makeFakeEngine(
['companies/brex'],
new Map([['Brex Inc', { slug: 'companies/brex', similarity: 0.8 }]]),
);
const r = makeResolver(engine);
expect(await r.resolve('Brex Inc', 'companies')).toBe('companies/brex');
});
test('batch mode NEVER calls searchKeyword (deterministic migration)', async () => {
const engine = makeFakeEngine([]);
const r = makeResolver(engine, { mode: 'batch' });
const result = await r.resolve('Unknown Name', 'companies');
expect(result).toBeNull();
const counts = (engine as any)._counts();
expect(counts.searchCalls).toBe(0);
});
test('cache: same name → single getPage call', async () => {
const engine = makeFakeEngine(['people/pedro']);
const r = makeResolver(engine);
await r.resolve('people/pedro');
await r.resolve('people/pedro');
await r.resolve('people/pedro');
const counts = (engine as any)._counts();
expect(counts.getPageCalls).toBe(1);
});
test('unresolvable → null (no dead link written)', async () => {
const engine = makeFakeEngine([]);
const r = makeResolver(engine, { mode: 'batch' });
expect(await r.resolve('Nonexistent Person', 'people')).toBeNull();
});
});
describe('FRONTMATTER_LINK_MAP integrity', () => {
test('every mapping has fields + type + direction + dirHint', () => {
for (const m of FRONTMATTER_LINK_MAP) {
expect(m.fields.length).toBeGreaterThan(0);
expect(m.type).toBeTruthy();
expect(['outgoing', 'incoming']).toContain(m.direction);
expect(m.dirHint !== undefined).toBe(true);
}
});
test('key_people maps to INCOMING works_at on company page', () => {
const m = FRONTMATTER_LINK_MAP.find(m => m.fields.includes('key_people'));
expect(m).toBeDefined();
expect(m!.direction).toBe('incoming');
expect(m!.pageType).toBe('company');
expect(m!.type).toBe('works_at');
});
test('attendees maps to INCOMING attended on meeting page', () => {
const m = FRONTMATTER_LINK_MAP.find(m => m.fields.includes('attendees'));
expect(m!.direction).toBe('incoming');
expect(m!.pageType).toBe('meeting');
expect(m!.type).toBe('attended');
});
test('investors uses multi-dir hint (companies/funds/people)', () => {
const m = FRONTMATTER_LINK_MAP.find(m => m.fields.includes('investors'));
expect(Array.isArray(m!.dirHint)).toBe(true);
expect(m!.dirHint).toContain('companies');
expect(m!.dirHint).toContain('funds');
expect(m!.dirHint).toContain('people');
});
});