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) <noreply@anthropic.com>
This commit is contained in:
spiky02plateau
2026-07-23 05:03:48 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 0367c800a4
commit 503f61e6e4
2 changed files with 78 additions and 2 deletions
+31 -2
View File
@@ -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<string, unknown>,
resolver: SlugResolver,
globalBasename = false,
): Promise<FrontmatterExtractResult> {
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;
+47
View File
@@ -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,