From 503f61e6e4f4524a1da058f0d6e6882088effd49 Mon Sep 17 00:00:00 2001 From: spiky02plateau <155588579+spiky02plateau@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:03:48 +0200 Subject: [PATCH] feat(links): resolve [[wikilink]] frontmatter values via global_basename (#2406) When link_resolution.global_basename is enabled, extend basename-index resolution to frontmatter link fields (FRONTMATTER_LINK_MAP), mirroring the body bare-wikilink path added in #972. Problem: a bare-title wikilink in a frontmatter list -- e.g. sources: - "[[2025-12-25_mentor-extraction]]" never resolves. SlugResolver.resolve() has no '/' to hit the slug-direct getPage, and the field's dirHint (sources -> ['source','media']) may name folders absent from the brain, so the dir-scoped exact + fuzzy steps also miss. The frontmatter path never consulted resolveBasenameMatches -- that was wired only for body bare-wikilinks. On a PARA/Obsidian vault this silently drops the bulk of sources:/related: provenance edges. Fix: extractFrontmatterLinks takes a globalBasename flag (threaded from extractPageLinks). On a resolve() miss, unwrap [[ ]] and fall back to resolver.resolveBasenameMatches -- UNIQUE-MATCH-ONLY, so ambiguous basenames (archive dupes, generic hubs like _index) stay unresolved rather than create a wrong edge. Purely additive; resolved frontmatter edges are unchanged. Scope: covers the db-source extract and live put_page paths (real makeResolver). The --source fs extract uses an inline resolver without a basename index, so it gracefully no-ops there (typeof guard). Tested: 3 new cases (resolves-when-on, ambiguous-stays-unresolved, gated-off-by-flag); full link-extraction suite green (130 pass). Co-authored-by: Claude Opus 4.8 (1M context) --- src/core/link-extraction.ts | 33 +++++++++++++++++++++++-- test/link-extraction.test.ts | 47 ++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/src/core/link-extraction.ts b/src/core/link-extraction.ts index 6ff2f6822..a23f2221e 100644 --- a/src/core/link-extraction.ts +++ b/src/core/link-extraction.ts @@ -567,7 +567,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; } @@ -1042,11 +1042,24 @@ export interface FrontmatterExtractResult { * Arrays of objects: uses the `name` or `slug` property (codex tension 6.3). * Non-string / non-object entries: silently skipped (log-only). */ +/** + * Unwrap an Obsidian-style `[[wikilink]]` frontmatter value to its bare link + * target: drops the surrounding brackets, any `|alias`, and `#heading` / + * `^block` suffixes. Non-bracketed values pass through unchanged. Used to feed + * the basename index when global_basename is on (see extractFrontmatterLinks). + */ +function unwrapWikilink(value: string): string { + const match = /^\s*\[\[(.+?)\]\]\s*$/.exec(value); + if (!match) return value; + return match[1].split('|')[0].split('#')[0].split('^')[0].trim(); +} + export async function extractFrontmatterLinks( slug: string, pageType: PageType, frontmatter: Record, resolver: SlugResolver, + globalBasename = false, ): Promise { const candidates: LinkCandidate[] = []; const unresolved: UnresolvedFrontmatterRef[] = []; @@ -1079,7 +1092,23 @@ export async function extractFrontmatterLinks( } if (!name) continue; // skip numbers, nulls, malformed objects - const resolved = await resolver.resolve(name, mapping.dirHint); + let resolved = await resolver.resolve(name, 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, after unwrapping + // the brackets. 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(unwrapWikilink(name))) + .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 9a2bc4f7d..8f7564edd 100644 --- a/test/link-extraction.test.ts +++ b/test/link-extraction.test.ts @@ -270,6 +270,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,