Files
gbrain/test/extract-incremental.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

194 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Regression guards for the incremental extract path (PR #417).
*
* Eng-review Step 5: 8 unit cases asserting `runExtractCore({ slugs })`
* processes only the requested slugs in the cycle path while
* `slugs: undefined` falls through to the existing full-walk behavior.
*
* All tests use PGLite/in-memory — no DB connection required.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { runExtractCore } from '../src/commands/extract.ts';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import type { BrainEngine } from '../src/core/engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
// One PGLite per file (beforeAll), wipe data per test (beforeEach).
// PGLite cold-start dominates wall-time; sharing the engine across all tests
// in this file cuts ~22s × 8 tests = ~3 min on CI.
let engine: PGLiteEngine;
let tempDir: string;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({ engine: 'pglite' });
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
tempDir = mkdtempSync(join(tmpdir(), 'gbrain-extract-test-'));
mkdirSync(join(tempDir, 'people'), { recursive: true });
mkdirSync(join(tempDir, 'companies'), { recursive: true });
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
async function seedPage(slug: string, body: string): Promise<void> {
const [type, name] = slug.split('/');
await engine.putPage(slug, {
type: type as 'person' | 'company',
title: name,
compiled_truth: body,
timeline: '',
frontmatter: {},
content_hash: 'h',
});
// Also write to disk so walkMarkdownFiles can find it
const filePath = join(tempDir, slug + '.md');
mkdirSync(join(tempDir, type), { recursive: true });
writeFileSync(filePath, body);
}
describe('runExtractCore — incremental cycle path (#417)', () => {
test('1. slugs: [] returns immediately with zero counts (early-return path)', async () => {
await seedPage('people/alice-example', '# alice');
const result = await runExtractCore(engine as unknown as BrainEngine, {
mode: 'all',
dir: tempDir,
slugs: [],
});
expect(result.links_created).toBe(0);
expect(result.timeline_entries_created).toBe(0);
expect(result.pages_processed).toBe(0);
});
test('2. slugs: undefined falls through to full-walk path', async () => {
await seedPage('people/alice-example', '# alice\n\n[bob](people/bob-example)');
await seedPage('people/bob-example', '# bob');
const result = await runExtractCore(engine as unknown as BrainEngine, {
mode: 'all',
dir: tempDir,
});
// Full walk processes everything found on disk
expect(result.pages_processed).toBeGreaterThan(0);
});
test('3. slugs: [a, b] reads only those two files (incremental processing)', async () => {
await seedPage('people/alice-example', '# alice');
await seedPage('people/bob-example', '# bob');
await seedPage('people/charlie-example', '# charlie');
const result = await runExtractCore(engine as unknown as BrainEngine, {
mode: 'all',
dir: tempDir,
slugs: ['people/alice-example', 'people/bob-example'],
});
// Only 2 files processed even though 3 exist on disk
expect(result.pages_processed).toBe(2);
});
test('4. Slug whose file no longer exists is silently skipped', async () => {
await seedPage('people/alice-example', '# alice');
// people/ghost has no file on disk but is in the slugs list
const result = await runExtractCore(engine as unknown as BrainEngine, {
mode: 'all',
dir: tempDir,
slugs: ['people/alice-example', 'people/ghost'],
});
// alice processed; ghost skipped (no file)
expect(result.pages_processed).toBe(1);
});
test('5. mode: links skips timeline extraction in incremental', async () => {
const body = '# alice\n\n## Timeline\n- 2026-01-01: started';
await seedPage('people/alice-example', body);
const result = await runExtractCore(engine as unknown as BrainEngine, {
mode: 'links',
dir: tempDir,
slugs: ['people/alice-example'],
});
// Timeline extraction skipped even though body contains a timeline
expect(result.timeline_entries_created).toBe(0);
});
test('6. dryRun: true does not invoke addLinksBatch / addTimelineEntriesBatch', async () => {
await seedPage('people/alice-example', '# alice\n\n[bob](people/bob-example)');
await seedPage('people/bob-example', '# bob');
let linksBatchCalled = false;
let timelineBatchCalled = false;
const originalAddLinks = engine.addLinksBatch.bind(engine);
const originalAddTimeline = engine.addTimelineEntriesBatch.bind(engine);
(engine as unknown as { addLinksBatch: typeof originalAddLinks }).addLinksBatch = async (...args) => {
linksBatchCalled = true;
return originalAddLinks(...args);
};
(engine as unknown as { addTimelineEntriesBatch: typeof originalAddTimeline }).addTimelineEntriesBatch = async (...args) => {
timelineBatchCalled = true;
return originalAddTimeline(...args);
};
await runExtractCore(engine as unknown as BrainEngine, {
mode: 'all',
dir: tempDir,
slugs: ['people/alice-example'],
dryRun: true,
});
expect(linksBatchCalled).toBe(false);
expect(timelineBatchCalled).toBe(false);
});
test('7. BATCH_SIZE flush — slugs producing >100 candidate links exercise the mid-iteration flush', async () => {
// BATCH_SIZE in extract.ts is 100. Create one slug with 150 outbound links.
const targets: string[] = [];
for (let i = 0; i < 150; i++) {
const target = `companies/co-${i}`;
targets.push(target);
await seedPage(target, `# co-${i}`);
}
const linkBlock = targets.map(t => `- [${t}](${t})`).join('\n');
await seedPage('people/alice-example', `# alice\n\n${linkBlock}`);
const result = await runExtractCore(engine as unknown as BrainEngine, {
mode: 'links',
dir: tempDir,
slugs: ['people/alice-example'],
});
// The flush happens mid-iteration when batch hits 100; the remaining 50 flush at end.
// No exception means the flush path executed cleanly.
expect(result.pages_processed).toBe(1);
expect(result.links_created).toBeGreaterThanOrEqual(0); // Just confirms the flush path didn't blow up
});
test('8. Full-slug-set resolution — slug references file outside changed set', async () => {
// alice references bob, but only alice is in the incremental slugs list.
// The allSlugs set must still include bob (from walkMarkdownFiles) so
// resolveSlug succeeds; otherwise the link would silently drop.
// Markdown link pattern requires .md target.
await seedPage('people/alice-example', '# alice\n\n[bob](bob-example.md)');
await seedPage('people/bob-example', '# bob');
const result = await runExtractCore(engine as unknown as BrainEngine, {
mode: 'links',
dir: tempDir,
slugs: ['people/alice-example'],
});
// Only alice's file was read, but the resulting link must reference bob
// (resolved via the full allSlugs set built from walkMarkdownFiles).
expect(result.pages_processed).toBe(1);
// Link from alice to bob was extracted successfully via the full allSlugs set
expect(result.links_created).toBeGreaterThan(0);
});
});