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 <JiraiyaETH@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-07-21 14:38:20 -07:00
co-authored by JiraiyaETH Claude Fable 5
parent 0612b0daa8
commit 221238deed
5 changed files with 342 additions and 109 deletions
+68 -90
View File
@@ -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, unknown>): 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<string, unknown> {
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<string>,
@@ -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<string | null> {
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<string[]> {
return resolveBasenameMatchesFromSlugs(name, allSlugs);
},
};
const seen = new Set<string>();
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<string | null> {
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;
}
+102 -9
View File
@@ -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<PageLinksResult> {
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 "<Name> - <role>" 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 (13 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;
}
+5 -2
View File
@@ -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']);
+28 -8
View File
@@ -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');
});
});
+139
View File
@@ -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' });