Files
gbrain/test/brain-writer.test.ts
T
3de06b6c29 v0.38.2.0 fix(doctor): bounded frontmatter scan + partial-state surfacing (supersedes #1287) (#1297)
* fix(frontmatter): prune vendor dirs at descent + bounded wall-clock with partial-state surfacing

Two production-grade fixes for the v0.38.2.0 wave (supersedes PR #1287).

Root cause Fix 1 (the bug that hung gbrain doctor on 216K-page brains): both
brain-writer.ts:walkDir and frontmatter.ts:collectFiles recursed into every
subdirectory without calling pruneDir, the canonical descent-time pruner
used by sync/extract/transcript-discovery since v0.35.5.0. On brains that
double as code workspaces, the walkers stat'd hundreds of thousands of
entries under node_modules / .git / .obsidian / *.raw / ops that isSyncable
filtered out at the leaf — paying the IO cost for nothing. Wiring pruneDir
at descent (with the v0.37.7.0 #1169 submodule-gitfile check) eliminates
the bulk of the wall-clock pain.

Fix 2 (codex outside-voice C1): AbortSignal.timeout cannot interrupt the
synchronous walker — readdirSync / lstatSync / readFileSync block the event
loop, so timer callbacks never fire mid-walk. The load-bearing wall-clock
bound is now a deadline check inside scanOneSource's visit callback
(Date.now() > opts.deadline). AbortSignal still works at source boundaries.

Shape changes (codex C2 + C4):
- ScanOpts: + deadline?: number, + dbPageCountForSource hook, + visitDir test seam
- PerSourceReport: + status: 'scanned' | 'partial' | 'skipped', + files_scanned, + db_page_count
- AuditReport: + partial: boolean, + aborted_at_source: string | null
- ok = grandTotal === 0 && !partial (a clean prefix from a timed-out scan
  no longer falsely reports clean)

walkDir + collectFiles now exported with an optional visitDir callback for
the regression suite. Production callers don't pass it.

Tests:
- test/brain-writer-walk-prune.test.ts (new, 12 cases): visitDir-based
  descent-time pruning assertions for both walkers. Pins the property
  output-based tests can't catch (isSyncable rejects vendor files at
  the leaf — so a test checking only output passes under the original bug).
- test/brain-writer-partial-scan.test.ts (new, 5 cases): deadline + partial
  state + ok-after-abort + numerator/denominator coverage. Uses deadline,
  NOT AbortSignal, since codex C1 proved abort can't interrupt sync.
- test/brain-writer.test.ts: existing "abort mid-scan" test refit to the
  new partial-state contract (per_source has 'skipped' entries instead of
  being empty — gives doctor visibility into which sources weren't checked).
- test/migrations-v0_22_4.test.ts: AuditReport fixture extended with the
  new required fields.

Plan + cross-model review: ~/.claude/plans/system-instruction-you-are-working-hidden-lollipop.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(doctor): wire deadline + partial-state into frontmatter_integrity check

Adopts the v0.38.2.0 ScanBrainSources surface in doctor's frontmatter_integrity
check.

- AbortSignal.timeout(fmTimeoutMs) for between-source bound.
- deadline = Date.now() + fmTimeoutMs (the load-bearing mid-walk bound —
  codex C1 caught that AbortSignal alone can't fire inside the sync walker).
- GBRAIN_DOCTOR_FM_TIMEOUT_MS env override (default 30000ms; invalid values
  fall back to default rather than crash).
- Per-source DB denominator via SELECT COUNT(*) FROM pages WHERE source_id = $1
  AND deleted_at IS NULL (codex C3: deleted_at filter so soft-deleted pages
  don't inflate the count).
- Honest partial-render: "PARTIAL — scanned ~N files (source has ~M pages in
  DB), K issue(s) so far" instead of "scanned ~N of M pages" (codex C3 — the
  two populations are overlapping but not identical sets).
- "NOT SCANNED (timeout — run gbrain frontmatter validate <id>)" per skipped
  source so the user knows which sources didn't get checked.
- Catch block simplified to "unexpected error only" (codex D4 — the
  AbortError special case from PR #1287 was unreachable in a sync walker).

Tests: test/doctor-frontmatter-partial.test.ts (new, 11 cases) — structural
source-grep pins on every load-bearing render string plus the simplified-
catch contract. Behavioral coverage is deferred to the heavy script
(tests/heavy/frontmatter_scan_wallclock.sh, T6) because runDoctor calls
process.exit unconditionally and can't be driven from bun:test directly;
refactoring runDoctor to return rather than exit is a separate TODO.

Plan + cross-model review: ~/.claude/plans/system-instruction-you-are-working-hidden-lollipop.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: v0.38.2.0 release notes, Phase 2 design sketch, heavy wall-clock smoke

- CHANGELOG.md: ELI10-lead-first release entry per CLAUDE.md voice rules.
  Names the user-visible behavior change, the per-source partial render, the
  performance numbers table, the "things to watch" caveats. Credits
  @garrytan-agents for PR #1287's diagnosis.
- VERSION + package.json: 0.37.11.0 -> 0.38.2.0.
- docs/architecture/frontmatter-scan-incremental.md: Phase 2 design sketch
  for DB-backed scan state. Schema, migration shape, writer paths
  (sync-side UPSERT + incremental scan + autopilot cycle phase), doctor
  reader, sequencing concerns, two-phase rollout plan. Starting point for
  the follow-up PR — sub-second steady-state doctor needs incremental
  state, but the schema migration carries its own contract surface
  (forward-reference bootstrap, schema-drift E2E, PGLite-vs-Postgres
  parity) that deserves its own focused PR.
- tests/heavy/frontmatter_scan_wallclock.sh (new, manual / nightly per
  tests/heavy/README.md): seeds a synthetic 60K-file brain (10K real + 50K
  under node_modules/) and asserts gbrain doctor completes in <15s with
  frontmatter_integrity: ok. Codex C7 caught that the original plan's
  1500-file budget was too small to be a meaningful guard — at that scale
  the test passes BEFORE AND AFTER the fix, proving nothing. 60K is the
  minimum that catches the descent-into-vendor-trees regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: adversarial review followups — CLI hint, deadline-vs-await race, between-source breadcrumb

Codex adversarial review caught 4 real bugs in the v0.38.2.0 wave. All four
fixed before ship.

#1 (user-facing): `gbrain frontmatter validate` takes a filesystem PATH, not a
source id. Pre-fix the NOT SCANNED hint pointed users at
`gbrain frontmatter validate src-a` — which would fail with "no such
directory", breaking the very remediation this PR ships to give them. Fix:
render `src.source_path` instead.

#2 (correctness): between sources, `await dbPageCountForSource(src.id)` ran
unchecked. A slow query could blow past the deadline, then scanOneSource was
still called and returned `status='partial'` with `files_scanned=0` —
misleading ("partial scan" when actually zero files were scanned). Fix: add a
post-await deadline re-check; mark source + remainder as 'skipped' if the
budget already burned.

#3 (UX): when the outer-loop deadline check fired BETWEEN sources,
`aborted_at_source` stayed null and the doctor message said "PARTIAL SCAN"
with no source name. Fix: stamp `aborted_at_source` with the source we were
about to start.

#4 (correctness): the COUNT query had no per-call deadline. A wedged
Postgres pool could make a single COUNT hang past the budget and defeat the
wall-clock guarantee. Fix: Promise.race against the remaining deadline; on
timeout, resolve null and the post-await re-check (#2) marks the source
skipped.

Tests: 3 new regression cases in brain-writer-partial-scan.test.ts pinning
the fixed contracts (skipped-vs-partial under slow COUNT, hanging COUNT
within deadline, aborted_at_source before any source starts). 8648 pass /
0 fail across the full suite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(README): refresh production-brain stats — 8.2x pages, 5.6x people, 7.4x companies

Pre-update line (months stale): "17,888 pages, 4,383 people, 723 companies, 21
cron jobs running autonomously, built in 12 days."

Fresh counts from ~/git/brain (the wintermute production brain):
- pages: 17,888 → 146,646 (8.2x)
- people: 4,383 → 24,585 (5.6x)
- companies: 723 → 5,339 (7.4x)
- cron jobs running: 21 → 66 (113 total, 66 enabled per ~/git/wintermute/workspace/ops/cron-snapshot.json)

Dropped "built in 12 days" — at 146K pages the initial-velocity claim is
stale narrative that no longer matches the current scale story.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:29:59 -07:00

317 lines
13 KiB
TypeScript

import { describe, expect, test, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync, mkdirSync, symlinkSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import {
autoFixFrontmatter,
writeBrainPage,
scanBrainSources,
BrainWriterError,
} from '../src/core/brain-writer.ts';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
const fence = '---';
describe('autoFixFrontmatter', () => {
test('strips null bytes', () => {
const input = `${fence}\ntitle: ok\n${fence}\n\nbody\x00drop\x00here`;
const { content, fixes } = autoFixFrontmatter(input);
expect(content.includes('\x00')).toBe(false);
expect(fixes.some(f => f.code === 'NULL_BYTES')).toBe(true);
});
test('inserts closing --- before heading when MISSING_CLOSE', () => {
const input = `${fence}\ntype: concept\ntitle: ok\n# A heading\n\nbody`;
const { content, fixes } = autoFixFrontmatter(input);
expect(fixes.some(f => f.code === 'MISSING_CLOSE')).toBe(true);
// After fix, parsing should find a closing --- before the heading.
const idxClose = content.indexOf('---', 3);
const idxHeading = content.indexOf('# A heading');
expect(idxClose).toBeGreaterThan(0);
expect(idxClose).toBeLessThan(idxHeading);
});
test('rewrites nested-quote title to single-quoted', () => {
const input = `${fence}\ntype: concept\ntitle: "Phil "Nick" Last"\n${fence}\n\nbody`;
const { content, fixes } = autoFixFrontmatter(input);
expect(fixes.some(f => f.code === 'NESTED_QUOTES')).toBe(true);
// Outer wrapper is now single quotes.
expect(content).toMatch(/^title: '.*'\s*$/m);
});
test('removes mismatched slug field', () => {
const input = `${fence}\ntype: concept\ntitle: hi\nslug: wrong-slug\n${fence}\n\nbody`;
const { content, fixes } = autoFixFrontmatter(input, { filePath: 'people/jane-doe.md' });
expect(fixes.some(f => f.code === 'SLUG_MISMATCH')).toBe(true);
expect(content).not.toMatch(/^slug:/m);
});
test('idempotent: running twice produces no diff and no fixes on second pass', () => {
const input = `${fence}\ntype: concept\ntitle: "Phil "Nick" Last"\n${fence}\n\nbody\x00`;
const first = autoFixFrontmatter(input);
const second = autoFixFrontmatter(first.content);
expect(second.content).toBe(first.content);
expect(second.fixes).toEqual([]);
});
test('clean input: no fixes, content unchanged', () => {
const input = `${fence}\ntype: concept\ntitle: ok\n${fence}\n\nbody`;
const { content, fixes } = autoFixFrontmatter(input);
expect(content).toBe(input);
expect(fixes).toEqual([]);
});
// v0.37.9.0 — Step 3a canonical-style normalization for tags/aliases arrays.
// The validator post-v0.37.5.0 no longer flags `tags: ["yc"]` as broken,
// but this pass still rewrites it for consistency with serializeFrontmatter.
test('step 3a: normalizes JSON-style double-quoted tags to single-quoted', () => {
const input = `${fence}\ntype: person\ntags: ["yc", "w2025"]\n${fence}\n\nbody`;
const { content, fixes } = autoFixFrontmatter(input);
expect(fixes.some(f => f.code === 'NESTED_QUOTES')).toBe(true);
expect(content).toContain("tags: ['yc', 'w2025']");
expect(content).not.toContain('tags: ["yc", "w2025"]');
});
test('step 3a: apostrophe in item falls back to double quotes', () => {
const input = `${fence}\ntype: person\ntags: ["Men's Fashion", "yc"]\n${fence}\n\nbody`;
const { content } = autoFixFrontmatter(input);
// Apostrophe item keeps double quotes; clean item uses single.
expect(content).toContain(`tags: ["Men's Fashion", 'yc']`);
});
test('step 3a: empty item handled as empty single-quoted scalar', () => {
const input = `${fence}\ntype: person\ntags: ["", "yc"]\n${fence}\n\nbody`;
const { content } = autoFixFrontmatter(input);
expect(content).toContain(`tags: ['', 'yc']`);
});
test('step 3a: non-allow-listed keys untouched (metrics, scores, etc.)', () => {
const input = `${fence}\ntype: company\nmetrics: ["1", "2", "3"]\nscores: ["a", "b"]\n${fence}\n\nbody`;
const { content, fixes } = autoFixFrontmatter(input);
// Only `tags` and `aliases` are in the allow-list.
expect(content).toContain('metrics: ["1", "2", "3"]');
expect(content).toContain('scores: ["a", "b"]');
expect(fixes.some(f => f.code === 'NESTED_QUOTES')).toBe(false);
});
test('step 3a applies to aliases: key as well as tags:', () => {
const input = `${fence}\ntype: person\naliases: ["Bob", "Robert"]\n${fence}\n\nbody`;
const { content, fixes } = autoFixFrontmatter(input);
expect(fixes.some(f => f.code === 'NESTED_QUOTES')).toBe(true);
expect(content).toContain("aliases: ['Bob', 'Robert']");
});
// codex outside-voice review (D7-2): when step 3a AND step 3 both fire on
// the same file, the audit must record ONE NESTED_QUOTES entry, not two.
// Otherwise frontmatter_integrity counts double-rewrites as two separate
// files needing repair.
test('step 3a + step 3 dedup: one NESTED_QUOTES fix record per file', () => {
const input = `${fence}\ntype: person\ntitle: "Phil "Nick" Last"\ntags: ["yc", "w2025"]\n${fence}\n\nbody`;
const { content, fixes } = autoFixFrontmatter(input);
const nestedQuotesFixes = fixes.filter(f => f.code === 'NESTED_QUOTES');
expect(nestedQuotesFixes.length).toBe(1);
// Both rewrites applied to the content.
expect(content).toContain("tags: ['yc', 'w2025']");
expect(content).toMatch(/^title: '.*'\s*$/m);
});
test('step 3a: idempotent (running twice on already-normalized leaves content unchanged)', () => {
const input = `${fence}\ntype: person\ntags: ['yc', 'w2025']\n${fence}\n\nbody`;
const { content, fixes } = autoFixFrontmatter(input);
expect(content).toBe(input);
expect(fixes).toEqual([]);
});
});
describe('writeBrainPage', () => {
let tmp: string;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), 'brain-writer-'));
});
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
test('happy path: writes file inside source', () => {
const file = join(tmp, 'people', 'jane.md');
const content = `${fence}\ntype: person\ntitle: Jane\n${fence}\n\nhello`;
writeBrainPage(file, content, { sourcePath: tmp });
expect(readFileSync(file, 'utf8')).toBe(content);
});
test('throws BrainWriterError when path is outside sourcePath', () => {
const elsewhere = mkdtempSync(join(tmpdir(), 'brain-writer-other-'));
try {
const offending = join(elsewhere, 'evil.md');
expect(() =>
writeBrainPage(offending, 'content', { sourcePath: tmp }),
).toThrow(BrainWriterError);
} finally {
rmSync(elsewhere, { recursive: true, force: true });
}
});
test('writes a centralized .bak before mutating an existing file', () => {
// v0.36.x #902: backups land under ~/.gbrain/backups/frontmatter/... not
// next to the source file (pre-fix littered the brain tree with .bak
// files that broke gitignore expectations). The returned backupPath is
// the contract — the test asserts both the path shape and that the
// backup faithfully captures the pre-write content.
const file = join(tmp, 'people', 'jane.md');
mkdirSync(join(tmp, 'people'), { recursive: true });
const original = `${fence}\ntype: person\ntitle: Old\n${fence}\n\nold`;
writeFileSync(file, original);
const backupRoot = mkdtempSync(join(tmpdir(), 'gbrain-test-backups-'));
try {
const { backupPath } = writeBrainPage(
file,
`${fence}\ntype: person\ntitle: New\n${fence}\n\nnew`,
{ sourcePath: tmp, backupRoot },
);
expect(backupPath).toBeDefined();
// Centralized — under the test-injected backupRoot, NOT a sibling .bak.
expect(existsSync(file + '.bak')).toBe(false);
expect(backupPath!.startsWith(backupRoot + '/')).toBe(true);
expect(backupPath!.endsWith('.bak')).toBe(true);
expect(existsSync(backupPath!)).toBe(true);
expect(readFileSync(backupPath!, 'utf8')).toBe(original);
} finally {
rmSync(backupRoot, { recursive: true, force: true });
}
});
test('autoFix: true repairs nested quotes before writing', () => {
const file = join(tmp, 'people', 'jane.md');
const broken = `${fence}\ntype: person\ntitle: "Phil "Nick" Last"\n${fence}\n\nbody`;
const { fixes } = writeBrainPage(file, broken, { sourcePath: tmp, autoFix: true });
expect(fixes.some(f => f.code === 'NESTED_QUOTES')).toBe(true);
expect(readFileSync(file, 'utf8')).toMatch(/^title: '.*'\s*$/m);
});
});
describe('scanBrainSources (PGLite)', () => {
let tmp: string;
let engine: PGLiteEngine;
// One PGLite per file — beforeEach wipes data only. PGLite cold-start is
// ~20s on CI; sharing one engine across 6 tests in this block saves ~2 min.
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
tmp = mkdtempSync(join(tmpdir(), 'brain-writer-scan-'));
});
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
async function registerSource(id: string, path: string) {
await engine.executeRaw(
`INSERT INTO sources (id, name, local_path) VALUES ($1, $1, $2)
ON CONFLICT (id) DO UPDATE SET local_path = EXCLUDED.local_path`,
[id, path],
);
}
test('returns ok=true for empty source', async () => {
await registerSource('empty', tmp);
const report = await scanBrainSources(engine);
expect(report.ok).toBe(true);
expect(report.total).toBe(0);
const empty = report.per_source.find(s => s.source_id === 'empty');
expect(empty).toBeDefined();
expect(empty!.total).toBe(0);
});
test('detects errors across multiple sources', async () => {
const srcA = join(tmp, 'a');
const srcB = join(tmp, 'b');
mkdirSync(srcA, { recursive: true });
mkdirSync(srcB, { recursive: true });
writeFileSync(join(srcA, 'p1.md'), `${fence}\ntype: x\ntitle: ok\n${fence}\n\nbody\x00`);
writeFileSync(join(srcB, 'p2.md'), `${fence}\ntype: x\ntitle: "P "I" L"\n${fence}\n\nbody`);
await registerSource('alpha', srcA);
await registerSource('beta', srcB);
const report = await scanBrainSources(engine);
expect(report.ok).toBe(false);
expect(report.total).toBeGreaterThan(0);
const alpha = report.per_source.find(s => s.source_id === 'alpha')!;
const beta = report.per_source.find(s => s.source_id === 'beta')!;
expect(alpha.errors_by_code.NULL_BYTES).toBeGreaterThanOrEqual(1);
expect(beta.errors_by_code.NESTED_QUOTES).toBeGreaterThanOrEqual(1);
});
test('respects sourceId filter', async () => {
const srcA = join(tmp, 'a');
const srcB = join(tmp, 'b');
mkdirSync(srcA, { recursive: true });
mkdirSync(srcB, { recursive: true });
writeFileSync(join(srcA, 'bad.md'), `${fence}\ntype: x\ntitle: ok\n${fence}\n\nbody\x00`);
writeFileSync(join(srcB, 'bad.md'), `${fence}\ntype: x\ntitle: ok\n${fence}\n\nbody\x00`);
await registerSource('alpha', srcA);
await registerSource('beta', srcB);
const onlyA = await scanBrainSources(engine, { sourceId: 'alpha' });
expect(onlyA.per_source.length).toBe(1);
expect(onlyA.per_source[0]!.source_id).toBe('alpha');
});
test('skips registered source with missing path', async () => {
await registerSource('ghost', join(tmp, 'does-not-exist'));
const report = await scanBrainSources(engine);
const ghost = report.per_source.find(s => s.source_id === 'ghost')!;
expect(ghost.total).toBe(0);
});
test('skips symlinks (matches sync no-symlink policy)', async () => {
mkdirSync(join(tmp, 'real'), { recursive: true });
writeFileSync(join(tmp, 'real', 'good.md'), `${fence}\ntype: x\ntitle: ok\n${fence}\n\nbody`);
// Create a symlink loop: tmp/real/loop -> tmp/real
try {
symlinkSync(join(tmp, 'real'), join(tmp, 'real', 'loop'));
} catch {
// Some CI environments forbid symlink creation; skip the assertion.
return;
}
await registerSource('with-symlink', tmp);
const report = await scanBrainSources(engine);
// The walk should complete without infinite-looping; at most one .md
// entry visited (via the real path, not the symlink).
expect(report.per_source[0]!.total).toBe(0);
});
test('AbortSignal before scan: every source marked skipped (v0.38.2.0 partial-state contract)', async () => {
const src = join(tmp, 'big');
mkdirSync(src, { recursive: true });
for (let i = 0; i < 50; i++) {
writeFileSync(join(src, `p${i}.md`), `${fence}\ntype: x\ntitle: t${i}\n${fence}\n\nbody`);
}
await registerSource('big', src);
const ctrl = new AbortController();
ctrl.abort();
const report = await scanBrainSources(engine, { signal: ctrl.signal });
// v0.38.2.0 changed the contract: instead of an empty per_source array
// (which hid the fact that sources weren't checked), the report now
// includes a 'skipped' entry per source the outer loop never reached.
// Doctor renders these as "NOT SCANNED" so the user knows.
expect(report.per_source.length).toBe(1);
expect(report.per_source[0].status).toBe('skipped');
expect(report.per_source[0].files_scanned).toBe(0);
expect(report.partial).toBe(true);
expect(report.ok).toBe(false);
});
});