mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 19:56:25 +00:00
* v0.40.8.2 fix(extract): opt-in global-basename wikilink resolution (#972) Bare wikilinks like [[struktura]] that point at pages in another folder were silently dropped from the graph. The issue reporter saw 71 wikilinks in Obsidian render to 12 in gbrain (~83% lost). Symptoms downstream: `gbrain graph` returns thin neighborhoods, `gbrain backlinks` undercounts. This release adds an opt-in mode that resolves bare wikilinks by basename match, covers all three resolver surfaces (FS-source extract, DB-source extract, put_page auto-link), and emits one edge per match — no silent winner on ambiguity. `gbrain doctor` surfaces a paste-ready enable hint when ≥5 bare wikilinks would resolve under the new mode. Enable with: gbrain config set link_resolution.global_basename true gbrain extract links Default stays off. Existing brains see zero behavior change on upgrade. Closes #972. Adapts PR #1233 from @rayers (regex shape + slug-tail index) into a multi-match, opt-in form with FS-source coverage that the original PR explicitly skipped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: document opt-in global-basename wikilink resolution (#972) The #972 feature shipped with no user-facing docs — only CHANGELOG + CLAUDE.md. Anyone migrating an Obsidian/Notion vault with bare [[name]] wikilinks couldn't discover the link_resolution.global_basename flag unless gbrain doctor happened to surface its hint. - README "Self-wiring knowledge graph": one sentence on the opt-in mode for Obsidian-style cross-folder bare wikilinks + the doctor pre-check, linking to the install step. - INSTALL_FOR_AGENTS Step 4.5 (Wire the Knowledge Graph): a dedicated agent- facing subsection — when bare [[name]] links need it, the enable command, re-running extract, the doctor opportunity hint, and the multi-match behavior. - Regenerated llms-full.txt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#972): resolve aliased wikilinks by target slug, not display text Codex outside-voice [P1]: `[[struktura|the project]]` resolved the basename "the project" (the alias) instead of `struktura` (the target), because extractPageLinks called resolveBasenameMatches(ref.name) and the doctor check keyed basenameIndex.get(e.name). ref.name is the display alias (match[2]); ref.slug is the wikilink target (match[1]). - extractPageLinks resolves ref.slug; context excerpt locates ref.slug. - doctor link_resolution_opportunity keys e.slug so its estimate matches what extraction actually resolves. - Test: aliased wikilink calls resolveBasenameMatches with the target, never the display text. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#972): reconcile wikilink-resolved edges in put_page auto-link Codex outside-voice [P1]: put_page's reconcilableOut filter excluded link_source='wikilink-resolved', so a basename edge written by auto-link survived after the bare wikilink was deleted from the page OR the link_resolution.global_basename flag was turned off (the stale-removal loop only iterates reconcilableOut). Add 'wikilink-resolved' to the reconcilable set; manual edges still untouched. Test: write page with [[struktura]] (flag on) → edge lands; re-put without the wikilink → edge reconciled away. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#972): source-scope basename resolution (no cross-source edges) Codex outside-voice [P1]: makeResolver.resolveBasenameMatches called engine.getAllSlugs() unscoped, so a bare [[name]] could resolve to a same-tail page in a DIFFERENT source and create a cross-source edge. The engine exposes getAllSlugs({sourceId}) precisely to prevent this. #972 is "global basename across folders," not "cross-source federation" — the canonical gbrain multi-source bug class. - makeResolver gains opts.sourceId; ensureBasenameIndex passes it to getAllSlugs (unscoped only when sourceId omitted — back-compat). - runAutoLink (put_page) passes opts.sourceId; extractLinksFromDB passes sourceIdFilter. FS extract is already single-source (walks one dir). - Tests: scoped index returns only the source's slugs (no cross-source); unscoped call stays brain-wide. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#972): FS-source basename edges carry link_source='wikilink-resolved' The FS extract path is the issue's default repro (gbrain extract links with no --source db). ExtractedLink had no link_source field, so FS basename edges landed with the engine default ('markdown') instead of the 'wikilink-resolved' provenance the DB / put_page paths set and the docs promise. The e2e FS test only asserted link_type, so it was blind to this. - ExtractedLink gains link_source?; extractLinksFromFile sets it to 'wikilink-resolved' on basename edges (undefined for ordinary markdown). - Carries through the addLinksBatch snapshots automatically (LinkBatchInput already has link_source); single-row addLink fallback now passes it too. - e2e FS repro asserts link_source === 'wikilink-resolved'. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(#972): one shared basename matcher across resolver/FS/doctor Codex outside-voice [P2] DRY: three surfaces each hand-rolled a basename matcher with divergent key sets — the doctor omitted the slugified key, so its link_resolution_opportunity estimate undercounted what extraction resolves, and the resolver returned matches in unsorted getAllSlugs bucket order. New shared exports in link-extraction.ts: buildBasenameIndex(slugs) + queryBasenameIndex(index, name) (keys raw/lower/slugified tail; stable sort shorter-first then lexical) + normalizeBasename. - makeResolver.resolveBasenameMatches → queryBasenameIndex (now stable-sorted). - extract.ts resolveBasenameMatchesFromSlugs → delegates to the shared pair. - doctor link_resolution_opportunity → shared builder/query (slugified key added; estimate now matches extraction). - Test: doctor counts a slugified-only match ([[Fast Weigh]] → companies/fast-weigh). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#972): P2 cluster — masking, code-fence, self-link, dedup decision Codex outside-voice P2 findings: - P2a markdown-label masking: a wikilink inside a markdown-link label ([see [[acme]]](companies/acme.md)) spawned a stray generic basename ref. Pass-1 can't match the nested brackets, so a new MARKDOWN_LABEL_WIKILINK_RE masks those spans out of pass 2c. Inner [[acme]] is now inert. - P2b FS code-fence: the FS path (extractMarkdownLinks on raw content) didn't strip code blocks like the DB path. extractLinksFromFile now scans stripCodeBlocks(content) so [[name]] inside a fence creates no FS edge. - P2c self-link guard: a basename [[own-tail]] on its own page resolved back to itself. Dropped in both extractPageLinks and the FS path. - P2d dedup: documented the decision to KEEP qualified + bare edges to the same target as separate rows (distinct provenance/audit trail). - P2e: skipFrontmatter unresolved-contract tests added. Tests: P2a inert-label, P2c self-link drop, P2b code-fence, P2e unresolved. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(#972): bound the doctor link_resolution_opportunity scan The check did listAllPageRefs() + a getPage() per page under a 60s budget. On a large brain (the eng-review concern) it hit the budget every non-fast doctor run and returned a perpetual partial, adding ~60s. Now batch-loads the 1000 most-recent pages in ONE query (ORDER BY id DESC LIMIT SAMPLE_LIMIT) and scans in memory, with the 60s cap kept as a backstop. Mirrors the v0.40.9 sampling convention. The estimate message names the bound when the brain exceeds the sample ("scanned the 1000 most-recent of N pages"). Test: source-grep pins the bounded query + the absence of the per-page getPage walk. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(#972): reconcile stale version/migration references to v112 / 0.42.6.0 Merge churn left intermediate refs: schema.sql + schema-embedded.ts said "migration v93", CLAUDE.md said "v0.41.32.0 / Migration v109", CHANGELOG said "Migration v93". Reconciled all to migration v112 / shipping 0.42.6.0. The CLAUDE.md annotation is also refreshed to describe the final behavior (shared matcher, source-scoping, alias-by-target, stale-edge reconciliation, bounded doctor scan) and credit @rayers + @ukd1. Regenerated schema-embedded + llms. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#972): register doctor check category + bump llms budget to 800KB Two full-suite gate failures from the re-sync: - doctor-categories drift guard: the new `link_resolution_opportunity` check wasn't in any category set. Added to BRAIN_CHECK_NAMES (alongside graph_coverage / orphan_ratio — it's a graph-quality signal). - build-llms size budget: the #972 Key Files annotation (landing with master's #1696/#1699 waves) pushed llms-full.txt past 750KB. Bumped FULL_SIZE_BUDGET 750KB→800KB, the established "budget tracks CLAUDE.md's legitimate per-feature growth" pattern (600→700→750→800 across releases). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Garry Tan <garrytan@gmail.com>
459 lines
19 KiB
TypeScript
459 lines
19 KiB
TypeScript
/**
|
|
* Tests for `gbrain extract --source fs` (the default, FS-walking path).
|
|
*
|
|
* Companion to test/extract-db.test.ts. Specifically guards against the
|
|
* v0.12.0 N+1 hang: extractLinksFromDir / extractTimelineFromDir used to
|
|
* pre-load the entire dedup set with one engine.getLinks() per page across
|
|
* engine.listPages(), which on a 47K-page brain meant 47K sequential
|
|
* round-trips before any work happened.
|
|
*
|
|
* Verifies:
|
|
* 1. Single run extracts the expected links + timeline entries.
|
|
* 2. Second run reports `created: 0` (proves DO NOTHING in batch + accurate
|
|
* counter via RETURNING).
|
|
* 3. --dry-run prints the same link found across multiple files exactly
|
|
* once (proves the dry-run-only dedup Set works).
|
|
* 4. Second run wall-clock < 2s (regression guard against any future change
|
|
* that re-introduces the N+1 read pre-load).
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { runExtract } from '../src/commands/extract.ts';
|
|
import type { PageInput } from '../src/core/types.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
let brainDir: string;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
async function truncateAll() {
|
|
for (const t of ['content_chunks', 'links', 'tags', 'raw_data', 'timeline_entries', 'page_versions', 'ingest_log', 'pages']) {
|
|
await (engine as any).db.exec(`DELETE FROM ${t}`);
|
|
}
|
|
}
|
|
|
|
const personPage = (title: string, body = ''): PageInput => ({
|
|
type: 'person', title, compiled_truth: body, timeline: '',
|
|
});
|
|
|
|
const companyPage = (title: string, body = ''): PageInput => ({
|
|
type: 'company', title, compiled_truth: body, timeline: '',
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await truncateAll();
|
|
brainDir = mkdtempSync(join(tmpdir(), 'gbrain-extract-fs-'));
|
|
}, 15_000);
|
|
|
|
function writeFile(rel: string, content: string) {
|
|
const full = join(brainDir, rel);
|
|
mkdirSync(join(full, '..'), { recursive: true });
|
|
writeFileSync(full, content);
|
|
}
|
|
|
|
describe('gbrain extract links --source fs', () => {
|
|
test('first run inserts links, second run reports 0 (idempotent + truthful counter)', async () => {
|
|
// Set up brain in DB matching the file structure
|
|
await engine.putPage('people/alice', personPage('Alice'));
|
|
await engine.putPage('people/bob', personPage('Bob'));
|
|
await engine.putPage('companies/acme', companyPage('Acme'));
|
|
|
|
// Set up matching markdown files on disk
|
|
writeFile('people/alice.md', '---\ntitle: Alice\n---\n\n[Bob](../people/bob.md) is a friend.\n');
|
|
writeFile('people/bob.md', '---\ntitle: Bob\n---\n\nWorks at [Acme](../companies/acme.md).\n');
|
|
writeFile('companies/acme.md', '---\ntitle: Acme\n---\n\nFounded by [Alice](../people/alice.md).\n');
|
|
|
|
// First run — write batch path
|
|
await runExtract(engine, ['links', '--dir', brainDir]);
|
|
const linksAfter1 = (await engine.getLinks('people/alice'))
|
|
.concat(await engine.getLinks('people/bob'))
|
|
.concat(await engine.getLinks('companies/acme'));
|
|
expect(linksAfter1.length).toBeGreaterThanOrEqual(3);
|
|
|
|
// Second run — must dedup via ON CONFLICT and report 0 new (truthful counter)
|
|
const start = Date.now();
|
|
await runExtract(engine, ['links', '--dir', brainDir]);
|
|
const elapsedMs = Date.now() - start;
|
|
|
|
const linksAfter2 = (await engine.getLinks('people/alice'))
|
|
.concat(await engine.getLinks('people/bob'))
|
|
.concat(await engine.getLinks('companies/acme'));
|
|
expect(linksAfter2.length).toBe(linksAfter1.length);
|
|
|
|
// Perf regression guard: re-run on tiny fixture must not loop through
|
|
// listPages + per-page getLinks. ~10 files should complete in well under
|
|
// 2s even on a slow CI box.
|
|
expect(elapsedMs).toBeLessThan(2000);
|
|
});
|
|
|
|
test('--dry-run dedups duplicate candidates across files (printed once, not N times)', async () => {
|
|
await engine.putPage('people/alice', personPage('Alice'));
|
|
await engine.putPage('companies/acme', companyPage('Acme'));
|
|
|
|
// Same link target appears in 3 different files. The target file must
|
|
// exist on disk so the FS extractor's allSlugs Set includes it.
|
|
writeFile('companies/acme.md', '---\ntitle: Acme\n---\n');
|
|
writeFile('a.md', '[Acme](companies/acme.md)\n');
|
|
writeFile('b.md', '[Acme](companies/acme.md)\n');
|
|
writeFile('c.md', '[Acme](companies/acme.md)\n');
|
|
|
|
// Capture stdout to check print frequency
|
|
const lines: string[] = [];
|
|
const origLog = console.log;
|
|
console.log = (...args: unknown[]) => { lines.push(args.join(' ')); };
|
|
try {
|
|
await runExtract(engine, ['links', '--dry-run', '--dir', brainDir]);
|
|
} finally {
|
|
console.log = origLog;
|
|
}
|
|
|
|
// Each (from, to, link_type) tuple should print at most once.
|
|
// Three distinct from_slugs (a, b, c) all link to companies/acme, so
|
|
// we expect 3 link lines (one per source file), not 9.
|
|
const linkLines = lines.filter(l => l.includes('→') && l.includes('companies/acme'));
|
|
expect(linkLines.length).toBe(3);
|
|
|
|
// No actual writes happened
|
|
const links = await engine.getLinks('companies/acme');
|
|
expect(links.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('gbrain extract timeline --source fs', () => {
|
|
test('first run inserts entries, second run reports 0 (idempotent + truthful counter)', async () => {
|
|
await engine.putPage('people/alice', personPage('Alice'));
|
|
|
|
writeFile('people/alice.md', `---
|
|
title: Alice
|
|
---
|
|
|
|
## Timeline
|
|
|
|
- **2024-01-15** | source — Founded NovaMind
|
|
- **2024-06-01** | source — Raised seed round
|
|
`);
|
|
|
|
await runExtract(engine, ['timeline', '--dir', brainDir]);
|
|
const after1 = await engine.getTimeline('people/alice');
|
|
expect(after1.length).toBe(2);
|
|
|
|
const start = Date.now();
|
|
await runExtract(engine, ['timeline', '--dir', brainDir]);
|
|
const elapsedMs = Date.now() - start;
|
|
|
|
const after2 = await engine.getTimeline('people/alice');
|
|
expect(after2.length).toBe(2);
|
|
|
|
expect(elapsedMs).toBeLessThan(2000);
|
|
});
|
|
});
|
|
|
|
describe('gbrain extract --dir default resolution', () => {
|
|
// Pin the cwd-footgun fix: when --dir is not passed, extract resolves the
|
|
// brain dir from the sources(local_path) row before falling back. The bare
|
|
// `.` default would let a user running from a directory with a node_modules/
|
|
// tree walk tens of thousands of unrelated .md files and report
|
|
// "created 0 links from 28K pages" — looks like a no-op, was actually a
|
|
// wasteful junk walk that wrote nothing because synthetic from_slugs don't
|
|
// match the pages table.
|
|
test('uses configured sources(local_path) when --dir is not passed', async () => {
|
|
await engine.putPage('people/alice', personPage('Alice'));
|
|
await engine.putPage('people/bob', personPage('Bob'));
|
|
writeFile('people/alice.md', '---\ntitle: Alice\n---\n\n[Bob](../people/bob.md) is a friend.\n');
|
|
writeFile('people/bob.md', '---\ntitle: Bob\n---\n');
|
|
|
|
// Register brainDir as the default source's local_path.
|
|
await (engine as any).db.exec(
|
|
`UPDATE sources SET local_path = '${brainDir.replace(/'/g, "''")}' WHERE id = 'default'`,
|
|
);
|
|
|
|
// Save + clobber cwd to a sibling tmpdir so the test fails loudly if the
|
|
// resolver still walks `.` instead of the configured path.
|
|
const otherDir = mkdtempSync(join(tmpdir(), 'gbrain-extract-other-'));
|
|
const savedCwd = process.cwd();
|
|
try {
|
|
process.chdir(otherDir);
|
|
await runExtract(engine, ['links']); // no --dir
|
|
} finally {
|
|
process.chdir(savedCwd);
|
|
try { rmSync(otherDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
}
|
|
|
|
const links = await engine.getLinks('people/alice');
|
|
expect(links.length).toBe(1);
|
|
expect(links[0]).toMatchObject({ to_slug: 'people/bob' });
|
|
});
|
|
|
|
test('errors with actionable message when no --dir and no source configured', async () => {
|
|
// Clear the default source's local_path so getDefaultSourcePath returns null.
|
|
await (engine as any).db.exec(`UPDATE sources SET local_path = NULL WHERE id = 'default'`);
|
|
await (engine as any).db.exec(`DELETE FROM config WHERE key = 'sync.repo_path'`);
|
|
|
|
let exitCode: number | null = null;
|
|
const errBuf: string[] = [];
|
|
const savedExit = process.exit;
|
|
const savedConsoleError = console.error;
|
|
try {
|
|
(process as any).exit = (code: number) => { exitCode = code; throw new Error('__test_exit__'); };
|
|
console.error = (...parts: unknown[]) => { errBuf.push(parts.join(' ')); };
|
|
try {
|
|
await runExtract(engine, ['links']);
|
|
} catch (e) {
|
|
if (!(e instanceof Error && e.message === '__test_exit__')) throw e;
|
|
}
|
|
} finally {
|
|
(process as any).exit = savedExit;
|
|
console.error = savedConsoleError;
|
|
}
|
|
expect(exitCode as unknown).toBe(1);
|
|
const all = errBuf.join('\n');
|
|
expect(all).toContain('No brain directory configured');
|
|
expect(all).toContain('--source db');
|
|
expect(all).toContain('--dir');
|
|
});
|
|
|
|
test('explicit --dir always wins over configured source', async () => {
|
|
await engine.putPage('people/alice', personPage('Alice'));
|
|
await engine.putPage('people/bob', personPage('Bob'));
|
|
writeFile('people/alice.md', '---\ntitle: Alice\n---\n\n[Bob](../people/bob.md) is a friend.\n');
|
|
writeFile('people/bob.md', '---\ntitle: Bob\n---\n');
|
|
|
|
// Configured path points elsewhere; explicit --dir must override.
|
|
const decoyDir = mkdtempSync(join(tmpdir(), 'gbrain-extract-decoy-'));
|
|
await (engine as any).db.exec(
|
|
`UPDATE sources SET local_path = '${decoyDir.replace(/'/g, "''")}' WHERE id = 'default'`,
|
|
);
|
|
|
|
try {
|
|
await runExtract(engine, ['links', '--dir', brainDir]);
|
|
const links = await engine.getLinks('people/alice');
|
|
expect(links.length).toBe(1);
|
|
expect(links[0]).toMatchObject({ to_slug: 'people/bob' });
|
|
} finally {
|
|
try { rmSync(decoyDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── issue #972: pure-function tests for resolveSlugAll + helpers ────────
|
|
|
|
import {
|
|
resolveSlug,
|
|
resolveSlugAll,
|
|
resolveBasenameMatchesFromSlugs,
|
|
extractLinksFromFile,
|
|
} from '../src/commands/extract.ts';
|
|
|
|
describe('extractLinksFromFile — code-fence stripping (codex P2b)', () => {
|
|
test('a bare wikilink inside a code fence does NOT create an FS edge', async () => {
|
|
const allSlugs = new Set(['projects/struktura', 'concepts/x']);
|
|
const content = [
|
|
'---', 'title: X', 'type: concept', '---', '',
|
|
'Real ref: [[struktura]].',
|
|
'',
|
|
'```', 'code mentions [[struktura]] but must be ignored', '```',
|
|
].join('\n');
|
|
const links = await extractLinksFromFile(content, 'concepts/x.md', allSlugs, { globalBasename: true });
|
|
const toStruktura = links.filter(l => l.to_slug === 'projects/struktura');
|
|
// Exactly one edge — from the prose ref, NOT the fenced one.
|
|
expect(toStruktura.length).toBe(1);
|
|
expect(toStruktura[0].link_source).toBe('wikilink-resolved');
|
|
});
|
|
});
|
|
|
|
describe('resolveBasenameMatchesFromSlugs (pure)', () => {
|
|
test('returns ALL slugs whose tail matches', () => {
|
|
const all = new Set([
|
|
'projects/struktura',
|
|
'archive/struktura',
|
|
'notes/other',
|
|
]);
|
|
const matches = resolveBasenameMatchesFromSlugs('struktura', all);
|
|
expect(matches.sort()).toEqual(['archive/struktura', 'projects/struktura']);
|
|
});
|
|
|
|
test('case-insensitive tail match', () => {
|
|
const all = new Set(['companies/fast-weigh']);
|
|
expect(resolveBasenameMatchesFromSlugs('Fast-Weigh', all))
|
|
.toEqual(['companies/fast-weigh']);
|
|
expect(resolveBasenameMatchesFromSlugs('FAST-WEIGH', all))
|
|
.toEqual(['companies/fast-weigh']);
|
|
});
|
|
|
|
test('slugified fallback (spaces → hyphens)', () => {
|
|
const all = new Set(['companies/fast-weigh']);
|
|
expect(resolveBasenameMatchesFromSlugs('Fast Weigh', all))
|
|
.toEqual(['companies/fast-weigh']);
|
|
});
|
|
|
|
test('top-level slugs (no `/`) match by themselves', () => {
|
|
const all = new Set(['struktura', 'notes/struktura']);
|
|
const matches = resolveBasenameMatchesFromSlugs('struktura', all);
|
|
expect(matches.sort()).toEqual(['notes/struktura', 'struktura']);
|
|
});
|
|
|
|
test('no match returns []', () => {
|
|
const all = new Set(['projects/struktura']);
|
|
expect(resolveBasenameMatchesFromSlugs('never-existed', all)).toEqual([]);
|
|
});
|
|
|
|
test('empty/whitespace input returns []', () => {
|
|
const all = new Set(['projects/struktura']);
|
|
expect(resolveBasenameMatchesFromSlugs('', all)).toEqual([]);
|
|
expect(resolveBasenameMatchesFromSlugs(' ', all)).toEqual([]);
|
|
});
|
|
|
|
test('stable sort: shorter slug first, then lexical', () => {
|
|
const all = new Set([
|
|
'zzz/struktura',
|
|
'projects/struktura',
|
|
'archive/struktura',
|
|
'a/struktura',
|
|
]);
|
|
const matches = resolveBasenameMatchesFromSlugs('struktura', all);
|
|
// Lengths: a/struktura(11), zzz/struktura(13), archive/struktura(17), projects/struktura(18)
|
|
expect(matches).toEqual([
|
|
'a/struktura',
|
|
'zzz/struktura',
|
|
'archive/struktura',
|
|
'projects/struktura',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('resolveSlugAll', () => {
|
|
test('ancestor walk wins → single-element array, no basename fallback', () => {
|
|
// resolveSlug already finds notes/struktura via ancestor walk.
|
|
// resolveSlugAll must return only that one even though basename
|
|
// would find others.
|
|
const all = new Set([
|
|
'notes/struktura',
|
|
'archive/struktura',
|
|
'projects/struktura',
|
|
]);
|
|
const out = resolveSlugAll('notes/sub', 'struktura.md', all,
|
|
{ globalBasename: true });
|
|
expect(out).toEqual(['notes/struktura']);
|
|
});
|
|
|
|
test('ancestor walk misses + globalBasename off → []', () => {
|
|
const all = new Set(['projects/struktura']);
|
|
const out = resolveSlugAll('concepts', 'struktura.md', all);
|
|
expect(out).toEqual([]);
|
|
});
|
|
|
|
test('ancestor walk misses + globalBasename on → all basename matches', () => {
|
|
const all = new Set([
|
|
'projects/struktura',
|
|
'archive/struktura',
|
|
]);
|
|
const out = resolveSlugAll('concepts', 'struktura.md', all,
|
|
{ globalBasename: true });
|
|
expect(out.sort()).toEqual(['archive/struktura', 'projects/struktura']);
|
|
});
|
|
|
|
test('zero basename matches when globalBasename on returns []', () => {
|
|
const all = new Set(['projects/struktura']);
|
|
const out = resolveSlugAll('concepts', 'phantom.md', all,
|
|
{ globalBasename: true });
|
|
expect(out).toEqual([]);
|
|
});
|
|
|
|
test('strips dirname from relTarget when applying basename lookup', () => {
|
|
// [[notes/struktura]] with no `notes` ancestor: ancestor walk strips
|
|
// `concepts` → tries `notes/struktura` which DOES exist → emits.
|
|
// This case verifies the basename fallback only fires when ancestor
|
|
// walk truly fails. (Sanity check on the fallback ordering.)
|
|
const all = new Set(['notes/struktura']);
|
|
expect(resolveSlugAll('concepts', 'notes/struktura.md', all))
|
|
.toEqual(['notes/struktura']);
|
|
});
|
|
|
|
test('resolveSlug back-compat: existing single-match callers unaffected', () => {
|
|
const all = new Set(['notes/struktura']);
|
|
// The legacy resolveSlug must keep returning the string|null shape.
|
|
expect(resolveSlug('notes', 'struktura.md', all)).toBe('notes/struktura');
|
|
expect(resolveSlug('concepts', 'phantom.md', all)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('issue #972 repro: bare wikilinks resolve when flag is on', () => {
|
|
// End-to-end: reproduces the issue's exact repro inside a tempdir +
|
|
// PGLite, then asserts edge count under both flag states.
|
|
test('flag OFF → 0 edges (back-compat)', async () => {
|
|
await engine.putPage('projects/struktura',
|
|
{ type: 'project', title: 'Struktura', compiled_truth: 'A project page.', timeline: '' });
|
|
await engine.putPage('concepts/knowledge-graph',
|
|
{ type: 'concept', title: 'Knowledge Graph',
|
|
compiled_truth: 'This concept relates to [[struktura]].', timeline: '' });
|
|
|
|
// Mirror to disk: the FS extractor walks files, not DB pages.
|
|
writeFile('projects/struktura.md', '---\ntitle: Struktura\ntype: project\n---\n\nA project page.\n');
|
|
writeFile('concepts/knowledge-graph.md',
|
|
'---\ntitle: Knowledge Graph\ntype: concept\n---\n\nThis concept relates to [[struktura]].\n');
|
|
|
|
// Ensure flag is off (default)
|
|
await engine.setConfig('link_resolution.global_basename', 'false');
|
|
|
|
await runExtract(engine, ['links', '--dir', brainDir]);
|
|
const links = await engine.getLinks('concepts/knowledge-graph');
|
|
expect(links.find(l => l.to_slug === 'projects/struktura')).toBeUndefined();
|
|
});
|
|
|
|
test('flag ON → 1 edge with wikilink_basename type', async () => {
|
|
await engine.putPage('projects/struktura',
|
|
{ type: 'project', title: 'Struktura', compiled_truth: 'A project page.', timeline: '' });
|
|
await engine.putPage('concepts/knowledge-graph',
|
|
{ type: 'concept', title: 'Knowledge Graph',
|
|
compiled_truth: 'This concept relates to [[struktura]].', timeline: '' });
|
|
|
|
writeFile('projects/struktura.md', '---\ntitle: Struktura\ntype: project\n---\n\nA project page.\n');
|
|
writeFile('concepts/knowledge-graph.md',
|
|
'---\ntitle: Knowledge Graph\ntype: concept\n---\n\nThis concept relates to [[struktura]].\n');
|
|
|
|
await engine.setConfig('link_resolution.global_basename', 'true');
|
|
|
|
await runExtract(engine, ['links', '--dir', brainDir]);
|
|
const links = await engine.getLinks('concepts/knowledge-graph');
|
|
const strk = links.find(l => l.to_slug === 'projects/struktura');
|
|
expect(strk).toBeDefined();
|
|
expect(strk!.link_type).toBe('wikilink_basename');
|
|
});
|
|
|
|
test('flag ON + ambiguous basename → one edge per match', async () => {
|
|
await engine.putPage('projects/struktura',
|
|
{ type: 'project', title: 'Struktura', compiled_truth: '', timeline: '' });
|
|
await engine.putPage('archive/struktura',
|
|
{ type: 'concept' as any, title: 'Struktura (archived)',
|
|
compiled_truth: '', timeline: '' });
|
|
await engine.putPage('concepts/knowledge-graph',
|
|
{ type: 'concept', title: 'Knowledge Graph',
|
|
compiled_truth: 'See [[struktura]].', timeline: '' });
|
|
|
|
writeFile('projects/struktura.md', '---\ntitle: Struktura\ntype: project\n---\n');
|
|
writeFile('archive/struktura.md', '---\ntitle: Struktura\ntype: concept\n---\n');
|
|
writeFile('concepts/knowledge-graph.md',
|
|
'---\ntitle: Knowledge Graph\ntype: concept\n---\n\nSee [[struktura]].\n');
|
|
|
|
await engine.setConfig('link_resolution.global_basename', 'true');
|
|
|
|
await runExtract(engine, ['links', '--dir', brainDir]);
|
|
const links = await engine.getLinks('concepts/knowledge-graph');
|
|
const basenameLinks = links.filter(l => l.link_type === 'wikilink_basename');
|
|
const targets = basenameLinks.map(l => l.to_slug).sort();
|
|
expect(targets).toEqual(['archive/struktura', 'projects/struktura']);
|
|
});
|
|
});
|