feat: backlinks check/fix, page lint, and report commands

Three new deterministic tools (zero LLM calls):

- gbrain backlinks check/fix -- scans brain for entity mentions without
  back-links, creates them. Enforces the Iron Law from the skills.
- gbrain lint [--fix] -- catches LLM preambles, code fence wrapping,
  placeholder dates, missing frontmatter, broken citations, empty sections.
  --fix auto-strips fixable artifacts.
- gbrain report --type <name> -- saves timestamped reports to
  brain/reports/{type}/YYYY-MM-DD-HHMM.md for audit trails.

33 new tests (409 total, 0 fail).
This commit is contained in:
Garry Tan
2026-04-11 21:25:44 -10:00
parent edc2174661
commit 13fca3768b
9 changed files with 819 additions and 4 deletions

View File

@@ -70,10 +70,22 @@ All notable changes to GBrain will be documented in this file.
uploaded, source_url, type).
- **All skills** updated to reference actual `gbrain files` commands instead of
theoretical patterns.
- **Back-link enforcer closes the loop.** `gbrain backlinks check` scans your
brain for entity mentions without back-links. `gbrain backlinks fix` creates
them. The Iron Law of Back-Linking is in every skill, now the code enforces it.
- **Page linter catches LLM slop.** `gbrain lint` flags "Of course! Here is..."
preambles, wrapping code fences, placeholder dates, missing frontmatter, broken
citations, and empty sections. `gbrain lint --fix` auto-strips the fixable ones.
Every brain that uses AI for ingestion accumulates this. Now it's one command.
- **Audit trail for everything.** `gbrain report --type enrichment-sweep` saves
timestamped reports to `brain/reports/{type}/YYYY-MM-DD-HHMM.md`. The maintain
skill references this for enrichment sweeps, meeting syncs, and maintenance runs.
- **Publish skill** added to manifest (8th skill). First code+skill pair.
- Skills version bumped to 0.9.0.
- 34 new unit tests for publish (content stripping, encryption, password
generation, HTML output, citation patterns). Total: 376 pass.
- 67 new unit tests across publish, backlinks, lint, and report. Total: 409 pass.
## [0.8.0] - 2026-04-11

View File

@@ -53,6 +53,9 @@ markdown files (tool-agnostic, work with both CLI and plugin contexts).
- `skills/_brain-filing-rules.md` — Cross-cutting brain filing rules (referenced by all brain-writing skills)
- `skills/migrations/` — Version migration files with feature_pitch YAML frontmatter
- `src/commands/publish.ts` — Deterministic brain page publisher (code+skill pair, zero LLM calls)
- `src/commands/backlinks.ts` — Back-link checker and fixer (enforces Iron Law)
- `src/commands/lint.ts` — Page quality linter (catches LLM artifacts, placeholder dates)
- `src/commands/report.ts` — Structured report saver (audit trail for maintenance/enrichment)
- `openclaw.plugin.json` — ClawHub bundle plugin manifest
## Commands
@@ -81,7 +84,10 @@ parity), `test/cli.test.ts` (CLI structure), `test/config.test.ts` (config redac
`test/pglite-engine.test.ts` (PGLite engine, all 37 BrainEngine methods),
`test/utils.test.ts` (shared SQL utilities), `test/engine-factory.test.ts` (engine factory + dynamic imports),
`test/integrations.test.ts` (recipe parsing, CLI routing, recipe validation),
`test/publish.test.ts` (content stripping, encryption, password generation, HTML output).
`test/publish.test.ts` (content stripping, encryption, password generation, HTML output),
`test/backlinks.test.ts` (entity extraction, back-link detection, timeline entry generation),
`test/lint.test.ts` (LLM artifact detection, code fence stripping, frontmatter validation),
`test/report.test.ts` (report format, directory structure).
E2E tests (`test/e2e/`): Run against real Postgres+pgvector. Require `DATABASE_URL`.
- `bun run test:e2e` runs Tier 1 (mechanical, all operations, no API keys)

View File

@@ -18,7 +18,7 @@ for (const op of operations) {
}
// CLI-only commands that bypass the operation layer
const CLI_ONLY = new Set(['init', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate']);
const CLI_ONLY = new Set(['init', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate']);
async function main() {
const args = process.argv.slice(2);
@@ -252,6 +252,21 @@ async function handleCliOnly(command: string, args: string[]) {
await runPublish(subArgs);
return;
}
if (command === 'backlinks') {
const { runBacklinks } = await import('./commands/backlinks.ts');
await runBacklinks(subArgs);
return;
}
if (command === 'lint') {
const { runLint } = await import('./commands/lint.ts');
await runLint(subArgs);
return;
}
if (command === 'report') {
const { runReport } = await import('./commands/report.ts');
await runReport(subArgs);
return;
}
// All remaining CLI-only commands need a DB connection
const engine = await connectEngine();

213
src/commands/backlinks.ts Normal file
View File

@@ -0,0 +1,213 @@
/**
* gbrain backlinks — Check and fix missing back-links across brain pages.
*
* Deterministic: zero LLM calls. Scans pages for entity mentions,
* checks if back-links exist, and optionally creates them.
*
* Usage:
* gbrain backlinks check [--dir <brain-dir>] # report missing back-links
* gbrain backlinks fix [--dir <brain-dir>] # create missing back-links
* gbrain backlinks fix --dry-run # preview fixes
*/
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'fs';
import { join, relative, basename } from 'path';
interface BacklinkGap {
/** The page that mentions the entity */
sourcePage: string;
/** The entity page that's missing the back-link */
targetPage: string;
/** The entity name mentioned */
entityName: string;
/** The source page title */
sourceTitle: string;
}
/** Extract entity references from markdown content (relative links to people/companies) */
export function extractEntityRefs(content: string, pagePath: string): { name: string; slug: string; dir: string }[] {
const refs: { name: string; slug: string; dir: string }[] = [];
// Match markdown links to brain pages: [Name](../people/slug.md) or [Name](../../companies/slug.md)
const linkPattern = /\[([^\]]+)\]\(([^)]*(?:people|companies)\/([^)]+\.md))\)/g;
let match;
while ((match = linkPattern.exec(content)) !== null) {
const name = match[1];
const fullPath = match[2];
const slug = match[3].replace('.md', '');
const dir = fullPath.includes('people') ? 'people' : 'companies';
refs.push({ name, slug, dir });
}
return refs;
}
/** Extract title from page (first H1 or frontmatter title) */
export function extractPageTitle(content: string): string {
const fmMatch = content.match(/^title:\s*"?(.+?)"?\s*$/m);
if (fmMatch) return fmMatch[1];
const h1Match = content.match(/^#\s+(.+)$/m);
if (h1Match) return h1Match[1].trim();
return 'Untitled';
}
/** Check if a page already contains a back-link to a given source file */
export function hasBacklink(targetContent: string, sourceFilename: string): boolean {
return targetContent.includes(sourceFilename);
}
/** Build a timeline back-link entry */
export function buildBacklinkEntry(sourceTitle: string, sourcePath: string, date: string): string {
return `- **${date}** | Referenced in [${sourceTitle}](${sourcePath})`;
}
/** Scan a brain directory for back-link gaps */
export function findBacklinkGaps(brainDir: string): BacklinkGap[] {
const gaps: BacklinkGap[] = [];
// Collect all markdown files
const allPages: { path: string; relPath: string; content: string }[] = [];
function walk(dir: string) {
for (const entry of readdirSync(dir)) {
if (entry.startsWith('.')) continue;
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
walk(full);
} else if (entry.endsWith('.md') && !entry.startsWith('_')) {
const relPath = relative(brainDir, full);
try {
allPages.push({ path: full, relPath, content: readFileSync(full, 'utf-8') });
} catch { /* skip unreadable */ }
}
}
}
walk(brainDir);
// Build a lookup of existing pages by directory/slug
const pagesBySlug = new Map<string, { path: string; content: string }>();
for (const page of allPages) {
const slug = page.relPath.replace('.md', '');
pagesBySlug.set(slug, { path: page.path, content: page.content });
}
// For each page, check entity references
for (const page of allPages) {
const refs = extractEntityRefs(page.content, page.relPath);
const sourceFilename = basename(page.relPath);
for (const ref of refs) {
const targetSlug = `${ref.dir}/${ref.slug}`;
const target = pagesBySlug.get(targetSlug);
if (!target) continue; // target page doesn't exist
// Check if the target already has a back-link to this source page
if (!hasBacklink(target.content, sourceFilename)) {
gaps.push({
sourcePage: page.relPath,
targetPage: targetSlug + '.md',
entityName: ref.name,
sourceTitle: extractPageTitle(page.content),
});
}
}
}
return gaps;
}
/** Fix back-link gaps by appending timeline entries to target pages */
export function fixBacklinkGaps(brainDir: string, gaps: BacklinkGap[], dryRun: boolean = false): number {
const today = new Date().toISOString().slice(0, 10);
let fixed = 0;
// Group gaps by target page to batch writes
const byTarget = new Map<string, BacklinkGap[]>();
for (const gap of gaps) {
const existing = byTarget.get(gap.targetPage) || [];
existing.push(gap);
byTarget.set(gap.targetPage, existing);
}
for (const [targetPage, targetGaps] of byTarget) {
const targetPath = join(brainDir, targetPage);
if (!existsSync(targetPath)) continue;
let content = readFileSync(targetPath, 'utf-8');
for (const gap of targetGaps) {
// Compute relative path from target to source
const targetDir = targetPage.split('/').slice(0, -1);
const sourceDir = gap.sourcePage.split('/');
const depth = targetDir.length;
const relPrefix = '../'.repeat(depth);
const relPath = relPrefix + gap.sourcePage;
const entry = buildBacklinkEntry(gap.sourceTitle, relPath, today);
// Insert into Timeline section
if (content.includes('## Timeline')) {
const parts = content.split('## Timeline');
const afterTimeline = parts[1];
const nextSection = afterTimeline.match(/\n## /);
if (nextSection) {
const insertIdx = parts[0].length + '## Timeline'.length + nextSection.index!;
content = content.slice(0, insertIdx) + '\n' + entry + content.slice(insertIdx);
} else {
content = content.trimEnd() + '\n' + entry + '\n';
}
} else {
// Add Timeline section
content = content.trimEnd() + '\n\n## Timeline\n\n' + entry + '\n';
}
fixed++;
}
if (!dryRun) {
writeFileSync(targetPath, content);
}
}
return fixed;
}
export async function runBacklinks(args: string[]) {
const subcommand = args[0];
const dirIdx = args.indexOf('--dir');
const brainDir = dirIdx >= 0 ? args[dirIdx + 1] : '.';
const dryRun = args.includes('--dry-run');
if (!subcommand || !['check', 'fix'].includes(subcommand)) {
console.error('Usage: gbrain backlinks <check|fix> [--dir <brain-dir>] [--dry-run]');
console.error(' check Report missing back-links');
console.error(' fix Create missing back-links (appends to Timeline)');
console.error(' --dir Brain directory (default: current directory)');
console.error(' --dry-run Preview fixes without writing');
process.exit(1);
}
if (!existsSync(brainDir)) {
console.error(`Directory not found: ${brainDir}`);
process.exit(1);
}
const gaps = findBacklinkGaps(brainDir);
if (gaps.length === 0) {
console.log('No missing back-links found.');
return;
}
if (subcommand === 'check') {
console.log(`Found ${gaps.length} missing back-link(s):\n`);
for (const gap of gaps) {
console.log(` ${gap.targetPage} <- ${gap.sourcePage}`);
console.log(` "${gap.entityName}" mentioned in "${gap.sourceTitle}"`);
}
console.log(`\nRun 'gbrain backlinks fix --dir ${brainDir}' to create them.`);
} else {
const label = dryRun ? '(dry run) ' : '';
const fixed = fixBacklinkGaps(brainDir, gaps, dryRun);
console.log(`${label}Fixed ${fixed} missing back-link(s) across ${new Set(gaps.map(g => g.targetPage)).size} page(s).`);
if (dryRun) {
console.log('\nRe-run without --dry-run to apply.');
}
}
}

245
src/commands/lint.ts Normal file
View File

@@ -0,0 +1,245 @@
/**
* gbrain lint — Deterministic brain page quality checker.
*
* Zero LLM calls. Catches common quality issues:
* - LLM preamble artifacts ("Of course! Here is...")
* - Placeholder dates (YYYY-MM-DD, XX-XX left unfilled)
* - Missing required frontmatter fields
* - Broken citations (unclosed brackets, missing dates)
* - Empty/stub sections
* - Wrapping code fences from LLM output
*
* Usage:
* gbrain lint <dir> # report issues
* gbrain lint <dir> --fix # auto-fix what's fixable
* gbrain lint <dir> --fix --dry-run # preview fixes
* gbrain lint <file.md> # lint single file
*/
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'fs';
import { join, relative } from 'path';
export interface LintIssue {
file: string;
line: number;
rule: string;
message: string;
fixable: boolean;
}
// ── LLM artifact patterns ──────────────────────────────────────────
const LLM_PREAMBLES = [
/^Of course\.?\s*Here is (?:a |the )?(?:detailed |comprehensive |updated )?(?:brain )?page[^.\n]*\.?\s*\n*/gim,
/^Certainly\.?\s*Here is[^.\n]*\.?\s*\n*/gim,
/^Here is (?:a |the )?(?:detailed |comprehensive |updated )?(?:brain )?page[^.\n]*\.?\s*\n*/gim,
/^I've (?:created|updated|written|prepared) (?:a |the )?(?:detailed |comprehensive )?(?:brain )?page[^.\n]*\.?\s*\n*/gim,
/^Sure(?:!|,)?\s*Here (?:is|are)[^.\n]*\.?\s*\n*/gim,
/^Absolutely\.?\s*Here[^.\n]*\.?\s*\n*/gim,
];
// ── Rules ──────────────────────────────────────────────────────────
export function lintContent(content: string, filePath: string): LintIssue[] {
const issues: LintIssue[] = [];
const lines = content.split('\n');
// Rule: LLM preamble artifacts
for (const pattern of LLM_PREAMBLES) {
pattern.lastIndex = 0;
if (pattern.test(content)) {
issues.push({
file: filePath, line: 1, rule: 'llm-preamble',
message: 'LLM preamble artifact detected (e.g., "Of course! Here is...")',
fixable: true,
});
}
}
// Rule: Wrapping code fences (```markdown ... ```)
if (content.match(/^```(?:markdown|md)\s*\n/m) && content.match(/\n```\s*$/m)) {
issues.push({
file: filePath, line: 1, rule: 'code-fence-wrap',
message: 'Page wrapped in ```markdown code fences (LLM artifact)',
fixable: true,
});
}
// Rule: Placeholder dates
for (let i = 0; i < lines.length; i++) {
if (lines[i].match(/\bYYYY-MM-DD\b/) || lines[i].match(/\bXX-XX\b/) || lines[i].match(/\b\d{4}-XX-XX\b/)) {
issues.push({
file: filePath, line: i + 1, rule: 'placeholder-date',
message: `Placeholder date found: ${lines[i].trim().slice(0, 60)}`,
fixable: false,
});
}
}
// Rule: Missing frontmatter
if (content.startsWith('---')) {
const fmEnd = content.indexOf('---', 3);
if (fmEnd > 0) {
const fm = content.slice(3, fmEnd);
if (!fm.match(/^title:/m)) {
issues.push({
file: filePath, line: 1, rule: 'missing-title',
message: 'Frontmatter missing required field: title',
fixable: false,
});
}
if (!fm.match(/^type:/m)) {
issues.push({
file: filePath, line: 1, rule: 'missing-type',
message: 'Frontmatter missing required field: type',
fixable: false,
});
}
if (!fm.match(/^created:/m)) {
issues.push({
file: filePath, line: 1, rule: 'missing-created',
message: 'Frontmatter missing required field: created',
fixable: false,
});
}
}
} else {
// No frontmatter at all
issues.push({
file: filePath, line: 1, rule: 'no-frontmatter',
message: 'Page has no YAML frontmatter',
fixable: false,
});
}
// Rule: Broken citations (unclosed [Source: ...)
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Open [Source: without closing ]
if (line.match(/\[Source:[^\]]*$/) && !(i + 1 < lines.length && lines[i + 1].match(/^\s*[^\[]*\]/))) {
issues.push({
file: filePath, line: i + 1, rule: 'broken-citation',
message: 'Unclosed [Source: ...] citation',
fixable: false,
});
}
}
// Rule: Empty/stub sections
const sectionPattern = /^##\s+(.+)$/gm;
let sectionMatch;
while ((sectionMatch = sectionPattern.exec(content)) !== null) {
const sectionStart = sectionMatch.index + sectionMatch[0].length;
const nextSection = content.indexOf('\n## ', sectionStart);
const sectionBody = content.slice(sectionStart, nextSection > 0 ? nextSection : undefined).trim();
if (sectionBody === '' || sectionBody === '[No data yet]' || sectionBody === '*[To be filled by agent]*') {
const lineNum = content.slice(0, sectionMatch.index).split('\n').length;
issues.push({
file: filePath, line: lineNum, rule: 'empty-section',
message: `Empty section: ## ${sectionMatch[1]}`,
fixable: false,
});
}
}
return issues;
}
/** Auto-fix fixable issues */
export function fixContent(content: string): string {
let fixed = content;
// Fix LLM preambles
for (const pattern of LLM_PREAMBLES) {
pattern.lastIndex = 0;
fixed = fixed.replace(pattern, '');
}
// Fix wrapping code fences
fixed = fixed.replace(/^```(?:markdown|md)\s*\n/, '');
fixed = fixed.replace(/\n```\s*$/, '');
// Clean up excessive blank lines left by fixes
fixed = fixed.replace(/\n{3,}/g, '\n\n');
return fixed.trim() + '\n';
}
/** Collect markdown files from a directory */
function collectPages(dir: string): string[] {
const pages: string[] = [];
function walk(d: string) {
for (const entry of readdirSync(d)) {
if (entry.startsWith('.') || entry.startsWith('_')) continue;
const full = join(d, entry);
if (statSync(full).isDirectory()) walk(full);
else if (entry.endsWith('.md')) pages.push(full);
}
}
walk(dir);
return pages.sort();
}
export async function runLint(args: string[]) {
const target = args.find(a => !a.startsWith('--'));
const doFix = args.includes('--fix');
const dryRun = args.includes('--dry-run');
if (!target) {
console.error('Usage: gbrain lint <dir|file.md> [--fix] [--dry-run]');
console.error(' --fix Auto-fix fixable issues (LLM preambles, code fences)');
console.error(' --dry-run Preview fixes without writing');
process.exit(1);
}
if (!existsSync(target)) {
console.error(`Not found: ${target}`);
process.exit(1);
}
// Single file or directory
const isSingleFile = statSync(target).isFile();
const pages = isSingleFile ? [target] : collectPages(target);
let totalIssues = 0;
let totalFixed = 0;
let pagesWithIssues = 0;
for (const page of pages) {
const content = readFileSync(page, 'utf-8');
const relPath = isSingleFile ? page : relative(target, page);
const issues = lintContent(content, relPath);
if (issues.length === 0) continue;
pagesWithIssues++;
totalIssues += issues.length;
console.log(`\n${relPath}:`);
for (const issue of issues) {
const fixLabel = issue.fixable ? ' [fixable]' : '';
console.log(` L${issue.line} ${issue.rule}: ${issue.message}${fixLabel}`);
}
// Auto-fix if requested
if (doFix && issues.some(i => i.fixable)) {
const fixed = fixContent(content);
if (fixed !== content) {
const fixCount = issues.filter(i => i.fixable).length;
totalFixed += fixCount;
if (!dryRun) {
writeFileSync(page, fixed);
}
console.log(` ${dryRun ? '(dry run) ' : ''}Fixed ${fixCount} issue(s)`);
}
}
}
console.log(`\n${pages.length} pages scanned. ${totalIssues} issue(s) in ${pagesWithIssues} page(s).`);
if (doFix) {
console.log(`${dryRun ? '(dry run) ' : ''}${totalFixed} auto-fixed.`);
} else if (totalIssues > 0) {
const fixable = totalIssues; // rough estimate
console.log(`Run with --fix to auto-fix fixable issues.`);
}
}

76
src/commands/report.ts Normal file
View File

@@ -0,0 +1,76 @@
/**
* gbrain report — Save a structured report to brain/reports/.
*
* Deterministic: zero LLM calls. Creates timestamped report pages
* for audit trails of enrichment sweeps, maintenance runs, syncs, etc.
*
* Usage:
* gbrain report --type enrichment-sweep --title "Enrichment Sweep" --content "..."
* echo "report body" | gbrain report --type meeting-sync --title "Meeting Sync"
* gbrain report --type enrichment-sweep --dir /path/to/brain
*/
import { writeFileSync, mkdirSync, readFileSync } from 'fs';
import { join } from 'path';
export async function runReport(args: string[]) {
const typeIdx = args.indexOf('--type');
const titleIdx = args.indexOf('--title');
const contentIdx = args.indexOf('--content');
const dirIdx = args.indexOf('--dir');
const reportType = typeIdx >= 0 ? args[typeIdx + 1] : null;
const brainDir = dirIdx >= 0 ? args[dirIdx + 1] : '.';
if (!reportType) {
console.error('Usage: gbrain report --type <name> --title "..." --content "..." [--dir <brain>]');
console.error(' Or pipe content via stdin:');
console.error(' echo "report body" | gbrain report --type meeting-sync --title "Daily Sync"');
console.error('');
console.error(' Common types: enrichment-sweep, meeting-sync, maintenance, backlink-check, lint');
console.error(' Creates: brain/reports/{type}/{YYYY-MM-DD-HHMM}.md');
process.exit(1);
}
// Read content from --content arg or stdin
let content = contentIdx >= 0 ? args[contentIdx + 1] : null;
if (!content && !process.stdin.isTTY) {
content = readFileSync('/dev/stdin', 'utf-8');
}
if (!content?.trim()) {
console.error('No content provided. Use --content "..." or pipe via stdin.');
process.exit(1);
}
const now = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
const dateStr = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
const timeStr = `${pad(now.getHours())}${pad(now.getMinutes())}`;
const timePretty = `${pad(now.getHours())}:${pad(now.getMinutes())}`;
const title = titleIdx >= 0
? args[titleIdx + 1]
: reportType.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
const filename = `${dateStr}-${timeStr}.md`;
const reportDir = join(brainDir, 'reports', reportType);
mkdirSync(reportDir, { recursive: true });
const page = `---
title: "${title} -- ${dateStr}"
type: report
report_type: ${reportType}
date: ${dateStr}
time: "${timePretty}"
---
# ${title} -- ${dateStr} ${timePretty}
${content.trim()}
`;
const filepath = join(reportDir, filename);
writeFileSync(filepath, page);
console.log(filepath);
}

80
test/backlinks.test.ts Normal file
View File

@@ -0,0 +1,80 @@
import { describe, test, expect } from 'bun:test';
import {
extractEntityRefs,
extractPageTitle,
hasBacklink,
buildBacklinkEntry,
} 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)');
});
});

118
test/lint.test.ts Normal file
View File

@@ -0,0 +1,118 @@
import { describe, test, expect } from 'bun:test';
import { lintContent, fixContent } from '../src/commands/lint.ts';
describe('lintContent', () => {
test('detects LLM preamble "Of course"', () => {
const content = 'Of course. Here is a detailed brain page for Jane Doe.\n\n# Jane Doe\n\nContent.';
const issues = lintContent(content, 'test.md');
expect(issues.some(i => i.rule === 'llm-preamble')).toBe(true);
});
test('detects LLM preamble "I\'ve created"', () => {
const content = "I've created a comprehensive brain page for the company.\n\n# Acme\n\nContent.";
const issues = lintContent(content, 'test.md');
expect(issues.some(i => i.rule === 'llm-preamble')).toBe(true);
});
test('detects LLM preamble "Certainly"', () => {
const content = 'Certainly. Here is the brain page.\n\n# Page\n\nContent.';
const issues = lintContent(content, 'test.md');
expect(issues.some(i => i.rule === 'llm-preamble')).toBe(true);
});
test('no false positive on normal content', () => {
const content = '---\ntitle: Test\ntype: person\ncreated: 2026-04-11\n---\n\n# Test\n\nNormal content.';
const issues = lintContent(content, 'test.md');
expect(issues.filter(i => i.rule === 'llm-preamble')).toHaveLength(0);
});
test('detects wrapping code fences', () => {
const content = '```markdown\n---\ntitle: Test\n---\n\n# Test\n\nContent.\n```';
const issues = lintContent(content, 'test.md');
expect(issues.some(i => i.rule === 'code-fence-wrap')).toBe(true);
});
test('detects placeholder dates', () => {
const content = '---\ntitle: Test\ntype: person\ncreated: YYYY-MM-DD\n---\n\n# Test';
const issues = lintContent(content, 'test.md');
expect(issues.some(i => i.rule === 'placeholder-date')).toBe(true);
});
test('detects XX-XX placeholder dates', () => {
const content = '---\ntitle: Test\ntype: person\ncreated: 2026-04-11\n---\n\n# Test\n\n- 2026-XX-XX | Something happened';
const issues = lintContent(content, 'test.md');
expect(issues.some(i => i.rule === 'placeholder-date')).toBe(true);
});
test('detects missing frontmatter title', () => {
const content = '---\ntype: person\ncreated: 2026-04-11\n---\n\n# Test';
const issues = lintContent(content, 'test.md');
expect(issues.some(i => i.rule === 'missing-title')).toBe(true);
});
test('detects missing frontmatter type', () => {
const content = '---\ntitle: Test\ncreated: 2026-04-11\n---\n\n# Test';
const issues = lintContent(content, 'test.md');
expect(issues.some(i => i.rule === 'missing-type')).toBe(true);
});
test('detects no frontmatter at all', () => {
const content = '# Test\n\nContent without frontmatter.';
const issues = lintContent(content, 'test.md');
expect(issues.some(i => i.rule === 'no-frontmatter')).toBe(true);
});
test('detects empty sections', () => {
const content = '---\ntitle: Test\ntype: person\ncreated: 2026-04-11\n---\n\n# Test\n\n## What They Believe\n\n[No data yet]\n\n## State\n\nReal content here.';
const issues = lintContent(content, 'test.md');
expect(issues.some(i => i.rule === 'empty-section' && i.message.includes('What They Believe'))).toBe(true);
});
test('detects agent placeholder sections', () => {
const content = '---\ntitle: Test\ntype: person\ncreated: 2026-04-11\n---\n\n# Test\n\n## Summary\n\n*[To be filled by agent]*\n\n## State\n\nContent.';
const issues = lintContent(content, 'test.md');
expect(issues.some(i => i.rule === 'empty-section' && i.message.includes('Summary'))).toBe(true);
});
test('clean page has no issues', () => {
const content = '---\ntitle: Jane Doe\ntype: person\ncreated: 2026-04-11\n---\n\n# Jane Doe\n\n## State\n\nCTO of Acme Corp.\n\n## Timeline\n\n- **2026-04-11** | Met at event [Source: User]';
const issues = lintContent(content, 'test.md');
expect(issues).toHaveLength(0);
});
});
describe('fixContent', () => {
test('removes LLM preamble', () => {
const input = 'Of course. Here is a detailed brain page for Jane.\n\n# Jane Doe\n\nContent.';
const fixed = fixContent(input);
expect(fixed).not.toContain('Of course');
expect(fixed).toContain('# Jane Doe');
expect(fixed).toContain('Content.');
});
test('removes wrapping code fences', () => {
const input = '```markdown\n# Title\n\nContent.\n```';
const fixed = fixContent(input);
expect(fixed).not.toContain('```');
expect(fixed).toContain('# Title');
});
test('cleans up excessive blank lines after fix', () => {
const input = 'Of course. Here is the brain page.\n\n\n\n# Title\n\nContent.';
const fixed = fixContent(input);
expect(fixed).not.toMatch(/\n{3,}/);
});
test('preserves content that needs no fixing', () => {
const input = '# Normal Title\n\nNormal content.\n';
expect(fixContent(input)).toBe(input);
});
test('handles multiple preambles', () => {
const input = 'Sure! Here is the page.\nCertainly. Here is the brain page.\n\n# Title\n\nContent.';
const fixed = fixContent(input);
expect(fixed).not.toContain('Sure');
expect(fixed).not.toContain('Certainly');
expect(fixed).toContain('# Title');
});
});

50
test/report.test.ts Normal file
View File

@@ -0,0 +1,50 @@
import { describe, test, expect } from 'bun:test';
import { mkdirSync, readFileSync, existsSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
// Test the report command's output format by importing the logic
// Since runReport reads from stdin/args and writes to disk, we test
// the file creation pattern directly.
describe('report output format', () => {
const testDir = join(tmpdir(), `gbrain-report-test-${Date.now()}`);
test('creates report directory structure', () => {
const reportDir = join(testDir, 'reports', 'test-type');
mkdirSync(reportDir, { recursive: true });
expect(existsSync(reportDir)).toBe(true);
rmSync(testDir, { recursive: true, force: true });
});
test('report filename format is YYYY-MM-DD-HHMM.md', () => {
const now = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
const filename = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}.md`;
expect(filename).toMatch(/^\d{4}-\d{2}-\d{2}-\d{4}\.md$/);
});
test('report page has correct frontmatter structure', () => {
const title = 'Enrichment Sweep';
const reportType = 'enrichment-sweep';
const date = '2026-04-11';
const time = '14:30';
const page = `---
title: "${title} -- ${date}"
type: report
report_type: ${reportType}
date: ${date}
time: "${time}"
---
# ${title} -- ${date} ${time}
Report content here.
`;
expect(page).toContain('type: report');
expect(page).toContain('report_type: enrichment-sweep');
expect(page).toContain('# Enrichment Sweep');
});
});