mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
fix(frontmatter): centralize --fix backups under ~/.gbrain/backups/
`gbrain frontmatter validate --fix` and `gbrain frontmatter generate --fix` wrote `<file>.bak` siblings into the source tree. Users running gbrain over a brain repo found .bak files scattered through people/, companies/, etc. that broke gitignore expectations and showed up in `git status` after every fix pass. Backups now land under `~/.gbrain/backups/frontmatter/<run-id>/<rel>.bak` with an iso-week-sorted run-id so a multi-fix session keeps the same parent directory. Backup directory + per-file structure mirrored from the original file's relative path. The .bak safety contract is intact for both git and non-git brain repos. Also adds `--include-catch-all` opt-in to `frontmatter generate` so the default catch-all rule (`type: note`) is no longer applied to arbitrary workspace documents that happen to live under a brain root. Closes #902. Cherry-picked from PR #903. Co-Authored-By: 100yenadmin <100yenadmin@users.noreply.github.com>
This commit is contained in:
+46
-35
@@ -3,8 +3,9 @@
|
||||
*
|
||||
* Subcommands:
|
||||
* gbrain frontmatter validate <path> [--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 <id>] [--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 <path> [--json] [--fix] [--dry-run]
|
||||
gbrain frontmatter generate <path> [--fix] [--dry-run] [--json]
|
||||
gbrain frontmatter generate <path> [--fix] [--dry-run] [--json] [--include-catch-all]
|
||||
gbrain frontmatter audit [--source <id>] [--json]
|
||||
gbrain frontmatter install-hook [--source <id>] [--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 <file>.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 <id>).
|
||||
@@ -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<void> {
|
||||
@@ -166,6 +177,7 @@ async function runValidate(rest: string[]): Promise<void> {
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
}
|
||||
}
|
||||
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<void> {
|
||||
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 <path> argument');
|
||||
@@ -342,7 +358,7 @@ async function runGenerate(args: string[]): Promise<void> {
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
skipped++;
|
||||
return;
|
||||
}
|
||||
if (!includeCatchAll && inferred.matchedRule === '(default)') {
|
||||
skippedCatchAll++;
|
||||
return;
|
||||
}
|
||||
|
||||
generated++;
|
||||
results.push({
|
||||
@@ -407,32 +429,17 @@ async function runGenerate(args: string[]): Promise<void> {
|
||||
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<void> {
|
||||
console.log(JSON.stringify({
|
||||
scanned,
|
||||
skipped,
|
||||
skippedCatchAll,
|
||||
generated,
|
||||
written,
|
||||
dryRun: !doFix || dryRun,
|
||||
@@ -457,9 +465,12 @@ async function runGenerate(args: string[]): Promise<void> {
|
||||
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
|
||||
|
||||
@@ -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 <source-path> --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 <source-path> --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 <source-path> --fix` ' +
|
||||
'to repair (creates centralized backups under ~/.gbrain/backups/frontmatter). Ships frontmatter-guard.',
|
||||
},
|
||||
orchestrator,
|
||||
};
|
||||
|
||||
+75
-14
@@ -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<Record<ParseValidationCode, number>>;
|
||||
sample: { path: string; codes: ParseValidationCode[] }[];
|
||||
ignoredMissingOpen: number;
|
||||
}
|
||||
|
||||
export interface AuditReport {
|
||||
@@ -46,10 +51,45 @@ export interface AuditReport {
|
||||
errors_by_code: Partial<Record<ParseValidationCode, number>>;
|
||||
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 `<filePath>.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<Record<ParseValidationCode, number>> = {};
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
|
||||
@@ -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<string, string> = {}): { 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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user