From 221238deed31ca15fa6a5cd34ac0dc34d831febb Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 21 Jul 2026 14:38:20 -0700 Subject: [PATCH] fix(extract): subject-scope link-type inference + reserved-slug fuzzy guard (takeover of #2408) Salvages PR #2408: canonical inferLinkType on the FS-source path (replacing dir-based inferTypeByDir), a selfName subject-scope guard so a third party's role on a page isn't stamped on the page subject, and a reserved-slug ('_'-prefixed) guard on fuzzy resolution targets. Repairs the named blocker: bumps LINK_EXTRACTOR_VERSION_TS to 2026-07-21 so previously-stamped pages re-extract under the new inference (the constant's own contract), and derives extract-stale's 'recent' fixture date from the constant so future bumps can't silently break that test. Co-authored-by: JiraiyaETH Co-Authored-By: Claude Fable 5 --- src/commands/extract.ts | 158 +++++++++++++++-------------------- src/core/link-extraction.ts | 111 ++++++++++++++++++++++-- test/extract-stale.test.ts | 7 +- test/extract.test.ts | 36 ++++++-- test/link-extraction.test.ts | 139 ++++++++++++++++++++++++++++++ 5 files changed, 342 insertions(+), 109 deletions(-) diff --git a/src/commands/extract.ts b/src/commands/extract.ts index 21eeaaef5..f92799607 100644 --- a/src/commands/extract.ts +++ b/src/commands/extract.ts @@ -36,7 +36,7 @@ import type { PageType } from '../core/types.ts'; import { parseMarkdown } from '../core/markdown.ts'; import { extractPageLinks, parseTimelineEntries, inferLinkType, makeResolver, - extractFrontmatterLinks, isGlobalBasenameEnabled, LINK_EXTRACTOR_VERSION_TS, + isGlobalBasenameEnabled, LINK_EXTRACTOR_VERSION_TS, WIKILINK_BASENAME_LINK_TYPE, buildBasenameIndex, queryBasenameIndex, stripCodeBlocks, type UnresolvedFrontmatterRef, type LinkCandidate, @@ -63,6 +63,7 @@ import { createHash } from 'crypto'; import { runSlidingPool } from '../core/worker-pool.ts'; import { isAborted } from '../core/abort-check.ts'; import { parseWorkers, resolveWorkersWithClamp } from '../core/sync-concurrency.ts'; +import { ensureWellFormed } from '../core/text-safe.ts'; // Batch size for addLinksBatch / addTimelineEntriesBatch. // Postgres bind-parameter limit is 65535. Links use 4 cols/row → 16K hard ceiling; @@ -227,15 +228,15 @@ export function walkMarkdownFiles(dir: string): { path: string; relPath: string * (containing ://) are always skipped. For wikilinks, the .md suffix is added * if absent and section anchors (#heading) are stripped. */ -export function extractMarkdownLinks(content: string): { name: string; relTarget: string }[] { - const results: { name: string; relTarget: string }[] = []; +export function extractMarkdownLinks(content: string): { name: string; relTarget: string; index: number }[] { + const results: { name: string; relTarget: string; index: number }[] = []; const mdPattern = /\[([^\]]+)\]\(([^)]+\.md)\)/g; let match; while ((match = mdPattern.exec(content)) !== null) { const target = match[2]; if (target.includes('://')) continue; - results.push({ name: match[1], relTarget: target }); + results.push({ name: match[1], relTarget: target, index: match.index }); } const wikiPattern = /\[\[([^|\]]+?)(?:\|[^\]]*?)?\]\]/g; @@ -248,7 +249,7 @@ export function extractMarkdownLinks(content: string): { name: string; relTarget const relTarget = pagePath.endsWith('.md') ? pagePath : pagePath + '.md'; const pipeIdx = match[0].indexOf('|'); const displayName = pipeIdx >= 0 ? match[0].slice(pipeIdx + 1, -2).trim() : rawPath; - results.push({ name: displayName, relTarget }); + results.push({ name: displayName, relTarget, index: match.index }); } return results; @@ -332,49 +333,21 @@ export function resolveSlugAll( return resolveBasenameMatchesFromSlugs(basename, allSlugs); } -/** - * Directory-based link-type inference for the fs-source path. - * - * FS-source operates without a BrainEngine. We have paths, not pages. This - * helper looks at source + target directories and returns a type aligned - * with the canonical `inferLinkType` in link-extraction.ts (calibrated - * verb-based inference for db-source). - * - * v0.13: aligned type names with link-extraction.ts (was: 'mention' → - * 'mentions', 'attendee' → 'attended'). Diverged historically; the v0_13_0 - * migration normalizes any legacy rows on existing brains. - */ -function inferTypeByDir(fromDir: string, toDir: string, frontmatter?: Record): string { - const from = fromDir.split('/')[0]; - const to = toDir.split('/')[0]; - if (from === 'people' && to === 'companies') { - if (Array.isArray(frontmatter?.founded)) return 'founded'; - return 'works_at'; - } - if (from === 'people' && to === 'deals') return 'involved_in'; - if (from === 'deals' && to === 'companies') return 'deal_for'; - if (from === 'meetings' && to === 'people') return 'attended'; - return 'mentions'; -} - -/** Parse frontmatter using the project's gray-matter-based parser */ -function parseFrontmatterFromContent(content: string, relPath: string): Record { - try { - const parsed = parseMarkdown(content, relPath); - return parsed.frontmatter; - } catch { - return {}; - } +function excerptForInference(s: string, idx: number, width: number): string { + const half = Math.floor(width / 2); + const start = Math.max(0, idx - half); + const end = Math.min(s.length, idx + half); + return ensureWellFormed(s.slice(start, end)).replace(/\s+/g, ' ').trim(); } /** * Full link extraction from a single markdown file (FS-source path). * - * Async (v0.13): uses the canonical `extractFrontmatterLinks` via a - * synthetic resolver backed by the pre-loaded `allSlugs` Set. No DB, - * no fuzzy match — FS-source resolves only when the dir-hint + slugify - * of the frontmatter value hits an actual file path. That mirrors the - * fs path's existing "exact match against disk" behavior. + * Async (v0.13): uses the canonical `extractPageLinks` via a synthetic + * resolver backed by the pre-loaded `allSlugs` Set. No DB, no fuzzy match — + * FS-source resolves only when the dir-hint + slugify of the frontmatter value + * hits an actual file path. That mirrors the fs path's existing "exact match + * against disk" behavior. */ export async function extractLinksFromFile( content: string, relPath: string, allSlugs: Set, @@ -383,18 +356,63 @@ export async function extractLinksFromFile( const links: ExtractedLink[] = []; const slug = pathToSlug(relPath); const fileDir = dirname(relPath); - const fm = parseFrontmatterFromContent(content, relPath); + const parsed = parseMarkdown(content, relPath); + const bodyContent = [parsed.compiled_truth, parsed.timeline].filter(Boolean).join('\n\n'); + const frontmatterForLinks = { ...parsed.frontmatter, title: parsed.title }; // Issue #972: globalBasename routes bare `[[name]]` wikilinks through // basename lookup against allSlugs when the ancestor walk fails. Off // by default for back-compat with the v0.10.1 ancestor-only behavior. const globalBasename = opts?.globalBasename ?? false; + const slugify = (s: string) => s.toLowerCase().replace(/[^a-z0-9\s-]/g, '').trim().replace(/\s+/g, '-'); + const fsResolver = { + async resolve(name: string, dirHint?: string | string[]): Promise { + if (!name) return null; + const trimmed = name.trim(); + if (/^[a-z][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/.test(trimmed) && allSlugs.has(trimmed)) { + return trimmed; + } + const hints = Array.isArray(dirHint) ? dirHint : (dirHint ? [dirHint] : []); + for (const hint of hints) { + if (!hint) continue; + const candidate = `${hint}/${slugify(trimmed)}`; + if (allSlugs.has(candidate)) return candidate; + } + return null; + }, + async resolveBasenameMatches(name: string): Promise { + return resolveBasenameMatchesFromSlugs(name, allSlugs); + }, + }; + + const seen = new Set(); + function pushLink(link: ExtractedLink) { + if (!allSlugs.has(link.from_slug) || !allSlugs.has(link.to_slug)) return; + const key = `${link.from_slug}::${link.to_slug}::${link.link_type}::${link.link_source ?? 'markdown'}`; + if (seen.has(key)) return; + seen.add(key); + links.push(link); + } + + const extracted = await extractPageLinks( + slug, bodyContent, frontmatterForLinks, parsed.type, fsResolver, + { skipFrontmatter: !opts?.includeFrontmatter, globalBasename }, + ); + for (const c of extracted.candidates) { + pushLink({ + from_slug: c.fromSlug ?? slug, + to_slug: c.targetSlug, + link_type: c.linkType, + context: c.context, + link_source: c.linkSource, + }); + } // Issue #972 (codex [P2]): strip code fences before scanning so a // `[[name]]` inside a code block doesn't create an FS edge. Mirrors the // DB path, which goes through extractEntityRefs (which strips internally). - const scanContent = stripCodeBlocks(content); + const scanContent = stripCodeBlocks(bodyContent); - for (const { name, relTarget } of extractMarkdownLinks(scanContent)) { + for (const { name, relTarget, index } of extractMarkdownLinks(scanContent)) { const resolvedSlugs = resolveSlugAll(fileDir, relTarget, allSlugs, { globalBasename }); if (resolvedSlugs.length === 0) continue; // Single hit on the ancestor path → emit one edge with the inferred @@ -409,15 +427,14 @@ export async function extractLinksFromFile( // Issue #972 (codex [P2]): drop a basename self-loop ([[own-tail]] on // its own page resolving back to itself). if (isBasename && target === slug) continue; - links.push({ + const context = excerptForInference(scanContent, index, 240) || `markdown link: [${name}]`; + pushLink({ from_slug: slug, to_slug: target, link_type: isBasename ? WIKILINK_BASENAME_LINK_TYPE - : inferTypeByDir(fileDir, dirname(target), fm), - context: isBasename - ? `wikilink (basename match): [${name}]` - : `markdown link: [${name}]`, + : inferLinkType(parsed.type, context, bodyContent, target, parsed.title), + context, // Issue #972: tag basename edges so the FS path matches DB/put_page // provenance and migration v112's widened CHECK is exercised here too. link_source: isBasename ? 'wikilink-resolved' : undefined, @@ -425,45 +442,6 @@ export async function extractLinksFromFile( } } - if (opts?.includeFrontmatter) { - // Synthetic sync-ish resolver: only does step 1 (already a slug) and - // step 2 (dir-hint + slugify), backed by the Set of all known slugs. - const slugify = (s: string) => s.toLowerCase().replace(/[^a-z0-9\s-]/g, '').trim().replace(/\s+/g, '-'); - const fsResolver = { - async resolve(name: string, dirHint?: string | string[]): Promise { - if (!name) return null; - const trimmed = name.trim(); - if (/^[a-z][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/.test(trimmed) && allSlugs.has(trimmed)) { - return trimmed; - } - const hints = Array.isArray(dirHint) ? dirHint : (dirHint ? [dirHint] : []); - for (const hint of hints) { - if (!hint) continue; - const candidate = `${hint}/${slugify(trimmed)}`; - if (allSlugs.has(candidate)) return candidate; - } - return null; - }, - }; - // Guess the page type from its directory for field-map filtering. - const topDir = slug.split('/')[0]; - const pageType = topDir === 'people' ? 'person' - : topDir === 'companies' ? 'company' - : topDir === 'deals' || topDir === 'deal' ? 'deal' - : topDir === 'meetings' ? 'meeting' - : 'concept'; - const fm = parseFrontmatterFromContent(content, relPath); - const fmLinks = await extractFrontmatterLinks(slug, pageType as never, fm, fsResolver); - for (const c of fmLinks.candidates) { - links.push({ - from_slug: c.fromSlug ?? slug, - to_slug: c.targetSlug, - link_type: c.linkType, - context: c.context, - }); - } - } - return links; } diff --git a/src/core/link-extraction.ts b/src/core/link-extraction.ts index c0fc2644a..16085ddc6 100644 --- a/src/core/link-extraction.ts +++ b/src/core/link-extraction.ts @@ -28,7 +28,7 @@ import { ensureWellFormed } from './text-safe.ts'; * OR updated_at > links_extracted_at`. It is an ISO-8601 string (NOT a number) — * the column is TIMESTAMPTZ and the predicate binds it as `::timestamptz`. */ -export const LINK_EXTRACTOR_VERSION_TS = '2026-05-31T00:00:00Z'; +export const LINK_EXTRACTOR_VERSION_TS = '2026-07-21T00:00:00Z'; // ─── Entity references ────────────────────────────────────────── @@ -472,6 +472,14 @@ export async function extractPageLinks( ): Promise { const candidates: LinkCandidate[] = []; + // Page subject's display name, for the inferLinkType subject-scope guard: a + // role keyword attributed to a DIFFERENT named person on this page must not be + // stamped on the page's outbound company edges. + const rawTitle = frontmatter?.title; + const selfName = (typeof rawTitle === 'string' && rawTitle.trim()) + ? rawTitle.trim() + : (slug.split('/').pop()?.replace(/[-_]+/g, ' ').trim() || undefined); + // 1. Markdown entity refs. for (const ref of extractEntityRefs(content)) { // Issue #972: refs from the generic `[[bare-name]]` pass carry the @@ -514,7 +522,7 @@ export async function extractPageLinks( const context = idx >= 0 ? excerpt(content, idx, 240) : ref.name; candidates.push({ targetSlug: ref.slug, - linkType: inferLinkType(pageType, context, content, ref.slug), + linkType: inferLinkType(pageType, context, content, ref.slug, selfName), context, linkSource: 'markdown', }); @@ -536,7 +544,7 @@ export async function extractPageLinks( const context = excerpt(strippedContent, m.index, 240); candidates.push({ targetSlug: m[1], - linkType: inferLinkType(pageType, context, content, m[1]), + linkType: inferLinkType(pageType, context, content, m[1], selfName), context, linkSource: 'markdown', }); @@ -675,6 +683,81 @@ const ADVISOR_ROLE_RE = /\b(?:full-time advisor|professional advisor|advises (?: // pages mentioning their employees use the page-role layer differently. const EMPLOYEE_ROLE_RE = /\b(?:is an? (?:senior|staff|principal|lead|backend|frontend|full-?stack|ML|data|security|DevOps|platform)? ?engineer at|is an? (?:senior|staff|principal|lead)? ?(?:developer|designer|product manager|engineering manager|director|VP) (?:at|of)|holds? the (?:CTO|CEO|CFO|COO|CMO|CRO|VP) (?:role|position|seat|title) at|is the (?:CTO|CEO|CFO|COO|CMO|CRO) of|employee at|on the team at|works on .{0,30} at)\b/i; +// Pronouns at a clause head refer back to the page subject, not a third party. +const PRONOUN_SUBJECT_RE = /^(?:he|she|they|his|her|their|its|my|our|we|i)$/i; + +/** + * Subject-attribution guard for the page-role prior. Returns true when the role + * keyword at `matchIdx` is attributed to a person OTHER than the page subject + * (`selfName`). Without it, a third party's role stated on someone else's page — + * e.g. "Alice is an investor in [[acme]]" appearing on Bob's page — would stamp + * `invested_in` on the page subject. Legacy callers that omit `selfName` keep the + * prior's original behavior (returns false), so single-subject inference is + * unchanged; the extraction call sites thread `selfName` and get the fix. + * + * Known limitations (each fails safe to the pre-guard "apply", never worse than + * pre-patch): a non-Latin clause subject (CJK/Cyrillic) isn't parsed by the Latin + * clause-head regex, and a role written as " - " splits at the + * list-marker boundary — both fall through to legacy behavior rather than + * suppressing. + */ +function rolePriorThirdParty(text: string, matchIdx: number, selfName?: string): boolean { + if (!selfName) return false; + const before = text.slice(0, matchIdx); + // Clause = text after the nearest preceding boundary (sentence end, ';', + // newline, or list marker) up to the role keyword. + const boundary = Math.max( + before.lastIndexOf('. '), before.lastIndexOf('! '), before.lastIndexOf('? '), + before.lastIndexOf('\n'), before.lastIndexOf(';'), + before.lastIndexOf(') '), before.lastIndexOf('- '), + ); + const clause = before.slice(boundary + 1).replace(/^\W+/, ''); + // Clause-head subject: a leading name/pronoun token sequence (1–3 words). + const head = clause.match(/^([A-Za-z][A-Za-z0-9.'’-]*(?:\s+[A-Z][A-Za-z0-9.'’-]+){0,2})/); + if (!head) return false; + const first = head[1].split(/\s+/)[0]; + if (PRONOUN_SUBJECT_RE.test(first)) return false; // pronoun → the page subject + if (!/^[A-Z]/.test(first)) return false; // not a name → can't judge + const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim(); + const selfTokens = norm(selfName).split(' ').filter(Boolean); + const subjTokens = norm(head[1]).split(' ').filter(Boolean); + if (selfTokens.length === 0 || subjTokens.length === 0) return false; + // Same person iff one name is a token-subset of the other ("Wendy" vs "Wendy + // Chen", "Adam" vs "Adam Lopez"). Two distinct people who merely SHARE a token + // ("John Doe" vs "John Smith") are a subset neither way → treated as third party. + const selfSet = new Set(selfTokens); + const subjSet = new Set(subjTokens); + const subjSubsetOfSelf = subjTokens.every((t) => selfSet.has(t)); + const selfSubsetOfSubj = selfTokens.every((t) => subjSet.has(t)); + return !(subjSubsetOfSelf || selfSubsetOfSubj); +} + +/** + * True if ANY match of `re` in `text` is a role attributed to the page subject + * (not a third party). Scans every match, not just the first — otherwise an + * earlier third-party role ("Alice is an investor…") on the page would suppress + * the page subject's own later role and the prior would wrongly fall to 'mentions'. + */ +function roleAppliesToSubject(re: RegExp, text: string, selfName?: string): boolean { + const g = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g'); + let m: RegExpExecArray | null; + while ((m = g.exec(text)) !== null) { + if (!rolePriorThirdParty(text, m.index, selfName)) return true; + if (m.index === g.lastIndex) g.lastIndex++; // guard against zero-width matches + } + return false; +} + +/** + * Reserved/internal pages (basename starts with '_', e.g. test fixtures like + * `meetings/_dirtest-zzz`) must never be a fuzzy-resolution TARGET: a weak + * wikilink/attendee name can cross the 0.55 similarity floor against them and + * silently redirect a real reference to junk. + */ +function isReservedSlug(slug: string): boolean { + return slug.slice(slug.lastIndexOf('/') + 1).startsWith('_'); +} + /** * Infer link_type from page context. Deterministic regex heuristics, no LLM. * @@ -683,15 +766,21 @@ const EMPLOYEE_ROLE_RE = /\b(?:is an? (?:senior|staff|principal|lead|backend|fro * verbs (FOUNDED_RE, INVESTED_RE, ADVISES_RE, WORKS_AT_RE). * 2. Page-role prior: when per-edge inference falls through to 'mentions', * check if the SOURCE page describes the author as a partner/investor. - * If yes, bias outbound company refs toward 'invested_in'. + * If yes, bias outbound company refs toward 'invested_in' — but ONLY when + * the role is attributed to the page subject (`selfName`), not to a third + * party named elsewhere on the page (see `rolePriorThirdParty`). * * Precedence: founded > invested_in > advises > works_at > role prior > mentions. * * The role-prior layer is what closes the gap on partner bios where the prose * lists portfolio companies without repeating the investment verb each time * ("Her current board seats reflect her portfolio: [Co A], [Co B], [Co C]"). + * + * @param selfName Display name of the page subject; gates the page-role prior + * (layer 2) so a third party's role mentioned on the page isn't attributed to + * the subject. Optional — legacy callers that omit it keep pre-guard behavior. */ -export function inferLinkType(pageType: PageType, context: string, globalContext?: string, targetSlug?: string): string { +export function inferLinkType(pageType: PageType, context: string, globalContext?: string, targetSlug?: string, selfName?: string): string { if (pageType === 'media') { return 'mentions'; } @@ -716,9 +805,13 @@ export function inferLinkType(pageType: PageType, context: string, globalContext // employee/advisor match would mis-classify; keep investor first so those // phrasings resolve correctly. if (pageType === 'person' && globalContext && targetSlug?.startsWith('companies/')) { - if (PARTNER_ROLE_RE.test(globalContext)) return 'invested_in'; - if (ADVISOR_ROLE_RE.test(globalContext)) return 'advises'; - if (EMPLOYEE_ROLE_RE.test(globalContext)) return 'works_at'; + // Subject-scope guard: apply a page-role prior only when the role is + // attributed to the page SUBJECT, not a third party named on the page. + // Scans ALL matches so an earlier third-party role can't suppress the + // subject's own later role. + if (roleAppliesToSubject(PARTNER_ROLE_RE, globalContext, selfName)) return 'invested_in'; + if (roleAppliesToSubject(ADVISOR_ROLE_RE, globalContext, selfName)) return 'advises'; + if (roleAppliesToSubject(EMPLOYEE_ROLE_RE, globalContext, selfName)) return 'works_at'; } return 'mentions'; } @@ -969,7 +1062,7 @@ export function makeResolver( const searchHints = hints.length > 0 ? hints : [undefined]; for (const hint of searchHints) { const match = await engine.findByTitleFuzzy(trimmed, hint, 0.55); - if (match) { + if (match && !isReservedSlug(match.slug)) { cache.set(cacheKey, match.slug); return match.slug; } diff --git a/test/extract-stale.test.ts b/test/extract-stale.test.ts index a725e309b..61238d441 100644 --- a/test/extract-stale.test.ts +++ b/test/extract-stale.test.ts @@ -187,8 +187,11 @@ describe('gbrain extract --stale', () => { await engine.putPage('people/alice', personPage('Alice')); await engine.putPage('companies/acme', companyPage('Acme', '[Alice](people/alice) advises [Acme](companies/acme).')); // Microsecond-precision updated_at, recent (after LINK_EXTRACTOR_VERSION_TS) so the - // version arm doesn't fire — the edited arm is what must clear. - await engine.executeRaw(`UPDATE pages SET updated_at = '2026-06-02 08:18:58.999166+00'`); + // version arm doesn't fire — the edited arm is what must clear. Derived from the + // constant so bumping LINK_EXTRACTOR_VERSION_TS doesn't silently break this test. + const recentDay = new Date(Date.parse(LINK_EXTRACTOR_VERSION_TS) + 2 * 86_400_000) + .toISOString().slice(0, 10); + await engine.executeRaw(`UPDATE pages SET updated_at = '${recentDay} 08:18:58.999166+00'`); expect(await engine.countStalePagesForExtraction({ versionTs: LINK_EXTRACTOR_VERSION_TS })).toBe(2); await runExtract(engine, ['--stale']); diff --git a/test/extract.test.ts b/test/extract.test.ts index 5764de52b..381f0f9a2 100644 --- a/test/extract.test.ts +++ b/test/extract.test.ts @@ -85,18 +85,38 @@ describe('extractLinksFromFile', () => { 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); + it('uses canonical verb inference for filesystem markdown links', async () => { + const content = 'Alice works at [Acme](../companies/acme.md).'; + const allSlugs = new Set(['people/alice', 'companies/acme']); + 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']); + it('does not stamp a relationship type from directory structure alone', async () => { + const content = 'See [Acme](../companies/acme.md).'; + const allSlugs = new Set(['deals/seed', 'companies/acme']); const links = await extractLinksFromFile(content, 'deals/seed.md', allSlugs); - expect(links[0].link_type).toBe('deal_for'); + expect(links[0].link_type).toBe('mentions'); + }); + + it('applies subject-scoped page-role inference on the filesystem path', async () => { + const filler = 'The team kept shipping updates while customers reviewed the roadmap and the operating notes stayed deliberately boring. '.padEnd(160, '.'); + const content = `---\ntitle: Alice\ntype: person\n---\n\nBob is an investor in early-stage startups.${filler}See [Acme](../companies/acme.md).`; + const allSlugs = new Set(['people/alice', 'companies/acme']); + const links = await extractLinksFromFile(content, 'people/alice.md', allSlugs); + const acme = links.find(l => l.to_slug === 'companies/acme'); + expect(acme).toBeDefined(); + expect(acme!.link_type).toBe('mentions'); + }); + + it('keeps the page subject role prior when the filesystem page title matches', async () => { + const filler = 'The team kept shipping updates while customers reviewed the roadmap and the operating notes stayed deliberately boring. '.padEnd(160, '.'); + const content = `---\ntitle: Alice\ntype: person\n---\n\nAlice is an investor in early-stage startups.${filler}See [Acme](../companies/acme.md).`; + const allSlugs = new Set(['people/alice', 'companies/acme']); + const links = await extractLinksFromFile(content, 'people/alice.md', allSlugs); + const acme = links.find(l => l.to_slug === 'companies/acme'); + expect(acme).toBeDefined(); + expect(acme!.link_type).toBe('invested_in'); }); }); diff --git a/test/link-extraction.test.ts b/test/link-extraction.test.ts index e0a741220..4f2990a50 100644 --- a/test/link-extraction.test.ts +++ b/test/link-extraction.test.ts @@ -474,6 +474,49 @@ describe('extractPageLinks', () => { ); expect(candidates).toEqual([]); }); + + // ─── selfName derivation + threading into the page-role guard ─────────── + // extractPageLinks derives selfName (frontmatter.title, else slug basename) + // and threads it to inferLinkType, so a role attributed to a THIRD PARTY in + // the page body no longer stamps a typed verb on the page subject's company + // edges. The role clause is placed >120 chars from the slug so the per-edge + // excerpt can't see the verb itself — the page-role prior is what's under test. + const FILLER = ' '.repeat(0) + 'and the team kept shipping features through the quarter while the rest of the org watched the numbers climb steadily upward overall. '.padEnd(160, '.'); + + test('selfName from frontmatter.title: third-party investor → company edge is mentions', async () => { + const content = `Carol is an investor in early-stage startups.${FILLER}See companies/acme for context.`; + const { candidates } = await extractPageLinks( + 'people/bob', content, { title: 'Bob' }, 'person', allowAllResolver, + ); + const acme = candidates.find(c => c.targetSlug === 'companies/acme'); + expect(acme).toBeDefined(); + expect(acme!.linkType).toBe('mentions'); + }); + + test('selfName from slug basename: third-party investor → company edge is mentions', async () => { + // No frontmatter.title, so selfName falls back to the slug basename + // ("bob-example" → "bob example"). The clause subject "Carol" shares no + // token with it, so the prior is suppressed — proving the basename branch. + const content = `Carol is an investor in early-stage startups.${FILLER}See companies/acme for context.`; + const { candidates } = await extractPageLinks( + 'people/bob-example', content, {}, 'person', allowAllResolver, + ); + const acme = candidates.find(c => c.targetSlug === 'companies/acme'); + expect(acme).toBeDefined(); + expect(acme!.linkType).toBe('mentions'); + }); + + test('selfName matches the page subject: page-role prior still applies', async () => { + // Same shape, but the role clause subject IS the page subject (title=Carol), + // so the guard does NOT suppress and the invested_in prior fires. + const content = `Carol is an investor in early-stage startups.${FILLER}See companies/acme for context.`; + const { candidates } = await extractPageLinks( + 'people/carol', content, { title: 'Carol' }, 'person', allowAllResolver, + ); + const acme = candidates.find(c => c.targetSlug === 'companies/acme'); + expect(acme).toBeDefined(); + expect(acme!.linkType).toBe('invested_in'); + }); }); // ─── inferLinkType ───────────────────────────────────────────── @@ -657,6 +700,79 @@ describe('inferLinkType', () => { const perEdgeContext = 'Jane has worked with Acme.'; expect(inferLinkType('person', perEdgeContext, globalContext, 'companies/acme')).toBe('invested_in'); }); + + // ─── Subject-scope guard on the page-role prior ───────────────────── + // A role keyword attributed to a THIRD PARTY on a person's page must not be + // stamped on the page subject. Regression for the case where "Alice is an + // investor in [[acme]]" on Bob's page produced bob --invested_in--> acme. + // The guard activates only when selfName is threaded (the production extraction + // path); legacy 4-arg calls keep the prior's original behavior. + + test('subject-scope: third-party investor on a person page → mentions (not invested_in)', () => { + const globalContext = 'Carol is the founder of Acme. Dave is a personal investor in Acme.'; + const perEdge = 'builds developer tooling for on-chain teams'; + expect(inferLinkType('person', perEdge, globalContext, 'companies/acme', 'Carol')).toBe('mentions'); + }); + + test('subject-scope: third-party employee on a person page → mentions (not works_at)', () => { + const globalContext = 'Dana runs design. Bob is a senior engineer at Delta.'; + const perEdge = 'Delta ships weekly.'; + expect(inferLinkType('person', perEdge, globalContext, 'companies/delta-3', 'Dana')).toBe('mentions'); + }); + + test('subject-scope: role attributed to the page SUBJECT still infers', () => { + const globalContext = 'Adam Lopez is a senior engineer at Delta. His work is excellent.'; + const perEdge = 'His work shipped on time.'; + expect(inferLinkType('person', perEdge, globalContext, 'companies/delta-3', 'Adam Lopez')).toBe('works_at'); + }); + + test('subject-scope: pronoun subject + first-name self still infers', () => { + const globalContext = 'Wendy is a venture partner. Her portfolio includes the firm.'; + const perEdge = 'discussed Cipher recently'; + expect(inferLinkType('person', perEdge, globalContext, 'companies/cipher-13', 'Wendy Chen')).toBe('invested_in'); + }); + + test('subject-scope: legacy callers without selfName keep prior behavior', () => { + const globalContext = 'Dave is a personal investor in Acme.'; + expect(inferLinkType('person', 'mentions Acme', globalContext, 'companies/acme')).toBe('invested_in'); + }); + + test('subject-scope: pronoun-led role clause attributes to the page subject', () => { + // selfName shares NO token with the clause head 'His'; only the pronoun rule + // (not token overlap) can attribute the role to the page subject here. + const globalContext = 'His portfolio includes several seed-stage companies.'; + expect(inferLinkType('person', 'mentions Acme', globalContext, 'companies/acme', 'Zeb Quill')).toBe('invested_in'); + }); + + test('subject-scope: distinct people sharing one name token → third party suppressed', () => { + // "John Doe" and "John Smith" share "john" but are different people; the role + // must NOT be stamped on the page subject. (Subset-neither-way → third party.) + const globalContext = 'John Doe is a personal investor in Acme.'; + expect(inferLinkType('person', 'mentions Acme', globalContext, 'companies/acme', 'John Smith')).toBe('mentions'); + }); + + test("subject-scope: subject's own role applies even when a third party is mentioned first", () => { + // First PARTNER match is the third party (Alice); the scan must continue to + // the page subject's own later role (Bob) instead of falling to 'mentions'. + const globalContext = 'Alice is an investor in widgets. Bob is an investor in Acme.'; + expect(inferLinkType('person', 'mentions Acme', globalContext, 'companies/acme', 'Bob')).toBe('invested_in'); + }); + + test('subject-scope: third-party advisor on a person page → mentions (not advises)', () => { + // Exercises the ADVISOR_ROLE_RE arm through the guard. Per-edge context is + // benign so the advisor prior is the only thing that could fire. + const globalContext = 'Carol is an advisor to several startups. Bob runs growth.'; + const perEdge = 'ships product weekly'; + expect(inferLinkType('person', perEdge, globalContext, 'companies/acme', 'Bob')).toBe('mentions'); + }); + + test('subject-scope: lowercase clause head (not a name) → prior still fires', () => { + // "the firm" leads the role clause: not a pronoun, not capitalized, so the + // guard can't judge attribution and falls back to applying the prior even + // though selfName differs. Mirrors src `if (!/^[A-Z]/.test(first)) return false`. + const globalContext = 'the firm is an investor in many seed-stage companies'; + expect(inferLinkType('person', 'mentions Acme', globalContext, 'companies/acme', 'Bob')).toBe('invested_in'); + }); }); // ─── parseTimelineEntries ────────────────────────────────────── @@ -998,6 +1114,29 @@ describe('makeResolver — fallback chain', () => { expect(await r.resolve('Brex Inc', 'companies')).toBe('companies/brex'); }); + test('step 3: fuzzy match to a reserved (_-prefixed) slug is rejected', async () => { + // isReservedSlug guard: a weak name must NOT fuzzy-resolve onto a + // reserved/test page (basename starting with '_'). Batch mode skips the + // step-4 search fallback, so the reserved hit becoming null is observable. + const engine = makeFakeEngine( + ['meetings/_dirtest-zzz'], + new Map([['Dirtest', { slug: 'meetings/_dirtest-zzz', similarity: 0.9 }]]), + ); + const r = makeResolver(engine, { mode: 'batch' }); + expect(await r.resolve('Dirtest', 'meetings')).toBeNull(); + }); + + test('step 3: a real (non-reserved) fuzzy hit still resolves under the guard', async () => { + // Control for the reserved-slug guard: an ordinary basename resolves + // normally, proving the guard only filters '_'-prefixed targets. + const engine = makeFakeEngine( + ['people/alice'], + new Map([['Alice Q', { slug: 'people/alice', similarity: 0.9 }]]), + ); + const r = makeResolver(engine, { mode: 'batch' }); + expect(await r.resolve('Alice Q', 'people')).toBe('people/alice'); + }); + test('batch mode NEVER calls searchKeyword (deterministic migration)', async () => { const engine = makeFakeEngine([]); const r = makeResolver(engine, { mode: 'batch' });