mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 14:59:47 +00:00
The help text (gbrain check-backlinks <check|fix> [dir]) promised a positional directory argument, but runBacklinks only parsed --dir and defaulted to cwd, so the walker ran from the wrong root and could hit EPERM on unreadable sibling dirs. Extract parseBacklinksArgs: positional [dir] is now honored, --dir still overrides it, --dry-run preserved, and a --dir flag missing its value falls back to the positional dir instead of picking up undefined. Takeover of #852 (rebased onto master past the findBacklinkGaps dedupe test block). Fixes #485. Co-authored-by: Sinabina <sinabina@Sinabinas-MacBook-Pro-4.local> Co-authored-by: Kage18 <Kage18@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
131 lines
5.0 KiB
TypeScript
131 lines
5.0 KiB
TypeScript
import { describe, test, expect } from 'bun:test';
|
|
import {
|
|
extractEntityRefs,
|
|
extractPageTitle,
|
|
hasBacklink,
|
|
buildBacklinkEntry,
|
|
parseBacklinksArgs,
|
|
} from '../src/commands/backlinks.ts';
|
|
|
|
describe('extractEntityRefs', () => {
|
|
test('extracts people links', () => {
|
|
const content = 'Met [Jane Doe](../people/jane-doe.md) at the event.';
|
|
const refs = extractEntityRefs(content, 'meetings/2026-04-01.md');
|
|
expect(refs).toHaveLength(1);
|
|
expect(refs[0].name).toBe('Jane Doe');
|
|
expect(refs[0].slug).toBe('jane-doe');
|
|
expect(refs[0].dir).toBe('people');
|
|
});
|
|
|
|
test('extracts company links', () => {
|
|
const content = 'Discussed [Acme Corp](../../companies/acme-corp.md) deal.';
|
|
const refs = extractEntityRefs(content, 'meetings/2026/q1.md');
|
|
expect(refs).toHaveLength(1);
|
|
expect(refs[0].name).toBe('Acme Corp');
|
|
expect(refs[0].slug).toBe('acme-corp');
|
|
expect(refs[0].dir).toBe('companies');
|
|
});
|
|
|
|
test('extracts multiple refs', () => {
|
|
const content = '[Alice](../people/alice.md) and [Bob](../people/bob.md) from [Acme](../companies/acme.md).';
|
|
const refs = extractEntityRefs(content, 'meetings/test.md');
|
|
expect(refs).toHaveLength(3);
|
|
});
|
|
|
|
test('returns empty for no entity links', () => {
|
|
const content = 'Just a plain page with [external](https://example.com) link.';
|
|
expect(extractEntityRefs(content, 'test.md')).toHaveLength(0);
|
|
});
|
|
|
|
test('ignores non-entity brain links', () => {
|
|
const content = '[Guide](../docs/setup.md) for reference.';
|
|
expect(extractEntityRefs(content, 'test.md')).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('extractPageTitle', () => {
|
|
test('extracts from frontmatter', () => {
|
|
expect(extractPageTitle('---\ntitle: "Jane Doe"\ntype: person\n---\n# Jane')).toBe('Jane Doe');
|
|
});
|
|
|
|
test('extracts from H1 when no frontmatter title', () => {
|
|
expect(extractPageTitle('---\ntype: person\n---\n# Jane Doe')).toBe('Jane Doe');
|
|
});
|
|
|
|
test('extracts H1 without frontmatter', () => {
|
|
expect(extractPageTitle('# Meeting Notes\n\nContent.')).toBe('Meeting Notes');
|
|
});
|
|
|
|
test('returns Untitled for no title', () => {
|
|
expect(extractPageTitle('Just content, no heading.')).toBe('Untitled');
|
|
});
|
|
});
|
|
|
|
describe('hasBacklink', () => {
|
|
test('returns true when source filename is present', () => {
|
|
const content = '## Timeline\n\n- Referenced in [Meeting](../../meetings/q1-review.md)';
|
|
expect(hasBacklink(content, 'q1-review.md')).toBe(true);
|
|
});
|
|
|
|
test('returns false when source filename is absent', () => {
|
|
const content = '## Timeline\n\n- Some other entry';
|
|
expect(hasBacklink(content, 'q1-review.md')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('buildBacklinkEntry', () => {
|
|
test('builds properly formatted entry', () => {
|
|
const entry = buildBacklinkEntry('Q1 Review', '../../meetings/q1-review.md', '2026-04-11');
|
|
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 });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('parseBacklinksArgs', () => {
|
|
test('uses positional dir for check and fix subcommands', () => {
|
|
expect(parseBacklinksArgs(['check', '/tmp/brain']).brainDir).toBe('/tmp/brain');
|
|
expect(parseBacklinksArgs(['fix', '/tmp/brain']).brainDir).toBe('/tmp/brain');
|
|
});
|
|
|
|
test('defaults to cwd when no dir given', () => {
|
|
expect(parseBacklinksArgs(['check']).brainDir).toBe('.');
|
|
});
|
|
|
|
test('--dir overrides positional dir and preserves dry-run', () => {
|
|
const parsed = parseBacklinksArgs(['fix', '/tmp/ignored', '--dir', '/tmp/brain', '--dry-run']);
|
|
expect(parsed.subcommand).toBe('fix');
|
|
expect(parsed.brainDir).toBe('/tmp/brain');
|
|
expect(parsed.dryRun).toBe(true);
|
|
});
|
|
|
|
test('--dir missing its value falls back to positional dir', () => {
|
|
expect(parseBacklinksArgs(['check', '/tmp/brain', '--dir']).brainDir).toBe('/tmp/brain');
|
|
expect(parseBacklinksArgs(['check', '--dir', '--dry-run']).brainDir).toBe('.');
|
|
});
|
|
});
|