mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* fix(extract,ingest): well-form lone UTF-16 surrogates before jsonb (#2011) excerpt() in link-extraction.ts sliced the link-context window by raw UTF-16 index, so a boundary landing inside a non-BMP char (emoji, math, CJK) left an unpaired surrogate half in `context`. Serialized to JSONB for the jsonb_to_recordset batch insert, Postgres rejects it at the ::jsonb cast and aborts the whole batch — wedging `extract --stale` because the staleness bookmark only advances on a clean finish. - text-safe.ts: new ensureWellFormed() (Bun isWellFormed/toWellFormed) — one shared surrogate-cleaning primitive. - link-extraction.ts: excerpt() well-forms the slice (root-cause fix). - batch-rows.ts: new sanitizeForJsonb() = ensureWellFormed(stripNul(s)) applied to every free-text body field (link context; timeline summary/detail/source; take claim/source). Identity/security fields stay un-sanitized and fail closed. - postgres-engine.ts + pglite-engine.ts: scalar addLink + addTimelineEntry use sanitizeForJsonb too, matching the batch path on both engines. - brainstorm/orchestrator.ts: consolidate hand-rolled sanitizeUnicode onto ensureWellFormed (also fixes consecutive-lone-surrogate mishandling). Tests: ensureWellFormed unit cases (incl. consecutive lone surrogates), an excerpt window-split regression, PGLite + Postgres-e2e surrogate cases across all free-text fields and scalar paths, and fail-closed identity-field tests proving sanitization was NOT extended to slugs/holders. * v0.42.39.0 chore: bump version and changelog (#2011) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: update project documentation for v0.42.39.0 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * v0.42.40.0 chore: re-slot release version (was 0.42.39.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: fix env-mutation isolation violations in retrieval-reflex tests check:test-isolation (rule R1) flags direct process.env mutation in non-serial test files — bun's parallel runner loads multiple files into one process, so a leaked GBRAIN_RETRIEVAL_REFLEX flips reflex behavior in unrelated tests. Both files landed via the #2019 merge; convert the beforeEach/afterEach env juggling to the canonical withEnv() wrapper, which restores the prior value via try/finally even on throw. Fixes the failing `verify` CI check on #2031 (and the `test-status` aggregate that inherits it). All 30 verify checks green locally. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
201 lines
8.2 KiB
TypeScript
201 lines
8.2 KiB
TypeScript
/**
|
||
* Unit tests for src/core/text-safe.ts — UTF-16 surrogate-safe helpers.
|
||
*
|
||
* Covers all 3 surrogate cases (high+low pair straddle, stray high,
|
||
* AT-low) plus boundary-after-pair (codex CK16 regression evidence).
|
||
*
|
||
* `truncateUtf8` regressions live here AND in
|
||
* `test/eval-contradictions-judge.test.ts` (which imports it through
|
||
* judge.ts re-export, proving the move is byte-equivalent).
|
||
*/
|
||
|
||
import { describe, test, expect } from 'bun:test';
|
||
import { truncateUtf8, safeSplitIndex, ensureWellFormed } from '../src/core/text-safe.ts';
|
||
|
||
// 🚀 = U+1F680 (surrogate pair: 0xD83D 0xDE80; JS string length = 2).
|
||
// 𝕏 = U+1D54F (surrogate pair: 0xD835 0xDD4F; JS string length = 2).
|
||
// 𠀀 = U+20000 (non-BMP CJK: 0xD840 0xDC00; JS string length = 2).
|
||
const ROCKET = '🚀'; // 🚀
|
||
const MATH_X = '𝕏'; // 𝕏
|
||
const NBMP_HAN = '𠀀'; // 𠀀
|
||
const STRAY_HIGH = '\uD83D'; // orphaned high surrogate (invalid alone)
|
||
const STRAY_LOW = '\uDE80'; // orphaned low surrogate (invalid alone)
|
||
const REPLACEMENT = '�'; // U+FFFD, what toWellFormed() substitutes
|
||
|
||
describe('truncateUtf8', () => {
|
||
test('returns empty for empty input', () => {
|
||
expect(truncateUtf8('', 100)).toBe('');
|
||
});
|
||
|
||
test('returns unchanged when already under limit', () => {
|
||
expect(truncateUtf8('short', 100)).toBe('short');
|
||
});
|
||
|
||
test('truncates at ASCII boundary', () => {
|
||
expect(truncateUtf8('hello world', 5)).toBe('hello');
|
||
});
|
||
|
||
test('case 1: pair straddles cut — drops both halves', () => {
|
||
// text="a🚀b" (length 4: ['a', 0xD83D, 0xDE80, 'b']). Cut at maxChars=2
|
||
// would split between 0xD83D and 0xDE80. Expect "a" (length 1).
|
||
const out = truncateUtf8('a' + ROCKET + 'b', 2);
|
||
expect(out).toBe('a');
|
||
});
|
||
|
||
test('case 2: stray high surrogate at end-1 — drops it', () => {
|
||
// text="ab<HIGH>cd". Cut at maxChars=3 lands ON the high; unitBefore
|
||
// is 'b' (regular). Cut at maxChars=4 lands on 'c'; unitBefore is
|
||
// the high surrogate AND unitAtEnd is 'c' (not low) → stray high
|
||
// case. Expect "ab".
|
||
const text = 'ab' + STRAY_HIGH + 'cd';
|
||
const out = truncateUtf8(text, 3); // index 3 → unitBefore=HIGH, unitAtEnd='c'
|
||
expect(out).toBe('ab');
|
||
});
|
||
|
||
test('case 3: low surrogate at end-1 — backs up two (intentionally conservative)', () => {
|
||
// text="ab🚀cd" (length 6). Cut at maxChars=4: unitBefore=0xDE80 (low),
|
||
// unitAtEnd='c'. Case 3 fires → end = 4-2 = 2. Expect "ab".
|
||
// Note: pair was COMPLETE in kept half at [2,3]; conservative back-up
|
||
// drops it. Intentional — matches truncateUtf8's pre-extraction behavior
|
||
// verbatim. See safeSplitIndex doc for rationale.
|
||
const text = 'ab' + ROCKET + 'cd';
|
||
const out = truncateUtf8(text, 4);
|
||
expect(out).toBe('ab');
|
||
});
|
||
|
||
test('non-BMP CJK pair behaves identically to emoji', () => {
|
||
const text = 'x' + NBMP_HAN + 'y';
|
||
expect(truncateUtf8(text, 2)).toBe('x'); // case 1: cut splits the pair
|
||
});
|
||
|
||
test('multiple consecutive pairs preserved when cut is past them', () => {
|
||
const text = ROCKET + MATH_X + 'tail';
|
||
// Cut at maxChars=10 ≥ length=8 → returns full text.
|
||
expect(truncateUtf8(text, 10)).toBe(text);
|
||
});
|
||
});
|
||
|
||
describe('ensureWellFormed (#2011)', () => {
|
||
test('lone high surrogate → U+FFFD', () => {
|
||
const out = ensureWellFormed('before' + STRAY_HIGH + 'after');
|
||
expect(out).toBe('before' + REPLACEMENT + 'after');
|
||
expect(out.isWellFormed()).toBe(true);
|
||
});
|
||
|
||
test('lone low surrogate → U+FFFD', () => {
|
||
const out = ensureWellFormed('before' + STRAY_LOW + 'after');
|
||
expect(out).toBe('before' + REPLACEMENT + 'after');
|
||
expect(out.isWellFormed()).toBe(true);
|
||
});
|
||
|
||
test('consecutive lone low surrogates → both replaced (the case the old regex got wrong)', () => {
|
||
// The prior hand-rolled two-pass regex produced "�\uDE80" here (the second
|
||
// lookbehind consumed the just-inserted boundary, leaving the 2nd low
|
||
// surrogate orphaned). The built-in replaces both → "��".
|
||
const out = ensureWellFormed(STRAY_LOW + STRAY_LOW);
|
||
expect(out).toBe(REPLACEMENT + REPLACEMENT);
|
||
expect(out.isWellFormed()).toBe(true);
|
||
});
|
||
|
||
test('consecutive lone high surrogates → both replaced', () => {
|
||
const out = ensureWellFormed(STRAY_HIGH + STRAY_HIGH);
|
||
expect(out).toBe(REPLACEMENT + REPLACEMENT);
|
||
expect(out.isWellFormed()).toBe(true);
|
||
});
|
||
|
||
test('valid pairs (emoji / math / non-BMP CJK) preserved unchanged', () => {
|
||
const text = 'a' + ROCKET + 'b' + MATH_X + 'c' + NBMP_HAN + 'd';
|
||
expect(ensureWellFormed(text)).toBe(text);
|
||
expect(ensureWellFormed(text).isWellFormed()).toBe(true);
|
||
});
|
||
|
||
test('clean ASCII / empty input returns an equal well-formed value', () => {
|
||
expect(ensureWellFormed('plain text')).toBe('plain text');
|
||
expect(ensureWellFormed('')).toBe('');
|
||
});
|
||
|
||
test('mixed valid pair + lone half: pair kept, orphan replaced', () => {
|
||
// Valid 🚀 then a stray high then text.
|
||
const out = ensureWellFormed(ROCKET + STRAY_HIGH + 'x');
|
||
expect(out).toBe(ROCKET + REPLACEMENT + 'x');
|
||
expect(out.isWellFormed()).toBe(true);
|
||
});
|
||
|
||
test('output is JSON-serializable without orphaned escapes', () => {
|
||
// The #2011 failure was Postgres ::jsonb rejecting a lone surrogate.
|
||
// After ensureWellFormed, a JSON round-trip preserves the value.
|
||
const out = ensureWellFormed('emoji ' + STRAY_HIGH + ' tail');
|
||
expect(JSON.parse(JSON.stringify(out))).toBe(out);
|
||
expect(out.isWellFormed()).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('safeSplitIndex', () => {
|
||
test('maxChars ≤ 0 returns 0', () => {
|
||
expect(safeSplitIndex('any', 0)).toBe(0);
|
||
expect(safeSplitIndex('any', -5)).toBe(0);
|
||
});
|
||
|
||
test('maxChars ≥ text.length returns text.length', () => {
|
||
expect(safeSplitIndex('abc', 3)).toBe(3);
|
||
expect(safeSplitIndex('abc', 100)).toBe(3);
|
||
});
|
||
|
||
test('maxChars in middle of ASCII text returns maxChars unchanged', () => {
|
||
expect(safeSplitIndex('hello world', 5)).toBe(5);
|
||
});
|
||
|
||
test('case 1: pair straddles cut → returns maxChars-1', () => {
|
||
// text="a🚀b" (length 4). maxChars=2: unitBefore=0xD83D, unitAtEnd=0xDE80.
|
||
expect(safeSplitIndex('a' + ROCKET + 'b', 2)).toBe(1);
|
||
});
|
||
|
||
test('case 2: stray high surrogate at maxChars-1 → returns maxChars-1', () => {
|
||
// text="ab<HIGH>cd". maxChars=3: unitBefore=HIGH, unitAtEnd='c'.
|
||
const text = 'ab' + STRAY_HIGH + 'cd';
|
||
expect(safeSplitIndex(text, 3)).toBe(2);
|
||
});
|
||
|
||
test('case 3: low at maxChars-1 → returns maxChars-2 (conservative)', () => {
|
||
// text="ab🚀cd" (length 6). maxChars=4: unitBefore=0xDE80 (low), unitAtEnd='c'.
|
||
// Pair COMPLETE in kept half; back-up is intentional per truncateUtf8 parity.
|
||
expect(safeSplitIndex('ab' + ROCKET + 'cd', 4)).toBe(2);
|
||
});
|
||
|
||
test('boundary-immediately-after-pair returns maxChars-2 (codex CK16 documented)', () => {
|
||
// Codex flagged this as "safe but overly conservative." We test the
|
||
// CURRENT conservative behavior so any future change is intentional.
|
||
// text="hello🚀" (length 7). maxChars=7 ≥ length → returns 7 (full).
|
||
// text="hello🚀x" (length 8). maxChars=7: unitBefore=0xDE80 (low), unitAtEnd='x'.
|
||
// Case 3 fires → returns 5. Documents the conservative back-up.
|
||
const text = 'hello' + ROCKET + 'x'; // length 8
|
||
expect(safeSplitIndex(text, 7)).toBe(5);
|
||
});
|
||
|
||
test('determinism: same input → same output across 100 calls', () => {
|
||
const text = 'lorem ' + ROCKET + ' ipsum ' + MATH_X + ' dolor ' + NBMP_HAN;
|
||
const refs = new Set<number>();
|
||
for (let i = 0; i < 100; i++) refs.add(safeSplitIndex(text, 12));
|
||
expect(refs.size).toBe(1);
|
||
});
|
||
|
||
test('empty text returns 0', () => {
|
||
expect(safeSplitIndex('', 5)).toBe(0);
|
||
});
|
||
|
||
test('truncateUtf8 and safeSplitIndex agree on slice length', () => {
|
||
// Property check: truncateUtf8(text, n).length === safeSplitIndex(text, n)
|
||
// (modulo the empty-string early return which both treat identically).
|
||
const cases: Array<[string, number]> = [
|
||
['hello world', 5],
|
||
['a' + ROCKET + 'b', 2],
|
||
['ab' + STRAY_HIGH + 'cd', 3],
|
||
['ab' + ROCKET + 'cd', 4],
|
||
['hello' + ROCKET + 'x', 7],
|
||
];
|
||
for (const [text, n] of cases) {
|
||
expect(truncateUtf8(text, n).length).toBe(safeSplitIndex(text, n));
|
||
}
|
||
});
|
||
});
|