diff --git a/src/core/fence-shared.ts b/src/core/fence-shared.ts index c09d9d8c9..d7801ab30 100644 --- a/src/core/fence-shared.ts +++ b/src/core/fence-shared.ts @@ -27,17 +27,30 @@ * or has no second pipe). On a match, returns the cells with surrounding * whitespace trimmed, with the outer pipes already stripped. * - * NOTE: does NOT unescape `\|` back to `|`. Round-trip-on-pipes is a - * separate concern callers handle if their domain text legitimately - * contains pipes (currently neither takes nor facts do at the LLM-extract - * layer; if a hand-edit introduces one, escape-on-write at render time - * protects the table shape). + * Escaped pipes (`\|`) stay inside their cell and are decoded back to `|`. + * Other backslashes are preserved verbatim so existing fence text such as + * Windows paths remains byte-stable across a render/parse cycle. */ export function parseRowCells(line: string): string[] | null { const trimmed = line.trim(); if (!trimmed.startsWith('|') || !trimmed.includes('|', 1)) return null; const inner = trimmed.replace(/^\|/, '').replace(/\|$/, ''); - return inner.split('|').map(c => c.trim()); + const cells: string[] = []; + let cell = ''; + for (let i = 0; i < inner.length; i++) { + const char = inner[i]; + if (char === '\\' && inner[i + 1] === '|') { + cell += '|'; + i += 1; + } else if (char === '|') { + cells.push(cell.trim()); + cell = ''; + } else { + cell += char; + } + } + cells.push(cell.trim()); + return cells; } /** @@ -77,10 +90,8 @@ export function parseStringCell(raw: string): string | undefined { /** * Escape a value for safe placement inside a pipe-separated cell. Replaces - * any literal `|` with `\|` so the table layout stays intact. Inverse is - * not needed at parse time today (see parseRowCells note); a future - * `unescapeFenceCell` helper can land alongside any domain that needs to - * read pipes back out of cell text. + * any literal `|` with `\|` so the table layout stays intact. `parseRowCells` + * is the inverse and decodes the escape after identifying cell boundaries. */ export function escapeFenceCell(s: string): string { return s.replace(/\|/g, '\\|'); diff --git a/test/e2e/facts-fence-reconcile-postgres.test.ts b/test/e2e/facts-fence-reconcile-postgres.test.ts new file mode 100644 index 000000000..774b55f44 --- /dev/null +++ b/test/e2e/facts-fence-reconcile-postgres.test.ts @@ -0,0 +1,75 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { PostgresEngine } from '../../src/core/postgres-engine.ts'; +import { runExtractFacts } from '../../src/core/cycle/extract-facts.ts'; +import { parseFactsFence, renderFactsTable, type ParsedFact } from '../../src/core/facts-fence.ts'; + +const databaseUrl = process.env.DATABASE_URL; +const skip = !databaseUrl; + +if (skip) test.skip('facts-fence Postgres reconciliation skipped (DATABASE_URL unset)', () => {}); + +describe.skipIf(skip)('facts-fence escaped-pipe reconciliation on Postgres', () => { + const slug = 'people/facts-pipe-roundtrip-example'; + let engine: PostgresEngine; + + beforeAll(async () => { + engine = new PostgresEngine(); + await engine.connect({ database_url: databaseUrl! }); + await engine.initSchema(); + }); + + afterAll(async () => { + if (engine) { + await engine.executeRaw('DELETE FROM pages WHERE slug = $1', [slug]); + await engine.disconnect(); + } + }); + + test('render → parse → reconcile preserves pipes, backslashes, empty cells, and adjacent rows', async () => { + const facts: ParsedFact[] = [ + { + rowNum: 1, + claim: 'scores correct|incorrect|partial', + kind: 'fact', + confidence: 1, + visibility: 'world', + notability: 'high', + validFrom: '2026-07-10', + source: String.raw`consumer\facts|review`, + context: String.raw`left|right\tail`, + active: true, + }, + { + rowNum: 2, + claim: 'ordinary adjacent fact', + kind: 'fact', + confidence: 0.8, + visibility: 'private', + notability: 'medium', + active: true, + }, + ]; + const rendered = renderFactsTable(facts); + expect(parseFactsFence(rendered)).toMatchObject({ warnings: [], facts }); + + await engine.putPage(slug, { + title: 'Facts Pipe Roundtrip Example', + type: 'person', + compiled_truth: rendered, + frontmatter: {}, + timeline: '', + }); + const result = await runExtractFacts(engine, { slugs: [slug] }); + const rows = await engine.executeRaw<{ fact: string; row_num: number; source: string; context: string | null }>( + 'SELECT fact, row_num, source, context FROM facts WHERE source_markdown_slug = $1 ORDER BY row_num', + [slug], + ); + + expect(result.warnings.some(w => w.includes('FACTS_TABLE_MALFORMED'))).toBe(false); + expect(result.factsInserted).toBe(2); + expect(Array.from(rows)).toEqual([ + { fact: facts[0].claim, row_num: 1, source: facts[0].source!, context: facts[0].context! }, + { fact: facts[1].claim, row_num: 2, source: 'fence:reconcile', context: null }, + ]); + }, 30_000); +}); diff --git a/test/facts-fence.test.ts b/test/facts-fence.test.ts index 732d6ef66..30dbe7c1d 100644 --- a/test/facts-fence.test.ts +++ b/test/facts-fence.test.ts @@ -350,6 +350,47 @@ describe('renderFactsTable', () => { // ───────────────────────────────────────────────────────────────── describe('round-trip: render then parse returns equivalent rows', () => { + test('preserves escaped pipes, backslashes, empty cells, and adjacent ordinary rows', () => { + const originals: ParsedFact[] = [ + minimalFact(1, { + claim: 'scores correct|incorrect|partial', + validFrom: '2026-07-10', + validUntil: undefined, + source: String.raw`consumer\facts|review`, + context: String.raw`left|right\tail`, + }), + minimalFact(2, { + claim: 'ordinary adjacent fact', + validFrom: undefined, + validUntil: undefined, + source: undefined, + context: undefined, + }), + ]; + + const rendered = renderFactsTable(originals); + expect(rendered).toContain(String.raw`scores correct\|incorrect\|partial`); + expect(rendered).toContain(String.raw`consumer\facts\|review`); + + const reparsed = parseFactsFence(rendered); + expect(reparsed.warnings).toEqual([]); + expect(reparsed.facts).toHaveLength(2); + expect(reparsed.facts[0]).toMatchObject({ + claim: originals[0].claim, + validFrom: originals[0].validFrom, + validUntil: undefined, + source: originals[0].source, + context: originals[0].context, + }); + expect(reparsed.facts[1]).toMatchObject({ + claim: originals[1].claim, + validFrom: undefined, + validUntil: undefined, + source: undefined, + context: undefined, + }); + }); + test('canonical row survives render+parse with all fields intact', () => { const original: ParsedFact = minimalFact(1, { claim: 'Founded Acme in 2017',