diff --git a/src/commands/frontmatter.ts b/src/commands/frontmatter.ts index 990628092..ae825c774 100644 --- a/src/commands/frontmatter.ts +++ b/src/commands/frontmatter.ts @@ -3,8 +3,9 @@ * * Subcommands: * gbrain frontmatter validate [--json] [--fix] [--dry-run] - * Validate one file or recursively a directory. --fix writes .bak then - * rewrites in place. --dry-run previews without writing. + * Validate one file or recursively a directory. --fix writes centralized + * backups under ~/.gbrain/backups/frontmatter/... then rewrites in place. + * --dry-run previews without writing. * * gbrain frontmatter audit [--source ] [--json] * Read-only scan across all registered sources (or one with --source). @@ -14,7 +15,7 @@ * validate. Pass an explicit path to validate a non-source-registered tree. */ -import { readFileSync, writeFileSync, existsSync, lstatSync, readdirSync, copyFileSync } from 'fs'; +import { readFileSync, writeFileSync, existsSync, lstatSync, readdirSync } from 'fs'; import { join, relative, resolve } from 'path'; import type { BrainEngine } from '../core/engine.ts'; import { loadConfig, toEngineConfig } from '../core/config.ts'; @@ -22,6 +23,8 @@ import { createEngine } from '../core/engine-factory.ts'; import { parseMarkdown, type ParseValidationCode } from '../core/markdown.ts'; import { autoFixFrontmatter, + createFrontmatterBackup, + makeFrontmatterBackupRunId, scanBrainSources, type AuditReport, type AuditFix, @@ -79,7 +82,7 @@ function printHelp() { Usage: gbrain frontmatter validate [--json] [--fix] [--dry-run] - gbrain frontmatter generate [--fix] [--dry-run] [--json] + gbrain frontmatter generate [--fix] [--dry-run] [--json] [--include-catch-all] gbrain frontmatter audit [--source ] [--json] gbrain frontmatter install-hook [--source ] [--force] [--uninstall] @@ -90,9 +93,10 @@ validate NULL_BYTES, NESTED_QUOTES, EMPTY_FRONTMATTER --fix Auto-repair the fixable subset (NULL_BYTES, MISSING_CLOSE, - NESTED_QUOTES, SLUG_MISMATCH). Writes .bak before any - in-place rewrite. .bak is the safety contract; works for both - git and non-git brain repos. + NESTED_QUOTES, SLUG_MISMATCH). Writes a backup under + ~/.gbrain/backups/frontmatter/... before any in-place rewrite. + Backups work for both git and non-git brain repos without + littering the source tree. --dry-run Preview --fix without writing. --json Emit a JSON envelope on stdout. @@ -102,7 +106,10 @@ generate the filesystem path and file content. Zero LLM calls, fully deterministic. Without --fix: dry-run preview showing what would be generated. - With --fix: writes frontmatter to files (with .bak safety backups). + With --fix: writes frontmatter to files with centralized safety backups. + Unknown/catch-all files are skipped by default so GBrain does not stamp + meaningless "type: note" metadata onto arbitrary workspace documents. Pass + --include-catch-all to opt into the legacy catch-all note behavior. Rules are defined in src/core/frontmatter-inference.ts DIRECTORY_RULES. Add new directory conventions by adding rules to the table. @@ -112,9 +119,12 @@ generate gbrain frontmatter generate /path/to/brain --fix # write all gbrain frontmatter generate /path/to/brain/people/ --fix # just people/ - --fix Write generated frontmatter to files (.bak safety backups). + --fix Write generated frontmatter to files with centralized backups. --dry-run Preview without writing (default when --fix is omitted). --json Emit JSON output. + --include-catch-all + Also write the default catch-all rule ("type: note") for paths + that do not match a more specific directory rule. audit Read-only scan across all registered sources (or one with --source ). @@ -140,6 +150,7 @@ interface FileValidation { path: string; errors: { code: ParseValidationCode; message: string; line?: number }[]; fixesApplied?: AuditFix[]; + backupPath?: string; } async function runValidate(rest: string[]): Promise { @@ -166,6 +177,7 @@ async function runValidate(rest: string[]): Promise { const files = collectFiles(resolved); const results: FileValidation[] = []; + const backupRunId = makeFrontmatterBackupRunId(); for (const file of files) { const content = readFileSync(file, 'utf8'); @@ -181,7 +193,7 @@ async function runValidate(rest: string[]): Promise { const { content: fixed, fixes } = autoFixFrontmatter(content, { filePath: file }); result.fixesApplied = fixes; if (fixes.length > 0 && !flags.dryRun) { - copyFileSync(file, file + '.bak'); + result.backupPath = createFrontmatterBackup(file, { sourcePath: resolved, runId: backupRunId }); writeFileSync(file, fixed, 'utf8'); } } @@ -225,7 +237,7 @@ async function runValidate(rest: string[]): Promise { } } if (flags.fix && !flags.dryRun) { - console.log(`\nWrote .bak backups for ${filesFixed} file(s).`); + console.log(`\nWrote centralized backups for ${filesFixed} file(s) under ~/.gbrain/backups/frontmatter/.`); } } } @@ -299,7 +311,10 @@ function printAuditHumanReport(report: AuditReport): void { console.log('No registered sources to audit. Run `gbrain sources list` to inspect.'); return; } - console.log(`Frontmatter audit — ${report.total} issue(s) across ${report.per_source.length} source(s) (scanned at ${report.scanned_at})`); + console.log(`Frontmatter audit — ${report.total} malformed issue(s) across ${report.per_source.length} source(s) (scanned at ${report.scanned_at})`); + if (report.ignored_missing_open) { + console.log(`Missing frontmatter ignored: ${report.ignored_missing_open} file(s). Use \`frontmatter validate\` for strict per-file checks or \`frontmatter generate\` to add meaningful metadata.`); + } for (const src of report.per_source) { console.log(`\n[${src.source_id}] ${src.source_path}`); if (src.total === 0) { @@ -332,6 +347,7 @@ async function runGenerate(args: string[]): Promise { const doFix = args.includes('--fix'); const dryRun = args.includes('--dry-run'); const jsonOut = args.includes('--json'); + const includeCatchAll = args.includes('--include-catch-all') || args.includes('--allow-catch-all'); if (!targetPath) { console.error('error: gbrain frontmatter generate requires a argument'); @@ -342,7 +358,7 @@ async function runGenerate(args: string[]): Promise { const { inferFrontmatter, serializeFrontmatter } = await import('../core/frontmatter-inference.ts'); const { resolve, relative, join, basename } = await import('path'); - const { readFileSync, writeFileSync, copyFileSync, statSync, readdirSync, lstatSync } = await import('fs'); + const { readFileSync, writeFileSync, statSync, lstatSync } = await import('fs'); const rootPath = resolve(targetPath); const isDir = statSync(rootPath).isDirectory(); @@ -376,12 +392,14 @@ async function runGenerate(args: string[]): Promise { const results: GenerateResult[] = []; let scanned = 0; let skipped = 0; + let skippedCatchAll = 0; let generated = 0; let written = 0; + const backupRunId = makeFrontmatterBackupRunId(); function processFile(absPath: string, relPath: string) { scanned++; - if (!absPath.endsWith('.md')) return; + if (!isSyncable(relPath, { strategy: 'markdown' })) return; // Skip symlinks try { if (lstatSync(absPath).isSymbolicLink()) return; } catch { return; } @@ -394,6 +412,10 @@ async function runGenerate(args: string[]): Promise { skipped++; return; } + if (!includeCatchAll && inferred.matchedRule === '(default)') { + skippedCatchAll++; + return; + } generated++; results.push({ @@ -407,32 +429,17 @@ async function runGenerate(args: string[]): Promise { if (doFix && !dryRun) { const fm = serializeFrontmatter(inferred); const newContent = fm + '\n' + content; - // Safety: write .bak first - copyFileSync(absPath, absPath + '.bak'); + // Safety: write a centralized backup first. + createFrontmatterBackup(absPath, { sourcePath: brainRoot, runId: backupRunId }); writeFileSync(absPath, newContent, 'utf-8'); written++; } } - function walkDir(dir: string, rootForRel: string) { - let entries: string[]; - try { entries = readdirSync(dir); } catch { return; } - for (const entry of entries) { - if (entry === '.git' || entry === 'node_modules' || entry === '.obsidian') continue; - const abs = join(dir, entry); - try { - const stat = statSync(abs); - if (stat.isDirectory()) { - walkDir(abs, rootForRel); - } else if (stat.isFile() && entry.endsWith('.md')) { - processFile(abs, relative(rootForRel, abs)); - } - } catch { /* skip unreadable */ } - } - } - if (isDir) { - walkDir(rootPath, brainRoot); + for (const absPath of collectFiles(rootPath)) { + processFile(absPath, relative(brainRoot, absPath)); + } } else { const relPath = relative(brainRoot, rootPath) || basename(rootPath); processFile(rootPath, relPath); @@ -443,6 +450,7 @@ async function runGenerate(args: string[]): Promise { console.log(JSON.stringify({ scanned, skipped, + skippedCatchAll, generated, written, dryRun: !doFix || dryRun, @@ -457,9 +465,12 @@ async function runGenerate(args: string[]): Promise { console.log(`\nFrontmatter generation (${mode})`); console.log(` Scanned: ${scanned} files`); console.log(` Already have frontmatter: ${skipped}`); + if (skippedCatchAll > 0) { + console.log(` Skipped catch-all/unknown: ${skippedCatchAll} (pass --include-catch-all to write type: note)`); + } console.log(` Would generate: ${generated}`); if (doFix && !dryRun) { - console.log(` Written: ${written} (with .bak backups)`); + console.log(` Written: ${written} (with centralized backups)`); } // Show sample by type diff --git a/src/commands/migrations/v0_22_4.ts b/src/commands/migrations/v0_22_4.ts index 0291ac946..e05b29f19 100644 --- a/src/commands/migrations/v0_22_4.ts +++ b/src/commands/migrations/v0_22_4.ts @@ -10,7 +10,9 @@ * one entry per source-with-issues to ~/.gbrain/migrations/pending-host-work.jsonl. * It NEVER mutates brain content. The agent reads skills/migrations/v0.22.4.md * after upgrade and runs `gbrain frontmatter validate --fix` with - * explicit user consent. + * explicit user consent. Missing frontmatter is not queued by default; plain + * Markdown imports are valid, and generated frontmatter should add real signal + * rather than blanket `type: note`. * * Phases (all idempotent): * A. Schema — no-op (no DB changes in v0.22.4). @@ -212,8 +214,9 @@ export const v0_22_4: Migration = { 'pre-commit hook helper, and a new frontmatter-guard skill. The migration is audit-only ' + '(it never mutates your brain) — it scans every registered source, writes a per-source ' + 'report to ~/.gbrain/migrations/v0.22.4-audit.json, and queues a TODO with the exact fix ' + - 'command. Run `gbrain frontmatter validate --fix` to repair (creates .bak ' + - 'backups). Resolves all 7 check-resolvable warnings on master; ships frontmatter-guard.', + 'command for malformed frontmatter only. Missing frontmatter is treated as optional metadata ' + + 'coverage for broad document sources. Run `gbrain frontmatter validate --fix` ' + + 'to repair (creates centralized backups under ~/.gbrain/backups/frontmatter). Ships frontmatter-guard.', }, orchestrator, }; diff --git a/src/core/brain-writer.ts b/src/core/brain-writer.ts index 733ca4661..6a25eb510 100644 --- a/src/core/brain-writer.ts +++ b/src/core/brain-writer.ts @@ -9,15 +9,19 @@ * validation stack. * * Path-guard contract: writeBrainPage refuses to write outside the source - * path. .bak backups are the safety contract (works for both git and non-git - * brain repos; the existing src/core/dry-fix.ts:getWorkingTreeStatus rejects - * non-git repos as unsafe, which is the wrong shape for brain rewrites). + * path. Pre-write backups are the safety contract (works for both git and + * non-git brain repos; the existing src/core/dry-fix.ts:getWorkingTreeStatus + * rejects non-git repos as unsafe, which is the wrong shape for brain + * rewrites). Backups live under ~/.gbrain/backups/frontmatter/... instead of + * beside source files so bulk repair never litters the user's workspace. */ -import { existsSync, readFileSync, readdirSync, statSync, copyFileSync, writeFileSync, mkdirSync, lstatSync } from 'fs'; -import { join, relative, resolve, dirname } from 'path'; +import { createHash } from 'crypto'; +import { existsSync, readFileSync, readdirSync, copyFileSync, writeFileSync, mkdirSync, lstatSync } from 'fs'; +import { join, relative, resolve, dirname, basename, isAbsolute } from 'path'; import type { BrainEngine } from './engine.ts'; import type { ProgressReporter } from './progress.ts'; +import { gbrainPath } from './config.ts'; import { parseMarkdown, type ParseValidationCode, @@ -38,6 +42,7 @@ export interface PerSourceReport { total: number; errors_by_code: Partial>; sample: { path: string; codes: ParseValidationCode[] }[]; + ignoredMissingOpen: number; } export interface AuditReport { @@ -46,10 +51,45 @@ export interface AuditReport { errors_by_code: Partial>; per_source: PerSourceReport[]; scanned_at: string; + ignored_missing_open?: number; } const SAMPLE_PER_SOURCE = 20; +// --------------------------------------------------------------------------- +// Frontmatter backups +// --------------------------------------------------------------------------- + +export function makeFrontmatterBackupRunId(date = new Date()): string { + return date.toISOString().replace(/[:.]/g, '-'); +} + +export interface FrontmatterBackupOpts { + sourcePath?: string; + backupRoot?: string; + runId?: string; +} + +function sourceKey(sourcePath: string): string { + return createHash('sha256').update(resolve(sourcePath)).digest('hex').slice(0, 12); +} + +export function defaultFrontmatterBackupRoot(runId = makeFrontmatterBackupRunId()): string { + return gbrainPath('backups', 'frontmatter', runId); +} + +export function createFrontmatterBackup(filePath: string, opts: FrontmatterBackupOpts = {}): string { + const resolvedFile = resolve(filePath); + const resolvedSource = resolve(opts.sourcePath ?? dirname(resolvedFile)); + const rel = relative(resolvedSource, resolvedFile); + const safeRel = rel && !rel.startsWith('..') && !isAbsolute(rel) ? rel : basename(resolvedFile); + const root = opts.backupRoot ?? defaultFrontmatterBackupRoot(opts.runId); + const backupPath = join(root, sourceKey(resolvedSource), safeRel + '.bak'); + mkdirSync(dirname(backupPath), { recursive: true }); + copyFileSync(resolvedFile, backupPath); + return backupPath; +} + // --------------------------------------------------------------------------- // autoFixFrontmatter // --------------------------------------------------------------------------- @@ -178,7 +218,7 @@ export function autoFixFrontmatter( } // --------------------------------------------------------------------------- -// writeBrainPage — path-guarded write with .bak backup +// writeBrainPage — path-guarded write with centralized backup // --------------------------------------------------------------------------- export class BrainWriterError extends Error { @@ -193,15 +233,16 @@ export class BrainWriterError extends Error { } /** - * Path-guarded brain page writer. Always writes `.bak` before any - * in-place mutation (the contract that replaces git-tree-clean for non-git - * brain repos). Throws BrainWriterError if filePath is not under sourcePath. + * Path-guarded brain page writer. Always writes a backup under + * ~/.gbrain/backups/frontmatter/... before any in-place mutation (the contract + * that replaces git-tree-clean for non-git brain repos). Throws + * BrainWriterError if filePath is not under sourcePath. */ export function writeBrainPage( filePath: string, content: string, - opts: { sourcePath: string; autoFix?: boolean }, -): { fixes: AuditFix[] } { + opts: { sourcePath: string; autoFix?: boolean; backupRoot?: string; backupRunId?: string }, +): { fixes: AuditFix[]; backupPath?: string } { const resolvedSource = resolve(opts.sourcePath); const resolvedTarget = resolve(filePath); if (resolvedTarget !== resolvedSource && !resolvedTarget.startsWith(resolvedSource + '/')) { @@ -220,13 +261,18 @@ export function writeBrainPage( fixes = result.fixes; } + let backupPath: string | undefined; if (existsSync(filePath)) { - copyFileSync(filePath, filePath + '.bak'); + backupPath = createFrontmatterBackup(filePath, { + sourcePath: opts.sourcePath, + backupRoot: opts.backupRoot, + runId: opts.backupRunId, + }); } else { mkdirSync(dirname(filePath), { recursive: true }); } writeFileSync(filePath, toWrite, 'utf8'); - return { fixes }; + return { fixes, backupPath }; } // --------------------------------------------------------------------------- @@ -242,6 +288,10 @@ export interface ScanOpts { /** Limit scan to one source. When omitted, all registered sources with a * local_path are scanned. */ sourceId?: string; + /** Missing frontmatter is optional metadata coverage for broad document + * sources. Set true for curated page repos that require every file to carry + * YAML frontmatter. */ + strictMissingOpen?: boolean; onProgress?: ProgressReporter; signal?: AbortSignal; } @@ -254,6 +304,7 @@ export async function scanBrainSources( const totals: Partial> = {}; const perSource: PerSourceReport[] = []; let grandTotal = 0; + let ignoredMissingOpen = 0; for (const src of sources) { if (opts.signal?.aborted) break; @@ -267,12 +318,14 @@ export async function scanBrainSources( total: 0, errors_by_code: {}, sample: [], + ignoredMissingOpen: 0, }); continue; } const report = scanOneSource(src.id, src.local_path, opts); perSource.push(report); grandTotal += report.total; + ignoredMissingOpen += report.ignoredMissingOpen; for (const [code, n] of Object.entries(report.errors_by_code)) { const k = code as ParseValidationCode; totals[k] = (totals[k] ?? 0) + (n as number); @@ -285,6 +338,7 @@ export async function scanBrainSources( errors_by_code: totals, per_source: perSource, scanned_at: new Date().toISOString(), + ignored_missing_open: ignoredMissingOpen || undefined, }; } @@ -298,6 +352,7 @@ function scanOneSource( const rootResolved = resolve(sourcePath); let scanned = 0; let total = 0; + let ignoredMissingOpen = 0; walkDir(rootResolved, (absPath) => { if (opts.signal?.aborted) return false; @@ -312,7 +367,12 @@ function scanOneSource( } const expectedSlug = slugifyPath(relPath); const parsed = parseMarkdown(content, relPath, { validate: true, expectedSlug }); - const errs = parsed.errors ?? []; + const errs = (parsed.errors ?? []).filter((e) => { + if (e.code !== 'MISSING_OPEN') return true; + if (opts.strictMissingOpen) return true; + ignoredMissingOpen++; + return false; + }); if (errs.length > 0) { total += errs.length; const codes: ParseValidationCode[] = []; @@ -340,6 +400,7 @@ function scanOneSource( total, errors_by_code: errorsByCode, sample, + ignoredMissingOpen, }; } diff --git a/src/core/frontmatter-inference.ts b/src/core/frontmatter-inference.ts index 691fbe83c..41f13aeff 100644 --- a/src/core/frontmatter-inference.ts +++ b/src/core/frontmatter-inference.ts @@ -28,7 +28,8 @@ * - **Deterministic.** Same file always produces the same frontmatter. No LLM calls, no network. * - **Extensible via rules.** The `DIRECTORY_RULES` table maps path patterns to type + source + tags. * Adding a new directory convention = adding one rule. - * - **Safe.** `.bak` files on write, `--dry-run` by default in CLI, idempotent. + * - **Safe.** centralized backups on write, `--dry-run` by default in CLI, + * idempotent. * * ## How it fits in the pipeline * @@ -177,6 +178,22 @@ export const DIRECTORY_RULES: DirectoryRule[] = [ titleStrategy: 'filename', }, + // Documentation/workspace repos. These make generated frontmatter useful + // instead of flattening everything to the catch-all `note` type. + { pathPrefix: 'docs/runbooks/', type: 'guide', source: 'docs', tags: ['runbook'], titleStrategy: 'heading' }, + { pathPrefix: 'runbooks/', type: 'guide', source: 'docs', tags: ['runbook'], titleStrategy: 'heading' }, + { pathPrefix: 'docs/guides/', type: 'guide', source: 'docs', tags: ['guide'], titleStrategy: 'heading' }, + { pathPrefix: 'docs/policies/', type: 'guide', source: 'docs', tags: ['policy'], titleStrategy: 'heading' }, + { pathPrefix: 'docs/projects/', type: 'project', source: 'docs', tags: ['project'], titleStrategy: 'heading' }, + { pathPrefix: 'docs/audits/', type: 'analysis', source: 'docs', tags: ['audit'], titleStrategy: 'heading' }, + { pathPrefix: 'docs/research/', type: 'analysis', source: 'docs', tags: ['research'], titleStrategy: 'heading' }, + { pathPrefix: 'docs/evaos/', type: 'architecture', source: 'docs', tags: ['evaos'], titleStrategy: 'heading' }, + { pathPrefix: 'docs/architecture/', type: 'architecture', source: 'docs', titleStrategy: 'heading' }, + { pathPrefix: 'docs/providers/', type: 'source', source: 'docs', tags: ['provider'], titleStrategy: 'heading' }, + { pathPrefix: 'security/', type: 'guide', source: 'docs', tags: ['security'], titleStrategy: 'heading' }, + { pathPrefix: 'support/', type: 'source', source: 'docs', tags: ['support'], titleStrategy: 'heading' }, + { pathPrefix: 'notes/', type: 'note', titleStrategy: 'heading' }, + // Personal sections { pathPrefix: 'personal/therapy/', @@ -233,7 +250,9 @@ export const DIRECTORY_RULES: DirectoryRule[] = [ { pathPrefix: 'meetings/', type: 'meeting', titleStrategy: 'heading', datePattern: 'filename' }, { pathPrefix: 'media/', type: 'media', titleStrategy: 'heading' }, - // Catch-all for any remaining files + // Catch-all for any remaining files. The CLI does not write this rule by + // default; users must pass --include-catch-all so `type: note` means an + // intentional fallback instead of "the script did not know what this was". { pathPrefix: '', type: 'note', titleStrategy: 'heading' }, ]; diff --git a/test/frontmatter-cli.test.ts b/test/frontmatter-cli.test.ts index 22968c6cf..598f4aab7 100644 --- a/test/frontmatter-cli.test.ts +++ b/test/frontmatter-cli.test.ts @@ -7,10 +7,11 @@ import { spawnSync } from 'child_process'; const fence = '---'; const CLI = ['run', 'src/cli.ts', 'frontmatter']; -function runCli(args: string[]): { stdout: string; stderr: string; code: number } { - const result = spawnSync('bun', [...CLI, ...args], { +function runCli(args: string[], env: Record = {}): { stdout: string; stderr: string; code: number } { + const result = spawnSync(process.execPath, [...CLI, ...args], { encoding: 'utf8', cwd: process.cwd(), + env: { ...process.env, ...env }, }); return { stdout: result.stdout ?? '', @@ -75,24 +76,32 @@ describe('gbrain frontmatter CLI (B4)', () => { expect(code).toBe(0); }); - test('validate --fix writes .bak and rewrites in place', () => { + test('validate --fix writes centralized backup and rewrites in place', () => { const f = join(tmp, 'broken.md'); + const gbrainHome = join(tmp, 'home'); const original = `${fence}\ntype: concept\ntitle: "P "I" L"\n${fence}\n\nbody`; writeFileSync(f, original); - const { code } = runCli(['validate', f, '--fix']); + const { stdout, code } = runCli(['validate', f, '--fix'], { GBRAIN_HOME: gbrainHome }); expect(code).toBe(0); - expect(existsSync(f + '.bak')).toBe(true); - expect(readFileSync(f + '.bak', 'utf8')).toBe(original); + expect(existsSync(f + '.bak')).toBe(false); + expect(stdout).toContain('centralized backups'); + const backupDir = join(gbrainHome, '.gbrain', 'backups', 'frontmatter'); + const backup = spawnSync('find', [backupDir, '-type', 'f', '-name', 'broken.md.bak'], { encoding: 'utf8' }); + const backupPath = backup.stdout.trim().split('\n').filter(Boolean)[0]; + expect(backupPath).toBeTruthy(); + expect(readFileSync(backupPath, 'utf8')).toBe(original); expect(readFileSync(f, 'utf8')).toMatch(/^title: '.*'\s*$/m); }); test('validate --fix succeeds on a non-git path (no dirty-tree guard)', () => { // tmp is not a git repo; --fix must still work. const f = join(tmp, 'broken.md'); + const gbrainHome = join(tmp, 'home'); writeFileSync(f, `${fence}\ntype: concept\ntitle: "A "B" C"\n${fence}\n\nbody`); - const { code } = runCli(['validate', f, '--fix']); + const { code } = runCli(['validate', f, '--fix'], { GBRAIN_HOME: gbrainHome }); expect(code).toBe(0); - expect(existsSync(f + '.bak')).toBe(true); + expect(existsSync(f + '.bak')).toBe(false); + expect(existsSync(join(gbrainHome, '.gbrain', 'backups', 'frontmatter'))).toBe(true); }); test('validate scans a directory recursively, skips non-.md files', () => { @@ -107,6 +116,27 @@ describe('gbrain frontmatter CLI (B4)', () => { expect(env.total_files).toBe(2); }); + test('generate --fix skips catch-all note writes unless explicitly included', () => { + const gbrainHome = join(tmp, 'home'); + writeFileSync(join(tmp, 'random.md'), '# Random\n\nbody'); + writeFileSync(join(tmp, 'notes.md'), '# Also Random\n\nbody'); + + const skipped = runCli(['generate', tmp, '--fix', '--json'], { GBRAIN_HOME: gbrainHome }); + expect(skipped.code).toBe(0); + const skippedEnv = JSON.parse(skipped.stdout); + expect(skippedEnv.generated).toBe(0); + expect(skippedEnv.skippedCatchAll).toBe(2); + expect(readFileSync(join(tmp, 'random.md'), 'utf8')).toBe('# Random\n\nbody'); + + const included = runCli(['generate', tmp, '--fix', '--json', '--include-catch-all'], { GBRAIN_HOME: gbrainHome }); + expect(included.code).toBe(0); + const includedEnv = JSON.parse(included.stdout); + expect(includedEnv.generated).toBe(2); + expect(readFileSync(join(tmp, 'random.md'), 'utf8')).toContain('type: note'); + expect(existsSync(join(gbrainHome, '.gbrain', 'backups', 'frontmatter'))).toBe(true); + expect(existsSync(join(tmp, 'random.md.bak'))).toBe(false); + }); + test('validate missing path errors clearly', () => { const { stderr, code } = runCli(['validate', join(tmp, 'does-not-exist.md')]); expect(code).toBe(1); diff --git a/test/frontmatter-inference.test.ts b/test/frontmatter-inference.test.ts index 0a4f14e92..9d73df1f4 100644 --- a/test/frontmatter-inference.test.ts +++ b/test/frontmatter-inference.test.ts @@ -176,6 +176,25 @@ describe('inferFrontmatter', () => { expect(result.source).toBe('calendar'); }); + test('docs/runbooks: infers guide instead of catch-all note', () => { + const result = inferFrontmatter( + 'docs/runbooks/cron-management-runbook.md', + '# Cron Management Runbook\n\nUse this when changing schedules.', + ); + expect(result.type).toBe('guide'); + expect(result.tags).toContain('runbook'); + expect(result.title).toBe('Cron Management Runbook'); + }); + + test('docs/projects: infers project type', () => { + const result = inferFrontmatter( + 'docs/projects/eva-brain.md', + '# Eva Brain\n\nProject notes.', + ); + expect(result.type).toBe('project'); + expect(result.tags).toContain('project'); + }); + test('companies/ directory: type company', () => { const result = inferFrontmatter( 'companies/stripe.md', @@ -185,7 +204,7 @@ describe('inferFrontmatter', () => { expect(result.title).toBe('Stripe'); }); - test('unknown directory: defaults to note type with heading title', () => { + test('unknown directory: catch-all remains note when explicitly used by callers', () => { const result = inferFrontmatter( 'random/some-file.md', '# My Random Notes\n\nStuff here', diff --git a/test/migrations-v0_22_4.test.ts b/test/migrations-v0_22_4.test.ts index 81aca6f55..86b6893e8 100644 --- a/test/migrations-v0_22_4.test.ts +++ b/test/migrations-v0_22_4.test.ts @@ -86,6 +86,7 @@ describe('v0.22.4 migration (B11)', () => { total: 8, errors_by_code: { NESTED_QUOTES: 8 }, sample: [], + ignoredMissingOpen: 0, }, { source_id: 'archive', @@ -93,6 +94,7 @@ describe('v0.22.4 migration (B11)', () => { total: 4, errors_by_code: { NULL_BYTES: 4 }, sample: [], + ignoredMissingOpen: 0, }, { source_id: 'clean-source', @@ -100,6 +102,7 @@ describe('v0.22.4 migration (B11)', () => { total: 0, errors_by_code: {}, sample: [], + ignoredMissingOpen: 0, }, ], scanned_at: new Date().toISOString(),