fix(facts): parse escaped pipes in facts fence round-trips (#2726) (#2738)

parseRowCells now splits on unescaped pipes only and decodes escaped pipes while preserving ordinary backslashes and empty cells, so facts whose text contains literal | survive the fence->DB reconcile instead of being silently deleted. Fixes #2726.
This commit is contained in:
Time Attakc
2026-07-13 00:09:16 -07:00
committed by GitHub
parent 68ed7bafa4
commit 8e84c5b4a1
3 changed files with 137 additions and 10 deletions
+21 -10
View File
@@ -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, '\\|');
@@ -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);
});
+41
View File
@@ -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',