From 13fca3768b08cd9c93dcb6d95510795dc7e57e46 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 11 Apr 2026 21:25:44 -1000 Subject: [PATCH] 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 -- saves timestamped reports to brain/reports/{type}/YYYY-MM-DD-HHMM.md for audit trails. 33 new tests (409 total, 0 fail). --- CHANGELOG.md | 16 ++- CLAUDE.md | 8 +- src/cli.ts | 17 ++- src/commands/backlinks.ts | 213 +++++++++++++++++++++++++++++++++ src/commands/lint.ts | 245 ++++++++++++++++++++++++++++++++++++++ src/commands/report.ts | 76 ++++++++++++ test/backlinks.test.ts | 80 +++++++++++++ test/lint.test.ts | 118 ++++++++++++++++++ test/report.test.ts | 50 ++++++++ 9 files changed, 819 insertions(+), 4 deletions(-) create mode 100644 src/commands/backlinks.ts create mode 100644 src/commands/lint.ts create mode 100644 src/commands/report.ts create mode 100644 test/backlinks.test.ts create mode 100644 test/lint.test.ts create mode 100644 test/report.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 423442740..caa1dd564 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index b645cf48f..78ea1264c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) diff --git a/src/cli.ts b/src/cli.ts index e15be248c..9a212c612 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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(); diff --git a/src/commands/backlinks.ts b/src/commands/backlinks.ts new file mode 100644 index 000000000..6f5c13a7b --- /dev/null +++ b/src/commands/backlinks.ts @@ -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 ] # report missing back-links + * gbrain backlinks fix [--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(); + 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(); + 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 [--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.'); + } + } +} diff --git a/src/commands/lint.ts b/src/commands/lint.ts new file mode 100644 index 000000000..754bbf287 --- /dev/null +++ b/src/commands/lint.ts @@ -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 # report issues + * gbrain lint --fix # auto-fix what's fixable + * gbrain lint --fix --dry-run # preview fixes + * gbrain lint # 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 [--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.`); + } +} diff --git a/src/commands/report.ts b/src/commands/report.ts new file mode 100644 index 000000000..9371e44f8 --- /dev/null +++ b/src/commands/report.ts @@ -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 --title "..." --content "..." [--dir ]'); + 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); +} diff --git a/test/backlinks.test.ts b/test/backlinks.test.ts new file mode 100644 index 000000000..a01705af7 --- /dev/null +++ b/test/backlinks.test.ts @@ -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)'); + }); +}); diff --git a/test/lint.test.ts b/test/lint.test.ts new file mode 100644 index 000000000..ca38b8d23 --- /dev/null +++ b/test/lint.test.ts @@ -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'); + }); +}); diff --git a/test/report.test.ts b/test/report.test.ts new file mode 100644 index 000000000..0086d3b5e --- /dev/null +++ b/test/report.test.ts @@ -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'); + }); +});