diff --git a/src/commands/extract.ts b/src/commands/extract.ts index 98176e317..4feb172ed 100644 --- a/src/commands/extract.ts +++ b/src/commands/extract.ts @@ -43,6 +43,8 @@ import { } from '../core/link-extraction.ts'; import { createProgress } from '../core/progress.ts'; import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts'; +import { loadActivePackBestEffort } from '../core/schema-pack/best-effort.ts'; +import { packLinkExtractionView } from '../core/schema-pack/link-inference.ts'; import { pathToSlug, pruneDir, isSyncable } from '../core/sync.ts'; // v0.41.18.0: withRetry + isRetryableConnError + WithRetryOpts moved to // src/core/retry.ts as the canonical primitive. Engine methods @@ -1381,6 +1383,9 @@ async function extractLinksFromDB( // Issue #972: opt-in global-basename wikilink resolution. Read once // per extract run; threaded into each extractPageLinks call. const globalBasename = await isGlobalBasenameEnabled(engine); + // #3190: active custom pack (null for the codegen'd base packs) so pack + // path_prefixes / link verbs / frontmatter_links drive extraction. + const pack = packLinkExtractionView(await loadActivePackBestEffort({ engine } as never)); // v0.32.8: listAllPageRefs enumerates (slug, source_id) so we can thread // sourceId to getPage AND build a cross-source resolution map for link // disambiguation. Pre-fix used getAllSlugs() which collapsed @@ -1461,7 +1466,7 @@ async function extractLinksFromDB( // basename lookup; off by default for back-compat. const extracted = await extractPageLinks( slug, fullContent, page.frontmatter, page.type, resolver, - { skipFrontmatter: !includeFrontmatter, globalBasename }, + { skipFrontmatter: !includeFrontmatter, globalBasename, pack }, ); unresolved.push(...extracted.unresolved); @@ -1687,6 +1692,9 @@ export async function extractStaleFromDB( const resolver = makeResolver(engine, { mode: 'batch' }); const nullResolver = { resolve: async () => null as string | null }; const activeResolver = includeFrontmatter ? resolver : nullResolver; + // #3190: same pack threading as extractLinksFromDB so `extract --stale` + // produces the same edges as a manual `extract links --source db`. + const pack = packLinkExtractionView(await loadActivePackBestEffort({ engine } as never)); const allRefs = await engine.listAllPageRefs(); const allSlugs = new Set(); const slugToSources = new Map(); @@ -1719,6 +1727,7 @@ export async function extractStaleFromDB( const fullContent = page.compiled_truth + '\n' + page.timeline; const extracted = await extractPageLinks( page.slug, fullContent, page.frontmatter, page.type, activeResolver, + { pack }, ); for (const c of extracted.candidates) { const r = resolveCandidateSources(c, page.slug, page.source_id, allSlugs, slugToSources); diff --git a/src/core/link-extraction.ts b/src/core/link-extraction.ts index 6ff2f6822..b68dad8f1 100644 --- a/src/core/link-extraction.ts +++ b/src/core/link-extraction.ts @@ -14,6 +14,8 @@ import type { BrainEngine } from './engine.ts'; import type { PageType } from './types.ts'; import { ensureWellFormed } from './text-safe.ts'; +import { inferLinkTypeFromPack, type PackLinkExtraction } from './schema-pack/link-inference.ts'; +import { PageRegexBudget } from './schema-pack/redos-guard.ts'; /** * v0.42.7 — link-extraction version stamp. Bump this ISO timestamp whenever the @@ -28,7 +30,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-23T00:00:00Z'; // ─── Entity references ────────────────────────────────────────── @@ -83,7 +85,22 @@ export type LinkResolutionType = 'qualified' | 'unqualified'; * - Our domain extensions: tech, finance, personal, openclaw (domain-organized wikis) * - Our entity prefix: entities (we kept some legacy entities/projects/ pages) */ -const DIR_PATTERN = '(?:people|companies|meetings|concepts|deal|civic|project|projects|source|media|yc|tech|finance|personal|openclaw|entities)'; +const DIR_ALTERNATIVES = 'people|companies|meetings|concepts|deal|civic|project|projects|source|media|yc|tech|finance|personal|openclaw|entities'; +const DIR_PATTERN = `(?:${DIR_ALTERNATIVES})`; + +/** + * #3190 — union the built-in dir whitelist with pack-declared entity dirs + * (from `page_types[].path_prefixes`, pre-validated to a slug-safe charset + * by `packLinkExtractionView`). Longer alternatives sort first so a nested + * prefix like `wiki/people` wins over a hypothetical `wiki`. + */ +function dirPatternWith(extraDirs?: string[]): string { + if (!extraDirs || extraDirs.length === 0) return DIR_PATTERN; + const extras = [...new Set(extraDirs)] + .sort((a, b) => (b.length - a.length) || a.localeCompare(b)) + .map(d => d.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + return `(?:${extras.join('|')}|${DIR_ALTERNATIVES})`; +} /** * Match `[Name](path)` markdown links pointing to entity directories. @@ -95,8 +112,8 @@ const DIR_PATTERN = '(?:people|companies|meetings|concepts|deal|civic|project|pr * The regex permits an optional `../` prefix (any number) and an optional * `.md` suffix so the same function works for both filesystem and DB content. */ -const ENTITY_REF_RE = new RegExp( - `\\[([^\\]]+)\\]\\((?:\\.\\.\\/)*(${DIR_PATTERN}\\/[^)\\s]+?)(?:\\.md)?\\)`, +const entityRefRe = (dirPattern: string) => new RegExp( + `\\[([^\\]]+)\\]\\((?:\\.\\.\\/)*(${dirPattern}\\/[^)\\s]+?)(?:\\.md)?\\)`, 'g', ); @@ -104,12 +121,12 @@ const ENTITY_REF_RE = new RegExp( * Match Obsidian-style `[[path]]` or `[[path|Display Text]]` wikilinks. * Captures: slug (dir/...), displayName (optional). * - * Same dir whitelist as ENTITY_REF_RE. Strips trailing `.md`, strips section + * Same dir whitelist as entityRefRe. Strips trailing `.md`, strips section * anchors (`#heading`), skips external URLs. Wiki KBs use this format almost * exclusively so missing it leaves the graph empty. */ -const WIKILINK_RE = new RegExp( - `\\[\\[(${DIR_PATTERN}\\/[^|\\]#]+?)(?:#[^|\\]]*?)?(?:\\|([^\\]]+?))?\\]\\]`, +const wikilinkRe = (dirPattern: string) => new RegExp( + `\\[\\[(${dirPattern}\\/[^|\\]#]+?)(?:#[^|\\]]*?)?(?:\\|([^\\]]+?))?\\]\\]`, 'g', ); @@ -121,12 +138,12 @@ const WIKILINK_RE = new RegExp( * * Captures: sourceId, slug (dir/...), displayName (optional). * - * Matched BEFORE WIKILINK_RE so `[[wiki:topics/ai]]` isn't mis-parsed by - * the unqualified regex (the source prefix would not satisfy DIR_PATTERN - * anyway, but the two-pass approach keeps intent crystal-clear). + * Matched BEFORE the unqualified wikilink pass so `[[wiki:topics/ai]]` isn't + * mis-parsed by the unqualified regex (the source prefix would not satisfy + * DIR_PATTERN anyway, but the two-pass approach keeps intent crystal-clear). */ -const QUALIFIED_WIKILINK_RE = new RegExp( - `\\[\\[([a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?):(${DIR_PATTERN}\\/[^|\\]#]+?)(?:#[^|\\]]*?)?(?:\\|([^\\]]+?))?\\]\\]`, +const qualifiedWikilinkRe = (dirPattern: string) => new RegExp( + `\\[\\[([a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?):(${dirPattern}\\/[^|\\]#]+?)(?:#[^|\\]]*?)?(?:\\|([^\\]]+?))?\\]\\]`, 'g', ); @@ -298,17 +315,19 @@ export function extractCodeRefs(content: string): CodeRef[] { * here; caller dedups). Slugs appearing inside fenced or inline code blocks * are excluded — those are typically code samples, not real entity references. */ -export function extractEntityRefs(content: string): EntityRef[] { +export function extractEntityRefs(content: string, extraDirs?: string[]): EntityRef[] { const stripped = stripCodeBlocks(content); const refs: EntityRef[] = []; let match: RegExpExecArray | null; + // #3190: pack-declared entity dirs widen the whitelist for this call. + const dirPattern = dirPatternWith(extraDirs); // 1. Markdown links: [Name](path) // Markdown links have no source-qualification syntax — they're // always unqualified. Omit sourceId so the shape stays compatible // with pre-v0.17 consumers doing strict equality. const markdownRanges: Array<[number, number]> = []; - const mdPattern = new RegExp(ENTITY_REF_RE.source, ENTITY_REF_RE.flags); + const mdPattern = entityRefRe(dirPattern); while ((match = mdPattern.exec(stripped)) !== null) { const name = match[1]; const fullPath = match[2]; @@ -322,7 +341,7 @@ export function extractEntityRefs(content: string): EntityRef[] { // Must run BEFORE the unqualified pass or we'd double-emit. We also // mask out the matched spans so pass 2b can't grab them. const qualifiedRanges: Array<[number, number]> = []; - const qualPattern = new RegExp(QUALIFIED_WIKILINK_RE.source, QUALIFIED_WIKILINK_RE.flags); + const qualPattern = qualifiedWikilinkRe(dirPattern); while ((match = qualPattern.exec(stripped)) !== null) { const sourceId = match[1]; let slug = match[2].trim(); @@ -339,7 +358,7 @@ export function extractEntityRefs(content: string): EntityRef[] { // Same shape rule: omit sourceId when unqualified. const unqualifiedRanges: Array<[number, number]> = []; const unmasked = maskRanges(stripped, qualifiedRanges); - const wikiPattern = new RegExp(WIKILINK_RE.source, WIKILINK_RE.flags); + const wikiPattern = wikilinkRe(dirPattern); while ((match = wikiPattern.exec(unmasked)) !== null) { let slug = match[1].trim(); if (!slug) continue; @@ -468,12 +487,35 @@ export async function extractPageLinks( frontmatter: Record, pageType: PageType, resolver: SlugResolver, - opts: { globalBasename?: boolean; skipFrontmatter?: boolean } = {}, + opts: { globalBasename?: boolean; skipFrontmatter?: boolean; pack?: PackLinkExtraction | null } = {}, ): Promise { const candidates: LinkCandidate[] = []; + // #3190: pack-aware extraction. When the caller threads the active + // (non-base) pack, three things widen: + // - entity-dir whitelist unions in pack path_prefixes, + // - link-verb inference consults pack link_types BEFORE the legacy + // matchers (the documented wrap in schema-pack/link-inference.ts), + // - frontmatter_links mappings extend FRONTMATTER_LINK_MAP. + // No pack (or a codegen'd base pack) → behavior is byte-identical to + // the legacy path. + const pack = opts.pack ?? null; + const extraDirs = pack?.entity_dirs; + // One ReDoS budget per page, shared across every per-candidate inference + // call (mirrors extract-ner's per-page budget). + const regexBudget = pack ? new PageRegexBudget() : null; + const inferType = (context: string, targetSlug: string): string => { + if (pack) { + try { + const verb = inferLinkTypeFromPack(pack, pageType as string, context, regexBudget ?? undefined); + if (verb) return verb; + } catch { /* pack inference is best-effort; fall through to legacy */ } + } + return inferLinkType(pageType, context, content, targetSlug); + }; + // 1. Markdown entity refs. - for (const ref of extractEntityRefs(content)) { + for (const ref of extractEntityRefs(content, extraDirs)) { // Issue #972: refs from the generic `[[bare-name]]` pass carry the // literal wikilink text, not a real page slug. When global_basename // mode is on AND the resolver implements basename lookup, resolve @@ -529,7 +571,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: inferType(context, ref.slug), context, linkSource: 'markdown', }); @@ -540,7 +582,7 @@ export async function extractPageLinks( // Code blocks are stripped first — slugs in code samples are not real refs. const strippedContent = stripCodeBlocks(content); const bareRe = new RegExp( - `\\b(${DIR_PATTERN}\\/[a-z0-9][a-z0-9/-]*[a-z0-9])\\b`, + `\\b(${dirPatternWith(extraDirs)}\\/[a-z0-9][a-z0-9/-]*[a-z0-9])\\b`, 'g', ); let m: RegExpExecArray | null; @@ -551,7 +593,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: inferType(context, m[1]), context, linkSource: 'markdown', }); @@ -567,7 +609,7 @@ export async function extractPageLinks( // path needed `resolveBasenameMatches` on the real resolver. let fmUnresolved: UnresolvedFrontmatterRef[] = []; if (!opts.skipFrontmatter) { - const fm = await extractFrontmatterLinks(slug, pageType, frontmatter, resolver); + const fm = await extractFrontmatterLinks(slug, pageType, frontmatter, resolver, pack); candidates.push(...fm.candidates); fmUnresolved = fm.unresolved; } @@ -1047,11 +1089,34 @@ export async function extractFrontmatterLinks( pageType: PageType, frontmatter: Record, resolver: SlugResolver, + pack?: PackLinkExtraction | null, ): Promise { const candidates: LinkCandidate[] = []; const unresolved: UnresolvedFrontmatterRef[] = []; - for (const mapping of FRONTMATTER_LINK_MAP) { + // #3190: pack-declared frontmatter_links extend the hardcoded map. + // The hardcoded map WINS on any (pageType, field) it already covers — + // it carries direction + dir hints the pack schema doesn't — so a pack + // restating a built-in field can't emit reversed/duplicate edges. + // Pack mappings are outgoing (page → resolved value); the pack's + // entity dirs serve as resolution hints so bare-name values resolve + // into pack-declared layouts (e.g. `wiki/people/`). + const mappings: FrontmatterFieldMapping[] = [...FRONTMATTER_LINK_MAP]; + for (const fl of pack?.frontmatter_links ?? []) { + const freeFields = fl.fields.filter(f => + !FRONTMATTER_LINK_MAP.some(m => + m.fields.includes(f) && (m.pageType === undefined || m.pageType === fl.page_type))); + if (freeFields.length === 0) continue; + mappings.push({ + fields: freeFields, + pageType: fl.page_type, + type: fl.link_type, + direction: 'outgoing', + dirHint: pack?.entity_dirs ?? '', + }); + } + + for (const mapping of mappings) { if (mapping.pageType && mapping.pageType !== pageType) continue; for (const field of mapping.fields) { const value = frontmatter[field]; diff --git a/src/core/operations.ts b/src/core/operations.ts index 7e20012c2..92a0fd0ce 100644 --- a/src/core/operations.ts +++ b/src/core/operations.ts @@ -1161,9 +1161,18 @@ async function runAutoLink( const resolver = makeResolver(engine, { mode: 'live', sourceId: opts?.sourceId }); // Issue #972: opt-in bare-wikilink basename resolution. Off by default. const globalBasename = await isGlobalBasenameEnabled(engine); + // #3190: consult the active custom pack (path_prefixes / link verbs / + // frontmatter_links) so put_page auto-link matches what `gbrain extract + // links --source db` produces. Best-effort: null for base packs or on + // any load failure. + const { loadActivePackBestEffort } = await import('./schema-pack/best-effort.ts'); + const { packLinkExtractionView } = await import('./schema-pack/link-inference.ts'); + const pack = packLinkExtractionView( + await loadActivePackBestEffort({ engine, sourceId: opts?.sourceId } as never), + ); const { candidates, unresolved } = await extractPageLinks( slug, fullContent, parsed.frontmatter, parsed.type, resolver, - { globalBasename }, + { globalBasename, pack }, ); // Resolve which targets exist (skip refs to non-existent pages to avoid FK diff --git a/src/core/schema-pack/link-inference.ts b/src/core/schema-pack/link-inference.ts index 2682a612d..d9db37d01 100644 --- a/src/core/schema-pack/link-inference.ts +++ b/src/core/schema-pack/link-inference.ts @@ -116,3 +116,58 @@ export function frontmatterLinkTypeFromPack( } return null; } + +// #3190 — pack-aware plain link extraction. +// +// The codegen'd base packs are generated FROM the in-code tables +// (inferLinkType's production regexes + FRONTMATTER_LINK_MAP + the +// DIR_PATTERN dirs — see scripts/generate-gbrain-base.ts). Consulting them +// at extraction time would double-apply WORSE copies of the same semantics: +// the YAML carries simplified sketch regexes, and its frontmatter_links +// entries have no direction field (the hardcoded map's `incoming` entries +// like key_people would come back reversed). Custom packs are the ones +// extraction must honor. +const CODEGEN_BASE_PACKS = new Set(['gbrain-base', 'gbrain-base-v2']); + +/** + * The slice of a resolved pack that plain link extraction + * (`extractPageLinks` / `extractFrontmatterLinks`) consumes. + */ +export interface PackLinkExtraction { + link_types: SchemaPackManifest['link_types']; + frontmatter_links: SchemaPackManifest['frontmatter_links']; + /** + * Entity-dir alternatives derived from `page_types[].path_prefixes` + * (leading/trailing slashes stripped). Unioned into DIR_PATTERN so + * markdown/wikilink targets under pack-declared layouts (e.g. + * `wiki/people/…`) become extraction candidates. + */ + entity_dirs: string[]; +} + +/** + * Project a resolved active pack down to what link extraction needs. + * Returns null (extraction stays purely legacy) when there is no pack or + * the pack is one of the codegen'd base packs. + */ +export function packLinkExtractionView( + pack: { manifest: SchemaPackManifest } | null | undefined, +): PackLinkExtraction | null { + const m = pack?.manifest; + if (!m || CODEGEN_BASE_PACKS.has(m.name)) return null; + const dirs = new Set(); + for (const pt of m.page_types) { + for (const prefix of pt.path_prefixes ?? []) { + const d = prefix.replace(/^\/+/, '').replace(/\/+$/, ''); + // Only slug-shaped prefixes participate in regex union; anything + // else (globs, regex metachars) is skipped rather than escaped so + // the entity regexes stay predictable. + if (d && /^[A-Za-z0-9][A-Za-z0-9/_-]*$/.test(d)) dirs.add(d); + } + } + return { + link_types: m.link_types, + frontmatter_links: m.frontmatter_links, + entity_dirs: [...dirs], + }; +} diff --git a/test/link-extraction-pack.test.ts b/test/link-extraction-pack.test.ts new file mode 100644 index 000000000..7a1dd71f9 --- /dev/null +++ b/test/link-extraction-pack.test.ts @@ -0,0 +1,186 @@ +/** + * #3190 — pack-aware plain link extraction. + * + * A custom schema pack's `page_types[].path_prefixes`, `link_types[].inference` + * and `frontmatter_links` must drive `extractPageLinks` / + * `extractFrontmatterLinks` (the plain fs/db extract path + put_page + * auto-link), not just the `--ner` path. Three gates pinned here: + * 1. entity-dir whitelist unions in pack path_prefixes, + * 2. pack verb regexes are consulted before legacy inferLinkType, + * 3. pack frontmatter_links extend FRONTMATTER_LINK_MAP (hardcoded map wins + * on collision so base-pack-shaped entries can't emit reversed edges). + * Plus the guard: codegen'd base packs are ignored (their semantics already + * live in the in-code tables). + */ + +import { describe, test, expect } from 'bun:test'; +import { + extractEntityRefs, + extractPageLinks, + extractFrontmatterLinks, + type SlugResolver, +} from '../src/core/link-extraction.ts'; +import { + packLinkExtractionView, + type PackLinkExtraction, +} from '../src/core/schema-pack/link-inference.ts'; +import type { PageType } from '../src/core/types.ts'; + +const nullResolver: SlugResolver = { resolve: async () => null }; + +/** Minimal custom-pack view matching the issue's genealogy repro. */ +const packView: PackLinkExtraction = { + link_types: [ + { name: 'parent_of', inference: { regex: '[Pp]arent of \\[' } }, + { name: 'member_of_line', inference: { regex: 'member of the \\[' } }, + ], + frontmatter_links: [ + { page_type: 'person', fields: ['parents'], link_type: 'parent_of' }, + // Collides with the hardcoded map (company/key_people is incoming + // works_at) — must be skipped, not applied outgoing. + { page_type: 'company', fields: ['key_people'], link_type: 'employs' }, + ], + entity_dirs: ['wiki/people', 'lines'], +}; + +describe('#3190 gate 1 — pack path_prefixes widen the entity-dir whitelist', () => { + const content = 'member of the [Pettit](../lines/pettit.md) line and ' + + 'parent of [Harriet](wiki/people/pettit-harriet-emeline-1828.md).'; + + test('without extra dirs, non-whitelisted targets yield zero refs', () => { + expect(extractEntityRefs(content)).toEqual([]); + }); + + test('with pack entity dirs, both targets become candidates', () => { + const refs = extractEntityRefs(content, packView.entity_dirs); + expect(refs.map(r => r.slug).sort()).toEqual([ + 'lines/pettit', + 'wiki/people/pettit-harriet-emeline-1828', + ]); + }); + + test('wikilinks honor pack dirs too', () => { + const refs = extractEntityRefs('[[wiki/people/pettit-james-b-1777|James]]', packView.entity_dirs); + expect(refs).toHaveLength(1); + expect(refs[0].slug).toBe('wiki/people/pettit-james-b-1777'); + }); +}); + +describe('#3190 gate 2 — pack verb inference runs on the plain extract path', () => { + test('pack regex verb wins over legacy mentions', async () => { + const { candidates } = await extractPageLinks( + 'wiki/people/pettit-james-b-1777', + 'parent of [Harriet](wiki/people/pettit-harriet-emeline-1828.md)', + {}, + 'person' as PageType, + nullResolver, + { pack: packView }, + ); + expect(candidates).toHaveLength(1); + expect(candidates[0].targetSlug).toBe('wiki/people/pettit-harriet-emeline-1828'); + expect(candidates[0].linkType).toBe('parent_of'); + }); + + test('no pack verb match falls through to legacy inference', async () => { + const { candidates } = await extractPageLinks( + 'wiki/people/a', + 'see also [Someone](wiki/people/someone)', + {}, + 'person' as PageType, + nullResolver, + { pack: packView }, + ); + expect(candidates).toHaveLength(1); + expect(candidates[0].linkType).toBe('mentions'); + }); + + test('no pack → legacy behavior unchanged (candidate never exists)', async () => { + const { candidates } = await extractPageLinks( + 'wiki/people/pettit-james-b-1777', + 'parent of [Harriet](wiki/people/pettit-harriet-emeline-1828.md)', + {}, + 'person' as PageType, + nullResolver, + {}, + ); + expect(candidates).toEqual([]); + }); +}); + +describe('#3190 gate 3 — pack frontmatter_links extend FRONTMATTER_LINK_MAP', () => { + const resolver: SlugResolver = { + async resolve(name: string, dirHint?: string | string[]) { + const hints = Array.isArray(dirHint) ? dirHint : (dirHint ? [dirHint] : []); + // Simulate step-2 resolution: dir-hint + already-slugified value. + if (hints.includes('wiki/people')) return `wiki/people/${name}`; + return null; + }, + }; + + test('pack-only field produces an outgoing typed edge', async () => { + const { candidates, unresolved } = await extractFrontmatterLinks( + 'wiki/people/pettit-harriet-emeline-1828', + 'person' as PageType, + { parents: ['pettit-james-b-1777', 'felt-lucy-w-1777'] }, + resolver, + packView, + ); + expect(unresolved).toEqual([]); + expect(candidates).toHaveLength(2); + for (const c of candidates) { + expect(c.linkType).toBe('parent_of'); + expect(c.fromSlug).toBe('wiki/people/pettit-harriet-emeline-1828'); + expect(c.linkSource).toBe('frontmatter'); + } + expect(candidates.map(c => c.targetSlug).sort()).toEqual([ + 'wiki/people/felt-lucy-w-1777', + 'wiki/people/pettit-james-b-1777', + ]); + }); + + test('hardcoded map wins on colliding (pageType, field) — no reversed duplicate', async () => { + const trackingResolver: SlugResolver = { + async resolve(name: string) { return `people/${name}`; }, + }; + const { candidates } = await extractFrontmatterLinks( + 'companies/acme', + 'company' as PageType, + { key_people: ['alice-example'] }, + trackingResolver, + packView, + ); + // Exactly one edge, from the hardcoded incoming works_at mapping — + // the pack's outgoing 'employs' restatement is skipped. + expect(candidates).toHaveLength(1); + expect(candidates[0].linkType).toBe('works_at'); + expect(candidates[0].fromSlug).toBe('people/alice-example'); + expect(candidates[0].targetSlug).toBe('companies/acme'); + }); +}); + +describe('#3190 — packLinkExtractionView', () => { + const manifest = (over: Record) => ({ + manifest: { + name: 'family-pack', + page_types: [ + { name: 'person', path_prefixes: ['wiki/people/', '/lines/'] }, + { name: 'weird', path_prefixes: ['../evil/', 'ok_dir/'] }, + ], + link_types: [], + frontmatter_links: [], + ...over, + } as never, + }); + + test('derives slug-safe entity dirs from path_prefixes', () => { + const view = packLinkExtractionView(manifest({})); + expect(view).not.toBeNull(); + expect(view!.entity_dirs.sort()).toEqual(['lines', 'ok_dir', 'wiki/people']); + }); + + test('returns null for codegen base packs and missing packs', () => { + expect(packLinkExtractionView(null)).toBeNull(); + expect(packLinkExtractionView(manifest({ name: 'gbrain-base' }))).toBeNull(); + expect(packLinkExtractionView(manifest({ name: 'gbrain-base-v2' }))).toBeNull(); + }); +});