mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* fix(sync): op_checkpoints pin write double-encodes jsonb — every sync aborts (#2339) recordCompleted bound JSON.stringify(array) to a $3::jsonb param via postgres.js .unsafe(), double-encoding it into a jsonb string scalar that violates the v119 op_checkpoints_completed_keys_array CHECK — aborting every multi-source sync on real Postgres at the first checkpoint write. PGLite parses the string silently, which is why unit tests stayed green and it shipped. Cast through $3::text::jsonb so the text->jsonb cast parses a genuine array. Adds a DATABASE_URL-gated parity test + a dedicated Postgres CI job so the guard can never silently skip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(db): sweep positional jsonb double-encode sites + AST CI guard (#2324) Every executeRaw/.unsafe site that bound JSON.stringify(x) to a bare positional jsonb cast double-encodes on real Postgres (same class as #2339). Sweep them all to the text::jsonb form across query-cache, sources-ops, llm-base, calibration-profile, impact-capture, subagent, receipt-write, traversal-cache, symbol-resolver, and the agent/sources commands. Adds scripts/check-jsonb-params.mjs (AST-lite scanner for the positional form the legacy template grep misses, incl. generic-typed calls), wired into check-jsonb-pattern.sh, with a self-test. PGLite's native db.query is not scanned — it parses text to jsonb natively, so the bug can't occur there. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(search,eval): alias-hop injected results carry page_id (contradiction-probe crash) applyAliasHop injected synthetic SearchResults without page_id (the `as SearchResult` cast hid the missing field), so listActiveTakesForPages bound undefined/NaN into ANY($1::int[]) and crashed the whole contradiction probe on real Postgres. Stamp page_id=page.id at the injection site and add a finite-id filter in generateIntraPagePairs as a defensive backstop (mirrors hybrid.ts:63). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(engines): positional jsonb binding rule (text::jsonb vs the double-encode trap) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * v0.42.53.0 fix(sync,db): #2339 op_checkpoints jsonb double-encode + bug-class sweep + CI guard Bumps VERSION + package.json to 0.42.53.0, adds the CHANGELOG entry, and regenerates llms-full.txt. Ships the #2339 sync-abort hotfix, the repo-wide positional jsonb double-encode sweep, the alias-hop contradiction-probe crash fix, and the new positional-form CI guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: post-ship sync — jsonb invariant now covers the positional form + new guard CLAUDE.md JSONB invariant + KEY_FILES (sql-query, check-jsonb-pattern, op-checkpoint) now describe the #2339 positional double-encode class, the $N::text::jsonb fix, and the new check-jsonb-params.mjs guard. Regenerates llms-full.txt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
129 lines
5.9 KiB
JavaScript
129 lines
5.9 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* CI guard for the POSITIONAL jsonb double-encode footgun (#2339 / #2324 class).
|
|
*
|
|
* The legacy scripts/check-jsonb-pattern.sh only catches the template-tag form
|
|
* (`${JSON.stringify(x)}::jsonb`). It MISSES the positional-param form:
|
|
*
|
|
* engine.executeRaw(`... $3::jsonb ...`, [a, b, JSON.stringify(x)])
|
|
*
|
|
* Under postgres.js `.unsafe(sql, params)` a JS STRING bound to a `$N::jsonb`
|
|
* param double-encodes — the text→jsonb cast wraps the already-JSON string into a
|
|
* jsonb *string scalar*. PGLite parses it silently, so the bug is invisible in
|
|
* unit tests and only bites on real Postgres (it aborted every sync in #2339).
|
|
*
|
|
* This scanner flags any executeRaw / executeRawDirect / .unsafe(...) call whose
|
|
* balanced argument span contains BOTH a positional `$N::jsonb` cast
|
|
* (NOT `$N::text::jsonb`, NOT `$N::text[]`) AND a `JSON.stringify(` — the exact
|
|
* double-encode shape. It is heuristic by design (whole-span correlation); the
|
|
* real backstop is the DATABASE_URL-gated e2e parity test. Keep both.
|
|
*
|
|
* Allowed forms (NOT flagged):
|
|
* - `$N::text::jsonb` + JSON.stringify (the fix: binds as text, cast parses it)
|
|
* - `$N::text[]` (the unnest path — arrays bind fine)
|
|
* - executeRawJsonb(...) (passes raw objects, not strings)
|
|
* - sql.json(x) (postgres.js native jsonb serializer)
|
|
* - a `jsonb-guard-ok` comment anywhere in the call span (explicit opt-out)
|
|
*
|
|
* Exit 0 = clean, 1 = violations found. Runs under node or bun.
|
|
*/
|
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
|
|
// Default scan roots; overridable via argv so the guard's own test can point it
|
|
// at a fixture dir (e.g. `node check-jsonb-params.mjs /tmp/fixtures`).
|
|
const ROOTS = process.argv.slice(2).length > 0 ? process.argv.slice(2) : ['src', 'scripts'];
|
|
// executeRawDirect must precede executeRaw in the alternation so the longer name
|
|
// wins; executeRawJsonb is deliberately excluded (it passes objects). The
|
|
// optional `<...>` handles generic type args, e.g. `executeRaw<{ id: string }>(`.
|
|
//
|
|
// Only the postgres.js raw path is scanned (executeRaw/executeRawDirect/.unsafe).
|
|
// PGLite's native `this.db.query(...)` is intentionally NOT matched: its driver
|
|
// parses a text→jsonb cast natively, so the double-encode that bites postgres.js
|
|
// `.unsafe()` does not occur there (the `pglite-masks` invariant). The engine
|
|
// parity test pins that the resulting jsonb_typeof agrees across both engines.
|
|
const CALL_RE = /\b(executeRawDirect|executeRaw|unsafe)\s*(?:<[^>;]*>)?\s*\(/g;
|
|
|
|
/** Walk from the '(' at openIdx and return [start,end) of the balanced span,
|
|
* respecting strings, template literals, and comments. */
|
|
function findSpan(src, openIdx) {
|
|
let depth = 0;
|
|
let mode = 'code'; // code | line | block | sq | dq | tpl
|
|
for (let i = openIdx; i < src.length; i++) {
|
|
const c = src[i];
|
|
const n = src[i + 1];
|
|
if (mode === 'line') { if (c === '\n') mode = 'code'; continue; }
|
|
if (mode === 'block') { if (c === '*' && n === '/') { mode = 'code'; i++; } continue; }
|
|
if (mode === 'sq') { if (c === '\\') { i++; continue; } if (c === "'") mode = 'code'; continue; }
|
|
if (mode === 'dq') { if (c === '\\') { i++; continue; } if (c === '"') mode = 'code'; continue; }
|
|
if (mode === 'tpl') { if (c === '\\') { i++; continue; } if (c === '`') mode = 'code'; continue; }
|
|
// mode === 'code'
|
|
if (c === '/' && n === '/') { mode = 'line'; i++; continue; }
|
|
if (c === '/' && n === '*') { mode = 'block'; i++; continue; }
|
|
if (c === "'") { mode = 'sq'; continue; }
|
|
if (c === '"') { mode = 'dq'; continue; }
|
|
if (c === '`') { mode = 'tpl'; continue; }
|
|
if (c === '(') depth++;
|
|
else if (c === ')') { depth--; if (depth === 0) return [openIdx + 1, i]; }
|
|
}
|
|
return [openIdx + 1, src.length];
|
|
}
|
|
|
|
/** Blank out comments so a commented-out example doesn't trip the JSON.stringify probe. */
|
|
function stripComments(s) {
|
|
return s.replace(/\/\/[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
}
|
|
|
|
const violations = [];
|
|
|
|
function scanFile(file) {
|
|
const src = readFileSync(file, 'utf8');
|
|
CALL_RE.lastIndex = 0;
|
|
let m;
|
|
while ((m = CALL_RE.exec(src))) {
|
|
const method = m[1];
|
|
const openIdx = m.index + m[0].length - 1; // index of the '('
|
|
const [s, e] = findSpan(src, openIdx);
|
|
const span = src.slice(s, e);
|
|
if (/jsonb-guard-ok/.test(span)) continue;
|
|
if (!/JSON\.stringify\s*\(/.test(stripComments(span))) continue;
|
|
// A positional `$N::jsonb` that is NOT `$N::text::jsonb`.
|
|
const jsonbRe = /\$\d+\s*::\s*jsonb\b/g;
|
|
let j;
|
|
let badText = '';
|
|
while ((j = jsonbRe.exec(span))) {
|
|
const pre = span.slice(Math.max(0, j.index - 12), j.index);
|
|
if (/::\s*text\s*$/.test(pre)) continue; // $N::text::jsonb is the fix — allowed
|
|
badText = j[0].replace(/\s+/g, '');
|
|
break;
|
|
}
|
|
if (!badText) continue;
|
|
const line = src.slice(0, s).split('\n').length;
|
|
violations.push(
|
|
`${file}:${line} ${method}(...) binds JSON.stringify into ${badText} — use $N::text::jsonb or pass a raw object (executeRawJsonb / sql.json)`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function walk(dir) {
|
|
let ents;
|
|
try { ents = readdirSync(dir); } catch { return; }
|
|
for (const ent of ents) {
|
|
if (ent === 'node_modules') continue;
|
|
const p = join(dir, ent);
|
|
const st = statSync(p);
|
|
if (st.isDirectory()) walk(p);
|
|
else if (p.endsWith('.ts') && !p.endsWith('.test.ts')) scanFile(p);
|
|
}
|
|
}
|
|
|
|
for (const root of ROOTS) walk(root);
|
|
|
|
if (violations.length) {
|
|
console.error('JSONB positional double-encode violations (#2339 class):\n');
|
|
for (const v of violations) console.error(' ' + v);
|
|
console.error(`\n${violations.length} violation(s). Fix: bind through $N::text::jsonb (keeping JSON.stringify), or pass a raw object via executeRawJsonb / sql.json. See docs/ENGINES.md.`);
|
|
process.exit(1);
|
|
}
|
|
console.log('check-jsonb-params: clean (no positional $N::jsonb + JSON.stringify double-encodes)');
|