Compare commits

..
Author SHA1 Message Date
583975dee8 fix(sync): guard putPage 0-row RETURNING + reclassify sync failure copy (#2189)
Root cause of #2189's opaque per-file crash: putPage's INSERT … ON CONFLICT
DO UPDATE … RETURNING can yield 0 rows when brain-local DB state (e.g. a
BEFORE trigger) suppresses the write, and rowToPage(rows[0]) then died with
"undefined is not an object (evaluating 'row.deleted_at')". Both engines now
throw a descriptive error naming the slug + source_id so the failure is
diagnosable per-file instead of an anonymous TypeError.

Also lands the salvageable parts of community PR #2586 (takeover): the
"failed to parse / fix the frontmatter" copy misclassified runtime import
errors as YAML problems — reworded to "failed to import" — plus its
first-code-sync PGLite smoke test.

New regression test reproduces the exact 0-row RETURNING state via a
suppressing trigger: fails pre-fix with the reported crash signature,
passes with the guard.

Co-authored-by: javieraldape <javieraldape@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:41:32 -07:00
12 changed files with 128 additions and 245 deletions
+1 -22
View File
@@ -365,12 +365,6 @@ async function main() {
if (def.required && params[key] === undefined) {
if (queryHasAlt && key === 'query') continue;
const cliName = op.cliHints?.name || op.name;
// #2822: when the missing param is the op's stdin-fed one, the usage
// line alone is misleading (the positionals may all be present — the
// pipe was just empty). Name the real problem.
if (op.cliHints?.stdin === key) {
console.error(`Error: required "${key}" is missing — stdin was empty or not piped. Pipe content on stdin or pass --${key.replace(/_/g, '-')}.`);
}
const positional = op.cliHints?.positional || [];
const usage = positional.map(p => `<${p}>`).join(' ');
console.error(`Usage: gbrain ${cliName} ${usage}`);
@@ -767,10 +761,6 @@ export function parseOpArgs(op: Operation, args: string[]): Record<string, unkno
const params: Record<string, unknown> = {};
const positional = op.cliHints?.positional || [];
let posIdx = 0;
// #2822: track which params came from positionals so a later flag that
// silently discards one (`gbrain put CONTENT --slug foo` — CONTENT was
// parsed as the slug) gets a stderr warning instead of vanishing.
const positionallySet = new Set<string>();
for (let i = 0; i < args.length; i++) {
const arg = args[i];
@@ -788,20 +778,13 @@ export function parseOpArgs(op: Operation, args: string[]): Record<string, unkno
if (paramDef?.type === 'boolean') {
params[key] = true;
} else if (i + 1 < args.length) {
if (positionallySet.has(key) && params[key] !== args[i + 1]) {
console.error(`Warning: ${arg} overrides the positional <${key}> value ${JSON.stringify(params[key])}.`);
}
params[key] = args[++i];
if (paramDef?.type === 'number') params[key] = Number(params[key]);
}
} else if (posIdx < positional.length) {
const key = positional[posIdx++];
const paramDef = op.params[key];
if (params[key] !== undefined && params[key] !== (paramDef?.type === 'number' ? Number(arg) : arg)) {
console.error(`Warning: positional <${key}> overrides the earlier --${key.replace(/_/g, '-')} value ${JSON.stringify(params[key])}.`);
}
params[key] = paramDef?.type === 'number' ? Number(arg) : arg;
positionallySet.add(key);
}
}
@@ -813,11 +796,7 @@ export function parseOpArgs(op: Operation, args: string[]): Record<string, unkno
console.error(`Error: stdin content exceeds ${MAX_STDIN} bytes. Split into smaller inputs.`);
process.exit(1);
}
// #2822: empty/whitespace-only stdin (cron with no input, broken pipe)
// stays UNSET so the required-param check rejects the call instead of
// silently writing an empty page (0 chunks, invisible to search and
// embed --stale).
if (stdinContent.trim().length > 0) params[op.cliHints.stdin] = stdinContent;
params[op.cliHints.stdin] = stdinContent;
}
return params;
-1
View File
@@ -48,7 +48,6 @@ const FRONTMATTER_RULE_NAMES: Record<ParseValidationCode, string> = {
NESTED_QUOTES: 'frontmatter-nested-quotes',
NON_STRING_FIELD: 'frontmatter-non-string-field',
EMPTY_FRONTMATTER: 'frontmatter-empty',
MULTI_FRONTMATTER: 'frontmatter-multi',
};
/** Codes whose lint findings are fixable by `gbrain frontmatter validate --fix`. */
+5 -5
View File
@@ -197,7 +197,7 @@ export interface SyncResult {
/** Pages re-embedded during this sync's auto-embed step. 0 if --no-embed or skipped. */
embedded: number;
pagesAffected: string[];
failedFiles?: number; // count of parse failures (Bug 9)
failedFiles?: number; // count of per-file import/sync failures (Bug 9)
/**
* v0.41.13.0 partial-sync fields (only set when status === 'partial').
*
@@ -3183,7 +3183,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
await clearOpCheckpoint(engine, ckpt.target);
};
// issue #1939 adversarial finding #1: a file that failed to parse (open ledger
// issue #1939 adversarial finding #1: a file that failed to import (open ledger
// row) and is then deleted/renamed-away never re-enters failedFiles and never
// imports, so its row would never clear and would age doctor to a permanent
// FAIL. Treat removed paths as resolved so the ledger self-heals.
@@ -3215,9 +3215,9 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
} else {
const fileFailCount = failedFiles.filter(f => isSkippablePath(f.path)).length;
serr(
`\nSync blocked: ${fileFailCount} file(s) failed to parse:\n` +
`\nSync blocked: ${fileFailCount} file(s) failed to import:\n` +
`${codeBreakdown}\n\n` +
`Fix the frontmatter and re-run, or use 'gbrain sync --skip-failed' to ` +
`Fix the listed file errors and re-run, or use 'gbrain sync --skip-failed' to ` +
`acknowledge and move on. A file that keeps failing auto-skips after ` +
`${resolveAutoSkipThreshold()} consecutive syncs.`,
);
@@ -5355,7 +5355,7 @@ function printSyncResult(result: SyncResult, sink: NodeJS.WriteStream = process.
case 'dry_run':
break; // already printed in performSync
case 'blocked_by_failures':
write(`Sync BLOCKED at ${result.toCommit.slice(0, 8)}: ${result.failedFiles ?? 0} file(s) failed to parse.`);
write(`Sync BLOCKED at ${result.toCommit.slice(0, 8)}: ${result.failedFiles ?? 0} file(s) failed to import.`);
write(` See ~/.gbrain/sync-failures.jsonl for details, or run 'gbrain doctor'.`);
write(` Fix the files then re-run 'gbrain sync', or 'gbrain sync --skip-failed' to move on.`);
break;
+1 -22
View File
@@ -301,17 +301,6 @@ export async function importFromContent(
// silently fabricated a duplicate at (default, slug) — causing later
// bare-slug subqueries (getTags, deleteChunks, etc.) to crash with 21000.
const sourceId = opts.sourceId;
// #2822: reject empty/whitespace-only content before any work happens. An
// empty page writes 0 chunks — invisible to search AND to `embed --stale`
// (nothing to embed), so the mistake never surfaces. Empty content is
// always a caller bug (empty piped stdin, bad shell substitution). Thrown
// (not returned) so every wrapper site surfaces the message, matching the
// ContentSanityBlockError flow.
if (content.trim().length === 0) {
throw new Error(
`Content for "${slug}" is empty; refusing to write an empty page (0 chunks would be invisible to search and embed --stale).`,
);
}
// Reject oversized payloads before any parsing, chunking, or embedding happens.
// Uses Buffer.byteLength to count UTF-8 bytes the same way disk size would,
// so the network path behaves identically to the file path.
@@ -325,17 +314,7 @@ export async function importFromContent(
};
}
const parsed = parseMarkdown(content, slug + '.md', { activePack: opts.activePack, validate: true });
// #2743: reject stacked frontmatter (the double-put corruption class —
// already-serialized markdown re-wrapped in fresh frontmatter). gray-matter
// parses only the first block; the second would land verbatim in the body
// and poison every subsequent round-trip. Only MULTI_FRONTMATTER rejects
// here — the other validation codes keep their lint-only semantics.
const multiFm = parsed.errors?.find(e => e.code === 'MULTI_FRONTMATTER');
if (multiFm) {
throw new Error(`MULTI_FRONTMATTER: ${multiFm.message} (slug "${slug}", line ${multiFm.line})`);
}
const parsed = parseMarkdown(content, slug + '.md', { activePack: opts.activePack });
// v0.42 (#1699 trust boundary): strip gate-owned markers from UNTRUSTED
// input. parseMarkdown preserves every frontmatter key except type/title/
+1 -46
View File
@@ -11,8 +11,7 @@ export type ParseValidationCode =
| 'NULL_BYTES'
| 'NESTED_QUOTES'
| 'NON_STRING_FIELD'
| 'EMPTY_FRONTMATTER'
| 'MULTI_FRONTMATTER';
| 'EMPTY_FRONTMATTER';
export interface ParseValidationError {
code: ParseValidationCode;
@@ -332,50 +331,6 @@ function collectValidationErrors(
});
}
}
// 9. MULTI_FRONTMATTER (#2743) — a second ---…--- block right after the
// closing fence is stacked frontmatter (the double-put corruption class:
// already-serialized markdown re-wrapped in fresh frontmatter).
// gray-matter parses only the first block and silently leaves the second
// in the body. Heuristic: first non-empty line after the close is `---`,
// a later `---` closes it, EVERY line between is frontmatter-shaped
// (YAML `key:`, `- ` list item, `#` comment, indented continuation, or
// blank — the issue's "stop at the first non-frontmatter character"
// spec), and at least one is a `key:` line. A lone `---` stays a
// markdown horizontal rule, and an hrule followed by prose — even
// colon-prefixed prose like `Note: …` mixed with plain lines — is body
// content, not a stacked block.
let afterClose = closeLine + 1;
while (afterClose < lines.length && lines[afterClose].trim().length === 0) afterClose++;
if (afterClose < lines.length && lines[afterClose].trim() === '---') {
let secondClose = -1;
for (let i = afterClose + 1; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (trimmed === '---') {
secondClose = i;
break;
}
const yamlShaped =
trimmed.length === 0 ||
/^[A-Za-z_][\w-]*\s*:/.test(trimmed) ||
trimmed.startsWith('- ') ||
trimmed === '-' ||
trimmed.startsWith('#') ||
/^\s/.test(lines[i]);
if (!yamlShaped) break; // first non-frontmatter line → body prose, not a stacked block
}
if (
secondClose > afterClose + 1 &&
lines.slice(afterClose + 1, secondClose).some(l => /^\s*[A-Za-z_][\w-]*\s*:/.test(l))
) {
errors.push({
code: 'MULTI_FRONTMATTER',
message:
'Stacked frontmatter: a second ---…--- block follows the frontmatter (double-put corruption); merge into a single frontmatter block',
line: afterClose + 1,
});
}
}
}
/**
+10
View File
@@ -1058,6 +1058,16 @@ export class PGLiteEngine implements BrainEngine {
RETURNING id, source_id, slug, type, title, compiled_truth, timeline, frontmatter, content_hash, created_at, updated_at, effective_date, effective_date_source, import_filename, source_kind, source_uri, ingested_via, ingested_at`,
[sourceId, slug, page.type, pageKind, page.title, page.compiled_truth, page.timeline || '', JSON.stringify(frontmatter), hash, effectiveDate, effectiveDateSource, importFilename, chunkerVersion, sourcePath, sourceKind, sourceUri, ingestedVia, ingestedAt]
);
// #2189: an INSERT … ON CONFLICT DO UPDATE … RETURNING that yields 0 rows
// (e.g. a BEFORE trigger suppressing the write) previously crashed in
// rowToPage with an opaque "undefined is not an object (row.deleted_at)".
// Throw a diagnosable error naming the row instead. Mirrors postgres-engine.ts.
if (!rows[0]) {
throw new Error(
`putPage: INSERT … RETURNING produced no row for slug='${slug}' source_id='${sourceId}'. ` +
`A trigger or rule on the pages table may be suppressing the write.`
);
}
return rowToPage(rows[0] as Record<string, unknown>);
}
+10
View File
@@ -1119,6 +1119,16 @@ export class PostgresEngine implements BrainEngine {
ingested_at = COALESCE(EXCLUDED.ingested_at, pages.ingested_at)
RETURNING id, source_id, slug, type, title, compiled_truth, timeline, frontmatter, content_hash, created_at, updated_at, effective_date, effective_date_source, import_filename, source_kind, source_uri, ingested_via, ingested_at
`;
// #2189: an INSERT … ON CONFLICT DO UPDATE … RETURNING that yields 0 rows
// (e.g. a BEFORE trigger suppressing the write) previously crashed in
// rowToPage with an opaque "undefined is not an object (row.deleted_at)".
// Throw a diagnosable error naming the row instead. Mirrors pglite-engine.ts.
if (!rows[0]) {
throw new Error(
`putPage: INSERT … RETURNING produced no row for slug='${slug}' source_id='${sourceId}'. ` +
`A trigger or rule on the pages table may be suppressing the write.`
);
}
return rowToPage(rows[0]);
}
+1 -70
View File
@@ -1,8 +1,4 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'child_process';
import { join, resolve } from 'path';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { describe, expect, test } from 'bun:test';
import { parseOpArgs } from '../src/cli.ts';
import { operationsByName } from '../src/core/operations.ts';
@@ -24,70 +20,5 @@ describe('parseOpArgs', () => {
source_id: 'gstack-code-repo-0e4763c9',
});
});
describe('positional/flag overwrite warning (#2822)', () => {
const errors: string[] = [];
const origError = console.error;
const captureErrors = () => {
console.error = (...args: unknown[]) => errors.push(args.join(' '));
};
afterEach(() => {
console.error = origError;
errors.length = 0;
});
test('a flag that overwrites a positional value warns to stderr', () => {
captureErrors();
const params = parseOpArgs(operationsByName.query, ['positional text', '--query', 'flag text']);
expect(params.query).toBe('flag text');
expect(errors.some(e => e.includes('Warning') && e.includes('--query'))).toBe(true);
});
test('a positional that overwrites an earlier flag value warns to stderr', () => {
captureErrors();
const params = parseOpArgs(operationsByName.query, ['--query', 'flag text', 'positional text']);
expect(params.query).toBe('positional text');
expect(errors.some(e => e.includes('Warning') && e.includes('<query>'))).toBe(true);
});
test('no warning when flag and positional agree', () => {
captureErrors();
parseOpArgs(operationsByName.query, ['same', '--query', 'same']);
expect(errors).toEqual([]);
});
});
});
describe('gbrain put — empty non-TTY stdin rejects (#2822)', () => {
const REPO = resolve(import.meta.dir, '..');
const CLI = join(REPO, 'src', 'cli.ts');
const runPut = (input: string) => {
// Isolated HOME so a regression can never write into a real brain.
const home = mkdtempSync(join(tmpdir(), 'gbrain-put-empty-'));
try {
return spawnSync('bun', [CLI, 'put', 'inbox/empty-stdin-test'], {
stdio: ['pipe', 'pipe', 'pipe'],
input,
encoding: 'utf-8',
timeout: 60_000,
env: { ...process.env, HOME: home, GBRAIN_SKIP_STARTUP_HOOKS: '1' },
});
} finally {
rmSync(home, { recursive: true, force: true });
}
};
test('empty stdin exits 1 and names the missing content param', () => {
const res = runPut('');
expect(res.status).toBe(1);
expect(res.stderr).toContain('content');
expect(res.stderr).toContain('stdin');
}, 90_000);
test('whitespace-only stdin also exits 1', () => {
const res = runPut(' \n\t\n');
expect(res.status).toBe(1);
expect(res.stderr).toContain('stdin');
}, 90_000);
});
-29
View File
@@ -708,32 +708,3 @@ body unchanged
expect(shortCircuited).toBe(true);
});
});
describe('importFromContent — empty content guard (#2822)', () => {
test('empty string throws instead of writing an invisible 0-chunk page', async () => {
const engine = mockEngine();
await expect(importFromContent(engine, 'inbox/empty', '', { noEmbed: true })).rejects.toThrow(/empty/i);
expect((engine as any)._calls.find((c: any) => c.method === 'putPage')).toBeUndefined();
});
test('whitespace-only content throws', async () => {
const engine = mockEngine();
await expect(importFromContent(engine, 'inbox/ws', ' \n\t \n', { noEmbed: true })).rejects.toThrow(/empty/i);
});
});
describe('importFromContent — stacked frontmatter rejection (#2743)', () => {
test('double-put shaped content (two ---…--- blocks) throws MULTI_FRONTMATTER', async () => {
const engine = mockEngine();
const md = '---\ntitle: outer\n---\n\n---\ntitle: inner\ntype: concept\n---\n\nreal body';
await expect(importFromContent(engine, 'inbox/double', md, { noEmbed: true })).rejects.toThrow(/MULTI_FRONTMATTER/);
expect((engine as any)._calls.find((c: any) => c.method === 'putPage')).toBeUndefined();
});
test('normal content with horizontal rules in the body still imports', async () => {
const engine = mockEngine();
const md = '---\ntitle: ok\ntype: concept\n---\n\nprose before\n\n---\n\nprose after the rule';
const result = await importFromContent(engine, 'inbox/hrule', md, { noEmbed: true });
expect(result.status).toBe('imported');
});
});
-50
View File
@@ -256,56 +256,6 @@ body`;
});
});
describe('MULTI_FRONTMATTER (#2743)', () => {
test('stacked frontmatter immediately after the close fence', () => {
const md = `${fence}\ntitle: outer\n${fence}\n${fence}\ntitle: inner\ntype: concept\n${fence}\n\nbody`;
const parsed = parseMarkdown(md, undefined, { validate: true });
expect(parsed.errors!.map(e => e.code)).toContain('MULTI_FRONTMATTER');
});
test('stacked frontmatter with a blank line between blocks (serializeMarkdown shape)', () => {
const md = `${fence}\ntitle: outer\n${fence}\n\n${fence}\ntitle: inner\n${fence}\n\nbody`;
const parsed = parseMarkdown(md, undefined, { validate: true });
expect(parsed.errors!.map(e => e.code)).toContain('MULTI_FRONTMATTER');
});
test('horizontal rules in the body are NOT flagged', () => {
const md = `${fence}\ntitle: ok\n${fence}\n\nsome prose\n\n${fence}\n\nmore prose\n\n${fence}\n\nend`;
const parsed = parseMarkdown(md, undefined, { validate: true });
expect(parsed.errors!.map(e => e.code)).not.toContain('MULTI_FRONTMATTER');
});
test('hrule pair at body start without YAML-shaped lines is NOT flagged', () => {
const md = `${fence}\ntitle: ok\n${fence}\n\n${fence}\n\nplain prose between rules\n\n${fence}\n\nend`;
const parsed = parseMarkdown(md, undefined, { validate: true });
expect(parsed.errors!.map(e => e.code)).not.toContain('MULTI_FRONTMATTER');
});
test('timeline sentinel form is NOT flagged', () => {
const md = `${fence}\ntitle: ok\n${fence}\n\nbody text\n\n${fence}\n\n## Timeline\n- 2024-01-01: thing`;
const parsed = parseMarkdown(md, undefined, { validate: true });
expect(parsed.errors!.map(e => e.code)).not.toContain('MULTI_FRONTMATTER');
});
test('body hrule + colon-prefixed prose (`Note: …`) mixed with plain lines is NOT flagged', () => {
const md = `${fence}\ntitle: ok\ntype: concept\n${fence}\n\n${fence}\n\nNote: remember to follow up\n\nlots of plain prose here\n\n${fence}\n\nmore prose`;
const parsed = parseMarkdown(md, undefined, { validate: true });
expect(parsed.errors!.map(e => e.code)).not.toContain('MULTI_FRONTMATTER');
});
test('fence pairing stops at the first non-frontmatter line (no far-fence pairing across prose)', () => {
const md = `${fence}\ntitle: ok\n${fence}\n\n${fence}\n\n${'plain prose line\n'.repeat(40)}TODO: fix the widget\n${'more prose\n'.repeat(40)}${fence}\nend`;
const parsed = parseMarkdown(md, undefined, { validate: true });
expect(parsed.errors!.map(e => e.code)).not.toContain('MULTI_FRONTMATTER');
});
test('stacked block with list-valued keys is still flagged', () => {
const md = `${fence}\ntitle: outer\n${fence}\n\n${fence}\ntitle: inner\ntags:\n - a\n - b\n${fence}\n\nbody`;
const parsed = parseMarkdown(md, undefined, { validate: true });
expect(parsed.errors!.map(e => e.code)).toContain('MULTI_FRONTMATTER');
});
});
test('error.line is set for line-bearing errors', () => {
const md = `${fence}\ntype: concept\n${fence}\n# Heading inline\n\nbody\x00drop`;
const parsed = parseMarkdown(md, undefined, { validate: true });
+74
View File
@@ -0,0 +1,74 @@
// #2189 regression guard: putPage's INSERT … ON CONFLICT DO UPDATE … RETURNING
// can yield 0 rows when brain-local DB state (e.g. a BEFORE INSERT trigger)
// suppresses the write. Pre-fix, rowToPage(rows[0]) crashed with the opaque
// "undefined is not an object (evaluating 'row.deleted_at')" that failed
// ~all files of a code sync. Post-fix, putPage throws a descriptive error
// naming the slug + source_id so the failure is diagnosable per-file.
//
// Same guard lands in postgres-engine.ts (engine-parity invariant); this test
// exercises the PGLite side, where the issue was reported.
import { describe, expect, test, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
// Simulate the reporter's state-dependent failure: a trigger that
// suppresses inserts for one slug, making RETURNING produce no row.
await engine.executeRaw(`
CREATE OR REPLACE FUNCTION suppress_pages_insert() RETURNS trigger AS $$
BEGIN
IF NEW.slug = 'suppressed-page' THEN RETURN NULL; END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
`);
await engine.executeRaw(`
CREATE TRIGGER suppress_pages_insert_trg
BEFORE INSERT ON pages
FOR EACH ROW EXECUTE FUNCTION suppress_pages_insert();
`);
});
afterAll(async () => {
await engine.executeRaw('DROP TRIGGER IF EXISTS suppress_pages_insert_trg ON pages');
await engine.executeRaw('DROP FUNCTION IF EXISTS suppress_pages_insert');
await engine.disconnect();
});
describe('putPage RETURNING guard (#2189)', () => {
test('0-row RETURNING throws a descriptive error, not row.deleted_at TypeError', async () => {
let err: Error | undefined;
try {
await engine.putPage('suppressed-page', {
type: 'code',
title: 'Suppressed',
compiled_truth: 'x',
timeline: '',
});
} catch (e) {
err = e as Error;
}
expect(err).toBeDefined();
expect(err!.message).toContain('putPage');
expect(err!.message).toContain("slug='suppressed-page'");
expect(err!.message).toContain("source_id='default'");
// The pre-fix crash signature must be gone.
expect(err!.message).not.toContain('deleted_at');
});
test('unsuppressed slugs still upsert normally with the trigger installed', async () => {
const page = await engine.putPage('normal-page', {
type: 'concept',
title: 'Normal',
compiled_truth: 'y',
timeline: '',
});
expect(page.slug).toBe('normal-page');
expect(page.source_id).toBe('default');
});
});
+25
View File
@@ -375,6 +375,31 @@ describe('performSync dry-run never writes', () => {
expect(messages.some(m => m.includes('git pull failed'))).toBe(false);
});
test('first PGLite code sync imports code files without runtime failures', async () => {
const { performSync } = await import('../src/commands/sync.ts');
mkdirSync(join(repoPath, 'src'), { recursive: true });
writeFileSync(
join(repoPath, 'src/example.ts'),
'export function add(left: number, right: number) { return left + right; }\n',
);
execSync('git add -A && git commit -m "add code file"', { cwd: repoPath, stdio: 'pipe' });
const result = await performSync(engine, {
repoPath,
noPull: true,
noEmbed: true,
noExtract: true,
strategy: 'code',
});
expect(result.status).toBe('first_sync');
expect(result.added).toBe(1);
expect(result.failedFiles ?? 0).toBe(0);
const page = await engine.getPage('src-example-ts');
expect(page?.type).toBe('code');
expect(page?.frontmatter).toMatchObject({ file: 'src/example.ts', language: 'typescript' });
});
test('incremental dry-run does NOT write to DB or advance the bookmark', async () => {
const { performSync } = await import('../src/commands/sync.ts');
// First do a real sync to seed the bookmark.