Files
gbrain/test/brain-writer.test.ts
T
08746b06d2 v0.22.9 feat: structured error code summary for sync --skip-failed (#501)
* feat: structured error code summary for sync --skip-failed (#500)

When sync encounters per-file failures, the blocked/skip-failed messages
now include a breakdown by error code (SLUG_MISMATCH, YAML_PARSE, etc.)
instead of just a raw count. This makes it immediately obvious *why*
files failed without requiring manual investigation.

Changes:
- Add classifyErrorCode() — maps error messages to ParseValidationCode
- Add summarizeFailuresByCode() — groups failures into sorted code summary
- SyncFailure now carries a 'code' field (backfilled on acknowledge)
- acknowledgeSyncFailures() returns AcknowledgeResult {count, summary}
- sync blocked + skip-failed messages show code breakdown
- doctor sync_failures check shows code breakdown for both unacked and historical
- 12 new tests for classifyErrorCode, summarizeFailuresByCode, and structured returns

Before:
  Sync blocked: 2688 file(s) failed to parse.

After:
  Sync blocked: 2688 file(s) failed to parse:
    SLUG_MISMATCH: 2685
    YAML_DUPLICATE_KEY: 3

Closes #500

* fix: eng-review fixes for sync error-code classification

- Reorder classifyErrorCode() so DB-layer errors (DB_DUPLICATE_KEY,
  STATEMENT_TIMEOUT) check BEFORE YAML patterns. Postgres "duplicate key
  value violates unique constraint" no longer mislabels as YAML_DUPLICATE_KEY.
- Rewrite MISSING_OPEN/MISSING_CLOSE/EMPTY_FRONTMATTER/NULL_BYTES/NESTED_QUOTES
  regexes to match the canonical messages emitted by collectValidationErrors()
  in src/core/markdown.ts. Previous patterns (e.g. /missing.*open/i) never
  fired because the upstream throw site emits prose ("File is empty...",
  "No closing --- delimiter found"), not the code name.
- Extract formatCodeBreakdown() helper that accepts either raw failures or
  pre-summarized {code, count}[] input. Replaces 3 duplicate inline builders
  in src/commands/sync.ts.
- 15 new tests (37/37 pass on test/sync-failures.test.ts):
  - DB vs YAML duplicate-key disambiguation (3 cases)
  - Canonical-message coverage for the 5 frontmatter codes (7 cases)
  - acknowledgeSyncFailures() legacy-entry backfill branch (2 cases)
  - formatCodeBreakdown() dual-input shape (3 cases)
- TODOS.md: file 3 follow-ups (P2 plumb structured ParseValidationCode;
  P0-at-ship CHANGELOG migration note for AcknowledgeResult; P3 concurrent-
  safe ack of sync-failures.jsonl).

Eng-review plan: ~/.claude/plans/then-codex-synchronous-toucan.md

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

* chore: bump version and changelog (v0.22.9)

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

* ci: 16-core runner + 4-way matrix shard for test job

The unit test suite ran 22m17s on ubuntu-latest (2-core/7GB) because:
- 187 test files run with bun test parallelism bounded by core count
- 23 of those files spin up a fresh PGLiteEngine + initSchema in beforeEach,
  paying ~22s WASM cold-start per test on the small runner

This commit fixes the runner side:
- runs-on: ubuntu-latest-16-cores (16 vCPU / 64 GB RAM)
- strategy.matrix.shard splits 4 parallel jobs, each running ~40 of 158 unit
  test files. Single-file wall-time floor is ~3 min after the test refactor,
  so 4 shards × 16 cores hits the floor quickly without wasting cores past it.
- pre-test gates (typecheck, check-jsonb, check-progress, check-wasm) only run
  on shard 1 — they're not test files and don't benefit from sharding.

scripts/test-shard.sh partitions test files by stable FNV-1a hash mod N. Same
file always lands in the same shard, so retries are reproducible. Pure shell,
portable to bash 3.2 (macOS) and bash 5.x (CI). Excludes test/e2e/ which runs
via bun run test:e2e separately and needs DATABASE_URL.

Also: ignore .claude/ harness state files (scheduled_tasks.lock etc) instead
of just .claude/skills/.

Cost: ~$0.19/run vs $0 (public repo, default runner is free). At 50 PRs/month
that's ~$10/month for ~5x faster CI.

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

* test: refactor top-3 PGLite-heavy files to share one engine per file

Three test files were spinning up a fresh PGLiteEngine + connect + initSchema
in beforeEach. PGLite WASM cold-start is ~22s on the small CI runner; doing
this per test multiplied wall-time across the suite. The 3 files alone
accounted for ~6.5 min of the 22m CI run (177s + 132s + 87s).

Refactor: move PGLite setup to beforeAll (one engine per file), wipe data
in beforeEach via the new test/helpers/reset-pglite.ts helper.

The reset helper:
- TRUNCATEs every public table CASCADE, including sources (so tests that
  register their own sources don't leak rows into the next test).
- Re-seeds the default source row that pages.source_id's DEFAULT FKs against.
  Without this, the next page insert would fail FK validation.
- Preserves schema_version so migration helpers don't think the brain is on v0.

Files refactored:
- test/extract-incremental.test.ts (8 tests, was 177s on CI)
- test/brain-writer.test.ts (16 tests; only the scanBrainSources block uses
  PGLite, was 132s on CI)
- test/sync.test.ts (37 tests; only the performSync dry-run block uses PGLite,
  was 87s on CI)

All 61 tests still pass locally. The remaining 20 PGLite-heavy files use the
same beforeEach anti-pattern; this commit only refactors the proven worst
offenders. Sweep the rest in a follow-up if CI numbers indicate it's worth it.

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

* ci: fall back to ubuntu-latest for matrix shard

The ubuntu-latest-16-cores label requires a provisioned larger-runner pool in
repo/org settings. Without that setup, jobs queue indefinitely waiting for a
runner that doesn't exist (verified: 4 shards stuck in 'queued' status with
empty runner_name for 5+ min).

Drop back to the default 2-core ubuntu-latest. The 4-way matrix shard still
delivers ~5-6x speedup via parallelism alone — 4 jobs running in parallel,
each handling ~40 of 158 unit test files. Cost stays $0 (default runner is
free for public repos).

If we ever provision a larger-runner pool, flip this label back to
ubuntu-latest-16-cores. The matrix + sharder will use the bigger boxes
unchanged.

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

---------

Co-authored-by: Wintermute <wintermute@garrytan.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:12:10 -07:00

230 lines
8.9 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([]);
});
});
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 .bak before mutating an existing file', () => {
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);
writeBrainPage(file, `${fence}\ntype: person\ntitle: New\n${fence}\n\nnew`, { sourcePath: tmp });
expect(existsSync(file + '.bak')).toBe(true);
expect(readFileSync(file + '.bak', 'utf8')).toBe(original);
});
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 mid-scan stops walking', 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 });
// Aborted before any source ran; per_source array stays empty (or has zero reports).
expect(report.per_source.length).toBe(0);
});
});