feat(links): resolve [[wikilink]] frontmatter values via global_basename (#2406) (#3313)

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: spiky02plateau <155588579+spiky02plateau@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
This commit is contained in:
Time Attakc
2026-07-24 12:27:41 -07:00
committed by GitHub
co-authored by spiky02plateau Claude Opus 4.8 Garry Tan
parent b35c617252
commit f30d789c3a
2 changed files with 65 additions and 2 deletions
+18 -2
View File
@@ -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<string, unknown>,
resolver: SlugResolver,
globalBasename = false,
): Promise<FrontmatterExtractResult> {
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;
+47
View File
@@ -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,