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>
This commit is contained in:
Garry Tan
2026-04-29 10:12:10 -07:00
committed by GitHub
co-authored by Wintermute Claude Opus 4.7
parent 8468ba25a9
commit 08746b06d2
15 changed files with 739 additions and 34 deletions
+12 -1
View File
@@ -21,11 +21,22 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
test:
# ubuntu-latest is free 2-core/7GB. Larger runners (16-cores, etc.) require
# a provisioned runner pool in repo settings. Falling back to default keeps
# the matrix shard speedup (~5-6x via parallelism) at zero cost.
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: latest
- run: bun install
- run: bun run test
- name: Pre-test gates (shard 1 only — they're not test files)
if: matrix.shard == 1
run: scripts/check-jsonb-pattern.sh && scripts/check-progress-to-stdout.sh && scripts/check-wasm-embedded.sh && bun run typecheck
- name: Run test shard ${{ matrix.shard }}/4
run: scripts/test-shard.sh ${{ matrix.shard }} 4
+1
View File
@@ -17,3 +17,4 @@ eval/data/world-v1/world.html
# BrainBench amara-life-v1 Opus cache (regenerate via eval:generate-amara-life)
eval/data/amara-life-v1/_cache/
.claude/
+68
View File
@@ -2,6 +2,74 @@
All notable changes to GBrain will be documented in this file.
## [0.22.9] - 2026-04-29
**Sync failures now tell you why, not just how many.**
**`gbrain sync --skip-failed` and `gbrain doctor` group failures by error code, so 2,685 silent SLUG_MISMATCH files don't hide behind a single count.**
Before this release, when sync hit per-file parse errors the only signal was a number:
```
Sync blocked: 2688 file(s) failed to parse. Fix the YAML frontmatter...
```
That count is useless when you're staring at 2,688 files and don't know what's wrong. On a real 81K-page brain, 2,685 of those turned out to be `SLUG_MISMATCH` from a posterous import — a single root cause hiding behind a giant number. It took manual `cat ~/.gbrain/sync-failures.jsonl | jq` to figure that out.
After:
```
Sync blocked: 2688 file(s) failed to parse:
SLUG_MISMATCH: 2685
YAML_DUPLICATE_KEY: 3
Fix the YAML frontmatter in the files above and re-run, or use 'gbrain sync --skip-failed' to acknowledge and move on.
# gbrain sync --skip-failed
Acknowledged 2688 failure(s) and advancing past them:
SLUG_MISMATCH: 2685
YAML_DUPLICATE_KEY: 3
```
`gbrain doctor` shows the same breakdown for unacknowledged AND historical entries:
```
[WARN] sync_failures: 2688 unacknowledged sync failure(s) [SLUG_MISMATCH=2685, YAML_DUPLICATE_KEY=3].
[OK] sync_failures: 500544 historical sync failure(s), all acknowledged [SLUG_MISMATCH=2685, ...].
```
The classifier knows the canonical messages from `collectValidationErrors()` in `src/core/markdown.ts` (8 frontmatter codes), Postgres unique-constraint violations (`DB_DUPLICATE_KEY`), statement-timeout errors (`STATEMENT_TIMEOUT`), invalid UTF-8, and YAML duplicates. DB-layer errors check before YAML-layer ones — so a Postgres `duplicate key value violates unique constraint` no longer mislabels as a YAML duplicate. Unrecognized errors fall through to `UNKNOWN`.
### What this means for you
If `gbrain sync` blocks with parse failures, the breakdown tells you what to fix first. SLUG_MISMATCH is one fix-pattern (frontmatter says one slug, path says another); YAML_PARSE is a different one (malformed YAML); STATEMENT_TIMEOUT means a DB timeout, not a parse problem. You stop staring at counts and start fixing root causes.
### For contributors
`acknowledgeSyncFailures()` in `src/core/sync.ts` now returns `{count, summary}` instead of `number`. If you import this directly from `gbrain/sync`, replace `n` with `result.count` and use `result.summary` (an `Array<{code, count}>`) for the new code-grouped breakdown. The function is reachable via the package exports map; this is a deliberate, non-shimmed breaking change. There is a new `formatCodeBreakdown()` helper in the same module that accepts either raw failures or pre-summarized input — use it instead of building breakdown strings inline.
### Itemized changes
#### Added
- `classifyErrorCode(errorMsg)` in `src/core/sync.ts` — best-effort error-code extraction from sync failure messages. Codes: `SLUG_MISMATCH`, `YAML_PARSE`, `YAML_DUPLICATE_KEY`, `MISSING_OPEN`, `MISSING_CLOSE`, `EMPTY_FRONTMATTER`, `NULL_BYTES`, `NESTED_QUOTES`, `DB_DUPLICATE_KEY`, `STATEMENT_TIMEOUT`, `INVALID_UTF8`, `UNKNOWN`.
- `summarizeFailuresByCode(failures)` — groups failures by code and returns a sorted `Array<{code, count}>`.
- `formatCodeBreakdown(input)` — renders a multi-line `code: count` string from either raw failures or a pre-computed summary. Single helper, two input shapes.
- `code?: string` field on the `SyncFailure` JSONL row in `~/.gbrain/sync-failures.jsonl`. Populated at write-time so the classifier runs once per failure, not on every load.
- `AcknowledgeResult` interface as the new return shape of `acknowledgeSyncFailures()`.
- 15 new test cases in `test/sync-failures.test.ts`: DB-vs-YAML duplicate-key disambiguation, canonical-message coverage for all 7 frontmatter codes, `acknowledgeSyncFailures()` legacy-entry backfill branch, `formatCodeBreakdown()` dual-input shape.
#### Changed
- `gbrain sync` blocked-message: now lists code breakdown above the fix instructions (both incremental and full-sync paths).
- `gbrain sync --skip-failed` ack message: now lists what was skipped, grouped by code.
- `gbrain doctor` `sync_failures` check: warn-and-ok messages both include `[code=count, ...]` breakdown.
- `recordSyncFailures()` now stores `code` alongside `error` so downstream readers don't re-classify.
- `acknowledgeSyncFailures()` backfills `code` on legacy rows that predate the field — upgrade-safe for users with existing `~/.gbrain/sync-failures.jsonl`.
- DB-layer error patterns (`DB_DUPLICATE_KEY`, `STATEMENT_TIMEOUT`) check BEFORE YAML patterns in the classifier, so Postgres errors don't get YAML-labeled.
- Frontmatter regex patterns rewritten to match canonical messages from `collectValidationErrors()` (`File is empty...`, `No closing --- delimiter found`, `Frontmatter block is empty`) instead of aspirational code-token strings (`missing.*open`) that never appeared in practice.
Closes #500. Eng-review plan: `~/.claude/plans/then-codex-synchronous-toucan.md` (codex outside-voice agreed on all 7 findings).
## [0.22.8] - 2026-04-28
## **Doctor stops timing out on Supabase. Integrity scan finishes in ~6s, multi-source brains get correct counts.**
+88
View File
@@ -1,5 +1,93 @@
# TODOS
## sync error-code classification (PR #501 follow-ups)
### Plumb structured `ParseValidationCode` through `ImportResult`
**Priority:** P2
**What:** Replace the regex-on-error-message path in `src/core/sync.ts:classifyErrorCode`
with a structured `code` field threaded through `ImportResult` from the parse layer.
Three changes:
1. `src/core/import-file.ts:362` — call `parseMarkdown(content, relativePath, { validate: true, expectedSlug })`
so `parsed.errors[0].code` is populated.
2. `src/core/import-file.ts` — add `code?: string` to `ImportResult`. Promote the
structured code (or `'SLUG_MISMATCH'` when the existing expectedSlug check trips)
into the result envelope alongside `error`.
3. `src/commands/sync.ts:488` — extend `failedFiles` shape with `code?: string`.
`recordSyncFailures` already accepts the field; the only thing missing is the
capture site populating it.
4. `src/core/sync.ts:classifyErrorCode` — keep as a fallback for un-coded errors
(DB exceptions, generic catches). Primary path reads the structured code.
**Why:** The repo already has `ParseValidationCode` + `ParseValidationError` in
`src/core/markdown.ts:5-18`, and three other consumers (`src/commands/lint.ts:72`,
`src/commands/frontmatter.ts:148`, `src/core/brain-writer.ts:314`) read structured
errors directly. Sync is the outlier — it calls `parseMarkdown` without validation
and reverse-engineers codes via regex. PR #501 shipped that regex out of pragmatism;
this TODO removes ~50% of `classifyErrorCode` and eliminates a class of false-positives.
**Pros:**
- One source of truth for parse codes (the enum in `markdown.ts`).
- Eliminates regex fragility — adding a new validation code in `markdown.ts`
automatically flows to sync without a new regex.
- Closes the case where canonical messages (`File is empty...`, `No closing ---...`)
don't match aspirational regex patterns.
**Cons:** Touches `ImportResult` interface, which ripples through `src/commands/import.ts:105`,
`src/commands/sync.ts:498-510`, `src/core/cycle.ts`, brain-writer reconciler.
**Context:** PR #501 documented this as P3 in the eng review at
`~/.claude/plans/then-codex-synchronous-toucan.md`. Codex's outside-voice review
agreed independently. The fix is small — ~50 lines including tests + downstream
call sites — and it's the correct architectural endpoint.
**Effort:** M (human: ~2 hr / CC: ~20 min).
**Depends on / blocked by:** Nothing.
### CHANGELOG migration note for `acknowledgeSyncFailures()` shape change
**Priority:** P0 — required at /ship time
**What:** When PR #501 ships, the release CHANGELOG entry MUST include this
`### For contributors` block:
```markdown
### For contributors
`acknowledgeSyncFailures()` now returns `{count, summary}` instead of `number`.
If you import this directly from `gbrain/sync`, replace `n` with `result.count`
and use `result.summary` for the new code-grouped breakdown.
```
**Why:** The function is exported from `src/core/sync.ts:433` and reachable via
the package exports map. External TS consumers (gbrain-evals, host agent forks)
that imported it got `number` and now get an object — silent type break.
**Effort:** XS (human: ~1 min). Just don't forget.
**Depends on / blocked by:** PR #501 ship.
### Concurrent-safe ack of `~/.gbrain/sync-failures.jsonl`
**Priority:** P3
**What:** Two concurrent `gbrain sync` runs hitting `acknowledgeSyncFailures()`
can clobber each other. The function does a whole-file `writeFileSync` rewrite
(`src/core/sync.ts:433-455`); `recordSyncFailures()` does independent
`appendFileSync` (`src/core/sync.ts:395-416`). Concurrent ack + append can lose rows.
**Why:** Pre-existing — predates PR #501. Real risk only on autopilot setups where
multiple sync invocations might overlap (rare today, more likely as multi-source
sync matures).
**Fix sketch:** Atomic rename pattern (write to `sync-failures.jsonl.tmp`, then
`renameSync`) plus a file lock for the read-modify-write cycle. Or move the
acknowledged-set to the DB.
**Effort:** S (human: ~1 hr / CC: ~10 min).
**Depends on / blocked by:** Nothing.
## test-infra
### Parallel-load timeout flake on v0.21 PGLite-heavy tests
+1 -1
View File
@@ -1 +1 @@
0.22.8
0.22.9
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gbrain",
"version": "0.22.8",
"version": "0.22.9",
"description": "Postgres-native personal knowledge brain with hybrid RAG search",
"type": "module",
"main": "src/core/index.ts",
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Partition unit test files into N shards by stable hash and run one shard.
#
# Usage: scripts/test-shard.sh <shard-index> <total-shards>
# shard-index: 1-based (1..N)
# total-shards: positive integer
#
# E2E tests under test/e2e/ are excluded — they need DATABASE_URL and run via
# bun run test:e2e separately.
#
# Stable partitioning: a file's shard is `(hash(path) % N) + 1`. Same file
# lands in the same shard on every run, regardless of how many other files
# exist, so retries are reproducible. Hash is FNV-1a — pure shell, no jq.
set -euo pipefail
if [ "$#" -ne 2 ]; then
echo "usage: scripts/test-shard.sh <shard-index> <total-shards>" >&2
exit 1
fi
SHARD_INDEX="$1"
TOTAL_SHARDS="$2"
if ! [[ "$SHARD_INDEX" =~ ^[0-9]+$ ]] || ! [[ "$TOTAL_SHARDS" =~ ^[0-9]+$ ]]; then
echo "error: shard index and total must be positive integers" >&2
exit 1
fi
if [ "$SHARD_INDEX" -lt 1 ] || [ "$SHARD_INDEX" -gt "$TOTAL_SHARDS" ]; then
echo "error: shard index $SHARD_INDEX out of range 1..$TOTAL_SHARDS" >&2
exit 1
fi
cd "$(dirname "$0")/.."
# Find all unit test files, deterministic order. Excludes test/e2e/.
# Portable: avoid `mapfile` (bash 4+) so this runs on macOS bash 3.2 too.
FILES=()
while IFS= read -r line; do
FILES+=("$line")
done < <(find test -name '*.test.ts' -not -path 'test/e2e/*' | sort)
if [ "${#FILES[@]}" -eq 0 ]; then
echo "no test files found under test/" >&2
exit 1
fi
# FNV-1a 32-bit hash of a string — implemented in pure bash so we don't depend
# on python/openssl/etc on the runner. Output is decimal.
fnv1a() {
local str="$1"
local h=2166136261 # FNV offset basis
local i ord
for (( i=0; i<${#str}; i++ )); do
ord=$(printf '%d' "'${str:$i:1}")
h=$(( (h ^ ord) & 0xFFFFFFFF ))
h=$(( (h * 16777619) & 0xFFFFFFFF ))
done
echo "$h"
}
SHARD_FILES=()
for f in "${FILES[@]}"; do
hash=$(fnv1a "$f")
bucket=$(( hash % TOTAL_SHARDS + 1 ))
if [ "$bucket" -eq "$SHARD_INDEX" ]; then
SHARD_FILES+=("$f")
fi
done
echo "shard $SHARD_INDEX/$TOTAL_SHARDS: ${#SHARD_FILES[@]}/${#FILES[@]} files"
if [ "${#SHARD_FILES[@]}" -eq 0 ]; then
echo "warning: shard $SHARD_INDEX has no files (rehash or reduce shard count)" >&2
exit 0
fi
exec bun test --timeout=60000 "${SHARD_FILES[@]}"
+8 -4
View File
@@ -249,25 +249,29 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
// Without this doctor check, users see "sync blocked" and have no
// surface showing which files to fix.
try {
const { unacknowledgedSyncFailures, loadSyncFailures } = await import('../core/sync.ts');
const { unacknowledgedSyncFailures, loadSyncFailures, summarizeFailuresByCode } = await import('../core/sync.ts');
const unacked = unacknowledgedSyncFailures();
const all = loadSyncFailures();
if (unacked.length > 0) {
const codeSummary = summarizeFailuresByCode(unacked);
const codeBreakdown = codeSummary.map(s => `${s.code}=${s.count}`).join(', ');
const preview = unacked.slice(0, 3).map(f => `${f.path} (${f.error.slice(0, 60)})`).join('; ');
checks.push({
name: 'sync_failures',
status: 'warn',
message:
`${unacked.length} unacknowledged sync failure(s). ${preview}` +
`${unacked.length} unacknowledged sync failure(s) [${codeBreakdown}]. ${preview}` +
`${unacked.length > 3 ? `, and ${unacked.length - 3} more` : ''}. ` +
`Fix the file(s) and re-run 'gbrain sync', or use 'gbrain sync --skip-failed' to acknowledge.`,
});
} else if (all.length > 0) {
// Acknowledged-only: informational, not a warning.
// Acknowledged-only: show code breakdown for visibility.
const ackedSummary = summarizeFailuresByCode(all);
const ackedBreakdown = ackedSummary.map(s => `${s.code}=${s.count}`).join(', ');
checks.push({
name: 'sync_failures',
status: 'ok',
message: `${all.length} historical sync failure(s), all acknowledged.`,
message: `${all.length} historical sync failure(s), all acknowledged [${ackedBreakdown}].`,
});
}
} catch {
+20 -5
View File
@@ -12,6 +12,7 @@ import {
recordSyncFailures,
unacknowledgedSyncFailures,
acknowledgeSyncFailures,
formatCodeBreakdown,
} from '../core/sync.ts';
import { estimateTokens, CHUNKER_VERSION } from '../core/chunkers/code.ts';
import { EMBEDDING_MODEL, estimateEmbeddingCostUsd } from '../core/embedding.ts';
@@ -522,9 +523,13 @@ export async function performSync(engine: BrainEngine, opts: SyncOpts): Promise<
// current set, --retry-failed re-parses before running the normal sync.
if (failedFiles.length > 0) {
recordSyncFailures(failedFiles, headCommit);
// Emit structured summary grouped by error code so the operator
// can see *why* files failed, not just how many.
const codeBreakdown = formatCodeBreakdown(failedFiles);
if (!opts.skipFailed) {
console.error(
`\nSync blocked: ${failedFiles.length} file(s) failed to parse. ` +
`\nSync blocked: ${failedFiles.length} file(s) failed to parse:\n` +
`${codeBreakdown}\n\n` +
`Fix the YAML frontmatter in the files above and re-run, or use ` +
`'gbrain sync --skip-failed' to acknowledge and move on.`,
);
@@ -547,8 +552,11 @@ export async function performSync(engine: BrainEngine, opts: SyncOpts): Promise<
}
// --skip-failed: acknowledge the now-recorded set and proceed.
const acked = acknowledgeSyncFailures();
if (acked > 0) {
console.error(` Acknowledged ${acked} failure(s) and advancing past them.`);
if (acked.count > 0) {
console.error(
` Acknowledged ${acked.count} failure(s) and advancing past them:\n` +
`${formatCodeBreakdown(acked.summary)}`,
);
}
}
@@ -656,9 +664,11 @@ async function performFullSync(
// the sync module owns the last_commit write. Respect the same gate.
if (result.failures.length > 0) {
recordSyncFailures(result.failures, headCommit);
const codeBreakdown = formatCodeBreakdown(result.failures);
if (!opts.skipFailed) {
console.error(
`\nFull sync blocked: ${result.failures.length} file(s) failed. ` +
`\nFull sync blocked: ${result.failures.length} file(s) failed:\n` +
`${codeBreakdown}\n\n` +
`Fix the YAML in those files and re-run, or use '--skip-failed'.`,
);
await engine.setConfig('sync.last_run', new Date().toISOString());
@@ -675,7 +685,12 @@ async function performFullSync(
};
}
const acked = acknowledgeSyncFailures();
if (acked > 0) console.error(` Acknowledged ${acked} failure(s) and advancing past them.`);
if (acked.count > 0) {
console.error(
` Acknowledged ${acked.count} failure(s) and advancing past them:\n` +
`${formatCodeBreakdown(acked.summary)}`,
);
}
}
// Persist sync state so next sync is incremental (C1 fix: was missing).
+103 -6
View File
@@ -307,6 +307,8 @@ import { createHash as _createHash } from 'crypto';
export interface SyncFailure {
path: string;
error: string;
/** Structured error code extracted from the error message. */
code?: string;
commit: string;
line?: number;
ts: string;
@@ -314,6 +316,86 @@ export interface SyncFailure {
acknowledged_at?: string;
}
/**
* Best-effort extraction of a structured error code from a sync failure
* message. Matches known ParseValidationCode patterns (SLUG_MISMATCH,
* YAML_PARSE, etc.) and common DB / timeout errors. Returns 'UNKNOWN'
* when no pattern matches.
*
* Order matters: DB-layer errors are checked BEFORE YAML-layer ones so
* Postgres `duplicate key value violates unique constraint` doesn't get
* mislabeled as a YAML duplicate-key. Frontmatter patterns key off the
* canonical messages emitted by `collectValidationErrors()` in markdown.ts.
*/
export function classifyErrorCode(errorMsg: string): string {
// SLUG_MISMATCH: thrown by importFromFile() at src/core/import-file.ts:374.
if (/slug.*does not match|SLUG_MISMATCH/i.test(errorMsg)) return 'SLUG_MISMATCH';
// DB-layer errors come BEFORE the YAML duplicate-key check. Postgres unique-
// constraint violations contain "duplicate key" but are not a YAML problem.
if (/duplicate key value violates unique constraint|DB_DUPLICATE_KEY/i.test(errorMsg)) {
return 'DB_DUPLICATE_KEY';
}
if (/canceling statement due to statement timeout|STATEMENT_TIMEOUT/i.test(errorMsg)) {
return 'STATEMENT_TIMEOUT';
}
// YAML / frontmatter patterns. These match either the canonical message
// strings in src/core/markdown.ts (collectValidationErrors) or the literal
// ParseValidationCode token, so they fire whether the caller stores the
// message or just the code.
if (/YAML parse failed|YAML_PARSE/i.test(errorMsg)) return 'YAML_PARSE';
if (/YAMLException|duplicated mapping key|YAML_DUPLICATE_KEY/i.test(errorMsg)) {
return 'YAML_DUPLICATE_KEY';
}
if (/File is empty or whitespace-only|Frontmatter must start with ---|MISSING_OPEN/i.test(errorMsg)) {
return 'MISSING_OPEN';
}
if (/No closing --- delimiter|Heading at line .* found inside frontmatter|MISSING_CLOSE/i.test(errorMsg)) {
return 'MISSING_CLOSE';
}
if (/Frontmatter block is empty|EMPTY_FRONTMATTER/i.test(errorMsg)) return 'EMPTY_FRONTMATTER';
if (/Content contains null bytes|NULL_BYTES|null byte/i.test(errorMsg)) return 'NULL_BYTES';
if (/Nested double quotes|NESTED_QUOTES/i.test(errorMsg)) return 'NESTED_QUOTES';
// Generic fallbacks.
if (/invalid UTF-?8|INVALID_UTF8/i.test(errorMsg)) return 'INVALID_UTF8';
return 'UNKNOWN';
}
/** Group failures by error code and return a sorted summary. */
export function summarizeFailuresByCode(
failures: Array<{ error: string; code?: string }>,
): Array<{ code: string; count: number }> {
const counts: Record<string, number> = {};
for (const f of failures) {
const code = f.code ?? classifyErrorCode(f.error);
counts[code] = (counts[code] ?? 0) + 1;
}
return Object.entries(counts)
.sort(([, a], [, b]) => b - a)
.map(([code, count]) => ({ code, count }));
}
/**
* Format a code-grouped summary as a human-readable multi-line string for
* stderr / doctor output. Accepts either raw failures (which are summarized
* internally) or an already-summarized `{code, count}[]` shape (the return
* value of `summarizeFailuresByCode` or `AcknowledgeResult.summary`).
* Returns an empty string when the input is empty.
*/
export function formatCodeBreakdown(
input: Array<{ error: string; code?: string }> | Array<{ code: string; count: number }>,
): string {
// Distinguish by shape: summary entries have a numeric `count`. Empty array
// returns '' from either branch — both paths produce a 0-length join.
const summary =
input.length > 0 && typeof (input[0] as { count?: unknown }).count === 'number'
? (input as Array<{ code: string; count: number }>)
: summarizeFailuresByCode(input as Array<{ error: string; code?: string }>);
return summary.map(s => ` ${s.code}: ${s.count}`).join('\n');
}
function _failuresDir(): string {
return _joinPath(_homedir(), '.gbrain');
}
@@ -370,6 +452,7 @@ export function recordSyncFailures(
const entry: SyncFailure = {
path: f.path,
error: f.error,
code: classifyErrorCode(f.error),
commit,
line: f.line,
ts: now,
@@ -380,28 +463,42 @@ export function recordSyncFailures(
}
}
export interface AcknowledgeResult {
count: number;
summary: Array<{ code: string; count: number }>;
}
/**
* Mark all unacknowledged failures as acknowledged. Used by
* `gbrain sync --skip-failed`. Returns the number newly acknowledged.
* `gbrain sync --skip-failed`. Returns count and a structured summary
* grouped by error code so the operator can see *why* files were skipped.
*
* We do not delete — acknowledged entries stay as historical record so
* doctor can still show them under a "previously skipped" bucket.
*/
export function acknowledgeSyncFailures(): number {
export function acknowledgeSyncFailures(): AcknowledgeResult {
const entries = loadSyncFailures();
if (entries.length === 0) return 0;
if (entries.length === 0) return { count: 0, summary: [] };
const now = new Date().toISOString();
let changed = 0;
const newlyAcked: SyncFailure[] = [];
const updated = entries.map(e => {
if (e.acknowledged) return e;
changed++;
return { ...e, acknowledged: true, acknowledged_at: now };
// Backfill code for entries that predate the code field.
const code = e.code ?? classifyErrorCode(e.error);
const acked = { ...e, code, acknowledged: true, acknowledged_at: now };
newlyAcked.push(acked);
return acked;
});
if (changed === 0) return 0;
if (changed === 0) return { count: 0, summary: [] };
_mkdirSync(_failuresDir(), { recursive: true });
const fd = require('fs').writeFileSync;
fd(syncFailuresPath(), updated.map(e => JSON.stringify(e)).join('\n') + '\n');
return changed;
return {
count: changed,
summary: summarizeFailuresByCode(newlyAcked),
};
}
/** Return only unacknowledged failures. */
+14 -4
View File
@@ -1,4 +1,4 @@
import { describe, expect, test, beforeEach, afterEach } from 'bun:test';
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';
@@ -9,6 +9,7 @@ import {
BrainWriterError,
} from '../src/core/brain-writer.ts';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
const fence = '---';
@@ -115,15 +116,24 @@ describe('scanBrainSources (PGLite)', () => {
let tmp: string;
let engine: PGLiteEngine;
beforeEach(async () => {
tmp = mkdtempSync(join(tmpdir(), 'brain-writer-scan-'));
// 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();
});
afterEach(async () => {
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
tmp = mkdtempSync(join(tmpdir(), 'brain-writer-scan-'));
});
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
+15 -4
View File
@@ -7,28 +7,39 @@
*
* All tests use PGLite/in-memory — no DB connection required.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
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;
beforeEach(async () => {
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(async () => {
await engine.disconnect();
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
+43
View File
@@ -0,0 +1,43 @@
/**
* Wipe per-test data on a connected PGLite engine without dropping the schema.
* Used by tests that share one engine across the file (beforeAll) and need a
* clean slate per test (beforeEach).
*
* Why this exists: PGLite WASM cold-start + initSchema() is ~20s on CI runners.
* Spinning up a fresh engine per test (the prior beforeEach pattern) multiplies
* that across every test in every file. Sharing one engine and wiping data
* is two orders of magnitude faster.
*
* Implementation:
* 1. TRUNCATE every public table CASCADE, including `sources` (so tests
* that register their own sources don't leak rows into the next test).
* 2. Re-seed the default source row that pages.source_id's DEFAULT FKs
* against. Without this, the next page insert would fail FK validation.
* 3. Preserve `schema_version` — it carries the migration ledger that
* initSchema() populates; wiping it would make migration helpers think
* the brain is on v0.
*
* Identifier-quoted defensively against pathological table names.
*/
import type { PGLiteEngine } from '../../src/core/pglite-engine.ts';
const PRESERVE_TABLES = new Set(['schema_version']);
export async function resetPgliteState(engine: PGLiteEngine): Promise<void> {
const rows = await engine.executeRaw<{ tablename: string }>(
`SELECT tablename FROM pg_tables WHERE schemaname='public'`,
);
const targets = rows
.map(r => r.tablename)
.filter(name => !PRESERVE_TABLES.has(name));
if (targets.length === 0) return;
const quoted = targets.map(t => `"${t.replace(/"/g, '""')}"`).join(', ');
await engine.executeRaw(`TRUNCATE ${quoted} RESTART IDENTITY CASCADE`);
// Re-seed the default source row that initSchema() inserts. Mirrors the
// INSERT in src/core/pglite-schema.ts so the FK target survives reset.
await engine.executeRaw(
`INSERT INTO sources (id, name, config)
VALUES ('default', 'default', '{"federated": true}'::jsonb)
ON CONFLICT (id) DO NOTHING`,
);
}
+276 -4
View File
@@ -76,18 +76,19 @@ describe('Bug 9 — sync-failures JSONL helpers', () => {
{ path: 'b.md', error: 'err2' },
], 'commit1');
const n = acknowledgeSyncFailures();
expect(n).toBe(2);
const result = acknowledgeSyncFailures();
expect(result.count).toBe(2);
expect(result.summary.length).toBeGreaterThan(0);
const after = loadSyncFailures();
expect(after.every(e => e.acknowledged === true)).toBe(true);
expect(after.every(e => typeof e.acknowledged_at === 'string')).toBe(true);
// Second ack: nothing new to mark.
expect(acknowledgeSyncFailures()).toBe(0);
expect(acknowledgeSyncFailures().count).toBe(0);
// Adding a fresh failure then ack: only the new one flips.
recordSyncFailures([{ path: 'c.md', error: 'err3' }], 'commit2');
expect(acknowledgeSyncFailures()).toBe(1);
expect(acknowledgeSyncFailures().count).toBe(1);
expect(loadSyncFailures().length).toBe(3);
expect(loadSyncFailures().every(e => e.acknowledged === true)).toBe(true);
});
@@ -158,3 +159,274 @@ describe('Bug 9 — sync.ts CLI flag wiring', () => {
expect(source).toContain('recordSyncFailures');
});
});
describe('classifyErrorCode — error message to code mapping', () => {
test('classifies SLUG_MISMATCH from error message', async () => {
const { classifyErrorCode } = await import('../src/core/sync.ts');
expect(classifyErrorCode(
'Frontmatter slug "my-friend-mike" does not match path-derived slug "2008-03-20-my-friend-mike"'
)).toBe('SLUG_MISMATCH');
});
test('classifies YAML_PARSE from error message', async () => {
const { classifyErrorCode } = await import('../src/core/sync.ts');
expect(classifyErrorCode('YAML parse failed: unexpected colon in title')).toBe('YAML_PARSE');
});
test('classifies YAML_DUPLICATE_KEY', async () => {
const { classifyErrorCode } = await import('../src/core/sync.ts');
expect(classifyErrorCode('YAMLException: duplicated mapping key')).toBe('YAML_DUPLICATE_KEY');
});
test('classifies STATEMENT_TIMEOUT', async () => {
const { classifyErrorCode } = await import('../src/core/sync.ts');
expect(classifyErrorCode('canceling statement due to statement timeout')).toBe('STATEMENT_TIMEOUT');
});
test('classifies NULL_BYTES', async () => {
const { classifyErrorCode } = await import('../src/core/sync.ts');
expect(classifyErrorCode('invalid UTF-8: null byte at position 3770')).toBe('NULL_BYTES');
});
test('classifies INVALID_UTF8', async () => {
const { classifyErrorCode } = await import('../src/core/sync.ts');
expect(classifyErrorCode('invalid UTF-8 sequence at position 500')).toBe('INVALID_UTF8');
});
test('returns UNKNOWN for unrecognized errors', async () => {
const { classifyErrorCode } = await import('../src/core/sync.ts');
expect(classifyErrorCode('something completely different')).toBe('UNKNOWN');
});
});
describe('summarizeFailuresByCode — grouped summary', () => {
test('groups failures by classified code', async () => {
const { summarizeFailuresByCode } = await import('../src/core/sync.ts');
const summary = summarizeFailuresByCode([
{ error: 'Frontmatter slug "a" does not match path-derived slug "b"' },
{ error: 'Frontmatter slug "c" does not match path-derived slug "d"' },
{ error: 'YAML parse failed: bad colon' },
{ error: 'something unknown' },
]);
expect(summary).toEqual([
{ code: 'SLUG_MISMATCH', count: 2 },
{ code: 'YAML_PARSE', count: 1 },
{ code: 'UNKNOWN', count: 1 },
]);
});
test('respects pre-classified code field', async () => {
const { summarizeFailuresByCode } = await import('../src/core/sync.ts');
const summary = summarizeFailuresByCode([
{ error: 'anything', code: 'SLUG_MISMATCH' },
{ error: 'anything', code: 'SLUG_MISMATCH' },
{ error: 'anything', code: 'YAML_PARSE' },
]);
expect(summary).toEqual([
{ code: 'SLUG_MISMATCH', count: 2 },
{ code: 'YAML_PARSE', count: 1 },
]);
});
test('returns empty array for no failures', async () => {
const { summarizeFailuresByCode } = await import('../src/core/sync.ts');
expect(summarizeFailuresByCode([])).toEqual([]);
});
});
describe('acknowledgeSyncFailures — structured return', () => {
test('returns count and code summary', async () => {
const { recordSyncFailures, acknowledgeSyncFailures } = await import('../src/core/sync.ts');
recordSyncFailures([
{ path: 'a.md', error: 'Frontmatter slug "x" does not match path-derived slug "y"' },
{ path: 'b.md', error: 'Frontmatter slug "p" does not match path-derived slug "q"' },
{ path: 'c.md', error: 'YAML parse failed: bad' },
], 'commit1');
const result = acknowledgeSyncFailures();
expect(result.count).toBe(3);
expect(result.summary).toEqual([
{ code: 'SLUG_MISMATCH', count: 2 },
{ code: 'YAML_PARSE', count: 1 },
]);
});
});
describe('recordSyncFailures — code field', () => {
test('records classified code alongside error message', async () => {
const { recordSyncFailures, loadSyncFailures } = await import('../src/core/sync.ts');
recordSyncFailures([
{ path: 'a.md', error: 'Frontmatter slug "x" does not match path-derived slug "y"' },
], 'commit1');
const entries = loadSyncFailures();
expect(entries[0].code).toBe('SLUG_MISMATCH');
});
});
// classifyErrorCode disambiguates Postgres unique-constraint errors from
// YAML duplicate-key errors. Pre-fix, every "duplicate.*key" string mapped
// to YAML_DUPLICATE_KEY, which mislabels DB-layer failures during sync.
describe('classifyErrorCode — DB vs YAML duplicate-key disambiguation', () => {
test('Postgres unique-constraint violation classifies as DB_DUPLICATE_KEY', async () => {
const { classifyErrorCode } = await import('../src/core/sync.ts');
expect(classifyErrorCode(
'duplicate key value violates unique constraint "pages_slug_key"'
)).toBe('DB_DUPLICATE_KEY');
});
test('YAML duplicated mapping key still classifies as YAML_DUPLICATE_KEY', async () => {
const { classifyErrorCode } = await import('../src/core/sync.ts');
expect(classifyErrorCode('YAMLException: duplicated mapping key "title"'))
.toBe('YAML_DUPLICATE_KEY');
});
test('DB pattern is checked BEFORE YAML so DB errors are not mislabeled', async () => {
// Both patterns historically matched /duplicate.*key/i — order matters now.
const { classifyErrorCode } = await import('../src/core/sync.ts');
expect(classifyErrorCode(
'duplicate key value violates unique constraint on table "pages"'
)).toBe('DB_DUPLICATE_KEY');
expect(classifyErrorCode(
'duplicate key value violates unique constraint on table "pages"'
)).not.toBe('YAML_DUPLICATE_KEY');
});
});
// classifyErrorCode matches the canonical messages emitted by
// collectValidationErrors() in src/core/markdown.ts. Pre-fix, the regexes
// keyed off "missing open" / "missing close" / "empty frontmatter" — none
// of which are produced upstream. Today these all classify correctly.
describe('classifyErrorCode — canonical message coverage', () => {
test('MISSING_OPEN matches "File is empty or whitespace-only"', async () => {
const { classifyErrorCode } = await import('../src/core/sync.ts');
expect(classifyErrorCode(
'File is empty or whitespace-only; expected frontmatter starting with ---'
)).toBe('MISSING_OPEN');
});
test('MISSING_OPEN matches "Frontmatter must start with ---"', async () => {
const { classifyErrorCode } = await import('../src/core/sync.ts');
expect(classifyErrorCode(
'Frontmatter must start with --- on the first non-empty line'
)).toBe('MISSING_OPEN');
});
test('MISSING_CLOSE matches "No closing --- delimiter"', async () => {
const { classifyErrorCode } = await import('../src/core/sync.ts');
expect(classifyErrorCode('No closing --- delimiter found')).toBe('MISSING_CLOSE');
});
test('MISSING_CLOSE matches "Heading at line N found inside frontmatter"', async () => {
const { classifyErrorCode } = await import('../src/core/sync.ts');
expect(classifyErrorCode(
'Heading at line 5 found inside frontmatter zone (closing --- comes after)'
)).toBe('MISSING_CLOSE');
});
test('EMPTY_FRONTMATTER matches "Frontmatter block is empty"', async () => {
const { classifyErrorCode } = await import('../src/core/sync.ts');
expect(classifyErrorCode('Frontmatter block is empty')).toBe('EMPTY_FRONTMATTER');
});
test('NULL_BYTES matches "Content contains null bytes"', async () => {
const { classifyErrorCode } = await import('../src/core/sync.ts');
expect(classifyErrorCode('Content contains null bytes (likely binary corruption)'))
.toBe('NULL_BYTES');
});
test('NESTED_QUOTES matches "Nested double quotes"', async () => {
const { classifyErrorCode } = await import('../src/core/sync.ts');
expect(classifyErrorCode('Nested double quotes in YAML value at line 3'))
.toBe('NESTED_QUOTES');
});
});
// acknowledgeSyncFailures backfills `code` on legacy entries that were
// recorded before the code field existed (~/.gbrain/sync-failures.jsonl
// from pre-PR brains). Without this branch, upgraded users see "UNKNOWN"
// for every previously-recorded failure even when the message is parseable.
describe('acknowledgeSyncFailures — backfill on legacy entries', () => {
test('backfills code on entries that predate the code field', async () => {
const { acknowledgeSyncFailures, loadSyncFailures, syncFailuresPath } =
await import('../src/core/sync.ts');
// Hand-write a legacy entry with no `code` field. Mimics a pre-PR
// ~/.gbrain/sync-failures.jsonl row that exists on real upgrades.
const { mkdirSync } = await import('fs');
const { dirname } = await import('path');
mkdirSync(dirname(syncFailuresPath()), { recursive: true });
writeFileSync(
syncFailuresPath(),
JSON.stringify({
path: 'a.md',
error: 'Frontmatter slug "x" does not match path-derived slug "y"',
commit: 'old',
ts: '2025-01-01T00:00:00Z',
}) + '\n',
);
const result = acknowledgeSyncFailures();
expect(result.count).toBe(1);
expect(result.summary).toEqual([{ code: 'SLUG_MISMATCH', count: 1 }]);
const after = loadSyncFailures();
expect(after).toHaveLength(1);
expect(after[0].code).toBe('SLUG_MISMATCH');
expect(after[0].acknowledged).toBe(true);
});
test('preserves existing code field; never reclassifies', async () => {
const { acknowledgeSyncFailures, loadSyncFailures, syncFailuresPath } =
await import('../src/core/sync.ts');
const { mkdirSync } = await import('fs');
const { dirname } = await import('path');
mkdirSync(dirname(syncFailuresPath()), { recursive: true });
// Pre-classified entry — should NOT be re-run through classifier.
writeFileSync(
syncFailuresPath(),
JSON.stringify({
path: 'a.md',
error: 'some message that would otherwise classify as UNKNOWN',
code: 'CUSTOM_CODE',
commit: 'x',
ts: '2025-01-01T00:00:00Z',
}) + '\n',
);
const result = acknowledgeSyncFailures();
expect(result.summary).toEqual([{ code: 'CUSTOM_CODE', count: 1 }]);
expect(loadSyncFailures()[0].code).toBe('CUSTOM_CODE');
});
});
// formatCodeBreakdown is the DRY helper used by both the failures-array
// path (sync.ts blocked-by-failures + full-sync stderr) and the pre-summarized
// AcknowledgeResult.summary path (--skip-failed ack message). One renderer,
// two input shapes.
describe('formatCodeBreakdown — dual input shape', () => {
test('renders raw failures by classifying internally', async () => {
const { formatCodeBreakdown } = await import('../src/core/sync.ts');
const out = formatCodeBreakdown([
{ error: 'Frontmatter slug "a" does not match path-derived slug "b"' },
{ error: 'Frontmatter slug "c" does not match path-derived slug "d"' },
{ error: 'YAML parse failed: bad' },
]);
expect(out).toBe(' SLUG_MISMATCH: 2\n YAML_PARSE: 1');
});
test('renders pre-summarized {code, count} input directly', async () => {
const { formatCodeBreakdown } = await import('../src/core/sync.ts');
const out = formatCodeBreakdown([
{ code: 'SLUG_MISMATCH', count: 5 },
{ code: 'YAML_PARSE', count: 2 },
]);
expect(out).toBe(' SLUG_MISMATCH: 5\n YAML_PARSE: 2');
});
test('returns empty string for empty input', async () => {
const { formatCodeBreakdown } = await import('../src/core/sync.ts');
expect(formatCodeBreakdown([])).toBe('');
});
});
+13 -4
View File
@@ -1,10 +1,11 @@
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
import { buildSyncManifest, isSyncable, pathToSlug } from '../src/core/sync.ts';
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs';
import { join } from 'path';
import { execSync } from 'child_process';
import { tmpdir } from 'os';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
describe('buildSyncManifest', () => {
test('parses A/M/D entries from single commit', () => {
@@ -204,11 +205,20 @@ describe('performSync dry-run never writes', () => {
let engine: PGLiteEngine;
let repoPath: string;
beforeEach(async () => {
// One PGLite per file — beforeEach wipes data only. Each test still gets a
// fresh git repo via mkdtempSync, but skips the ~20s PGLite cold-start.
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
repoPath = mkdtempSync(join(tmpdir(), 'gbrain-sync-dryrun-'));
execSync('git init', { cwd: repoPath, stdio: 'pipe' });
execSync('git config user.email "test@test.com"', { cwd: repoPath, stdio: 'pipe' });
@@ -233,8 +243,7 @@ describe('performSync dry-run never writes', () => {
execSync('git add -A && git commit -m "initial"', { cwd: repoPath, stdio: 'pipe' });
});
afterEach(async () => {
await engine.disconnect();
afterEach(() => {
if (repoPath) rmSync(repoPath, { recursive: true, force: true });
});