fix(backlinks): dedupe (source, target) pairs within a single source page

A source page that mentions the same entity N times produced N
duplicate "Referenced in" lines on the target. `extractEntityRefs`
returns one EntityRef per occurrence, and the per-ref `hasBacklink`
check reads a snapshot of `target.content` that's frozen at outer
scope — so every iteration sees "no backlink yet" and appends another
gap. The cumulative effect on a long meeting note with multiple
mentions of the same person was visible in PRs landing 3-5 identical
Timeline entries.

Track seen target slugs per source page; cap gaps at one pair.

Cherry-picked from PR #967 with a current-master regression test
covering both markdown-link and Obsidian-wikilink formats in the same
source page.

Co-Authored-By: p3ob7o <p3ob7o@users.noreply.github.com>
This commit is contained in:
Garry Tan
2026-05-18 13:34:46 -07:00
co-authored by p3ob7o
parent 4173f9e0dc
commit 2e4bef7477
2 changed files with 37 additions and 0 deletions
+11
View File
@@ -100,9 +100,20 @@ export function findBacklinkGaps(brainDir: string): BacklinkGap[] {
for (const page of allPages) {
const refs = extractEntityRefs(page.content, page.relPath);
const sourceFilename = basename(page.relPath);
// LOCAL PATCH (paolo, 2026-05-12): dedupe (source, target) pairs within
// a single source page. extractEntityRefs returns one EntityRef per
// occurrence, so a source page that mentions the same target N times
// produced N identical gaps → N duplicate "Referenced in" lines on the
// target. The per-ref `hasBacklink(target.content, ...)` check reads a
// stale snapshot (target.content is frozen at this scope), so every
// iteration sees the same "no backlink yet" state and pushes another
// gap. Tracking seen target slugs per source caps gaps at one per pair.
const seen = new Set<string>();
for (const ref of refs) {
const targetSlug = `${ref.dir}/${ref.slug}`;
if (seen.has(targetSlug)) continue;
seen.add(targetSlug);
const target = pagesBySlug.get(targetSlug);
if (!target) continue; // target page doesn't exist
+26
View File
@@ -78,3 +78,29 @@ describe('buildBacklinkEntry', () => {
expect(entry).toBe('- **2026-04-11** | Referenced in [Q1 Review](../../meetings/q1-review.md)');
});
});
describe('findBacklinkGaps dedupe (v0.36.x #967 regression)', () => {
test('a source page mentioning the same target N times yields one gap, not N', async () => {
const { mkdtempSync, writeFileSync, mkdirSync, rmSync } = await import('fs');
const { tmpdir } = await import('os');
const { join } = await import('path');
const { findBacklinkGaps } = await import('../src/commands/backlinks.ts');
const root = mkdtempSync(join(tmpdir(), 'gbrain-backlinks-dedupe-'));
try {
mkdirSync(join(root, 'people'));
mkdirSync(join(root, 'meetings'));
writeFileSync(join(root, 'people/alice.md'), '# Alice');
// Source page mentions alice three times, no Timeline yet on alice
writeFileSync(
join(root, 'meetings/standup.md'),
'# Standup\n\nWe discussed [Alice](people/alice).\nLater [Alice](people/alice) chimed in.\nFinally [[people/alice]] left.\n',
);
const gaps = findBacklinkGaps(root);
const alicePairs = gaps.filter(g => g.targetPage === 'people/alice.md' && g.sourcePage === 'meetings/standup.md');
expect(alicePairs.length).toBe(1);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});