diff --git a/src/core/link-extraction.ts b/src/core/link-extraction.ts index 7f0a552f5..83c273693 100644 --- a/src/core/link-extraction.ts +++ b/src/core/link-extraction.ts @@ -570,7 +570,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, opts.globalBasename); candidates.push(...fm.candidates); fmUnresolved = fm.unresolved; } @@ -1078,6 +1078,7 @@ export async function extractFrontmatterLinks( pageType: PageType, frontmatter: Record, resolver: SlugResolver, + globalBasename = false, ): Promise { const candidates: LinkCandidate[] = []; const unresolved: UnresolvedFrontmatterRef[] = []; @@ -1115,7 +1116,22 @@ export async function extractFrontmatterLinks( // through unchanged; the original `name` is preserved for the // unresolved report and edge context. const linkTarget = unwrapWikilink(name); - const resolved = await resolver.resolve(linkTarget, mapping.dirHint); + let resolved = await resolver.resolve(linkTarget, mapping.dirHint); + if (!resolved && globalBasename && typeof resolver.resolveBasenameMatches === 'function') { + // Issue #972 follow-up: extend global_basename resolution to + // frontmatter link fields. resolve() can't reach a bare-title + // wikilink value (e.g. `sources: "[[2025-12-25_mentor-extraction]]"`) + // — it has no '/', so the slug-direct getPage is skipped, and the + // field's dirHint may name folders that don't exist in this brain, + // so the dir-scoped exact + fuzzy steps miss too. When + // link_resolution.global_basename is on, fall back to the SAME + // basename index the body bare-wikilink pass uses. Unique-match-only: + // ambiguous basenames (e.g. archive duplicates, generic hubs like + // `_index`) stay unresolved rather than create a wrong edge. + const matches = (await resolver.resolveBasenameMatches(linkTarget)) + .filter((s) => s !== slug); + if (matches.length === 1) resolved = matches[0]; + } if (!resolved) { unresolved.push({ field, name }); continue; diff --git a/test/link-extraction.test.ts b/test/link-extraction.test.ts index 7782ea6aa..840a63dcb 100644 --- a/test/link-extraction.test.ts +++ b/test/link-extraction.test.ts @@ -282,6 +282,53 @@ describe('extractPageLinks', () => { expect(sourceLink!.targetSlug).toBe('meetings/2026-01-15'); }); + // ─── global_basename for frontmatter link fields (issue #972 follow-up) ─── + + test('frontmatter [[wikilink]] resolves via global_basename when resolve() misses', async () => { + // `sources: [[2025-12-25_mentor-extraction]]` — bare title, no '/', so the + // standard resolver misses; the basename index finds the single match. + const resolver: SlugResolver = { + resolve: async () => null, + resolveBasenameMatches: async (name) => + name === '2025-12-25_mentor-extraction' + ? ['trading/raw/2025-12-25_mentor-extraction'] + : [], + }; + const { candidates } = await extractPageLinks( + 'trading/wiki/backtesting', 'Body.', + { sources: ['[[2025-12-25_mentor-extraction]]'] }, + 'concept', resolver, { globalBasename: true }, + ); + // `sources` is direction:'incoming' → edge is resolved → page. + const edge = candidates.find(c => c.linkType === 'discussed_in'); + expect(edge).toBeDefined(); + expect(edge!.fromSlug).toBe('trading/raw/2025-12-25_mentor-extraction'); + expect(edge!.targetSlug).toBe('trading/wiki/backtesting'); + }); + + test('frontmatter basename fallback stays unresolved when ambiguous (>1 match)', async () => { + const resolver: SlugResolver = { + resolve: async () => null, + resolveBasenameMatches: async () => ['a/dup', 'b/dup'], + }; + const { candidates, unresolved } = await extractPageLinks( + 'wiki/x', 'Body.', { sources: ['[[dup]]'] }, 'concept', resolver, { globalBasename: true }, + ); + expect(candidates.find(c => c.linkType === 'discussed_in')).toBeUndefined(); + expect(unresolved.some(u => u.field === 'sources')).toBe(true); + }); + + test('frontmatter basename fallback is gated OFF when globalBasename is false', async () => { + const resolver: SlugResolver = { + resolve: async () => null, + resolveBasenameMatches: async () => ['raw/note'], + }; + const { candidates } = await extractPageLinks( + 'wiki/x', 'Body.', { sources: ['[[note]]'] }, 'concept', resolver, // globalBasename omitted = false + ); + expect(candidates.find(c => c.linkType === 'discussed_in')).toBeUndefined(); + }); + test('extracts bare slug references in text', async () => { const { candidates } = await extractPageLinks( 'docs/x', 'See companies/acme for details.', {}, 'concept', nullResolver,