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:
16
CHANGELOG.md
16
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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
17
src/cli.ts
17
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();
|
||||
|
||||
213
src/commands/backlinks.ts
Normal file
213
src/commands/backlinks.ts
Normal 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
245
src/commands/lint.ts
Normal 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
76
src/commands/report.ts
Normal 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
80
test/backlinks.test.ts
Normal 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
118
test/lint.test.ts
Normal 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
50
test/report.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user