easyvibegate 0.4.4 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,183 +1,590 @@
1
1
  import { lineAt } from '../../util/text.js';
2
- const IDENT = '(?:"[^"]+"|`[^`]+`|[A-Za-z_][A-Za-z0-9_$]*)';
3
- const QUALIFIED = `(?:(${IDENT})\\s*\\.\\s*)?(${IDENT})`;
4
- const CREATE_TABLE = new RegExp(`create\\s+table\\s+(if\\s+not\\s+exists\\s+)?${QUALIFIED}`, 'gi');
5
- const RLS_STMT = new RegExp(`alter\\s+table\\s+(?:only\\s+)?${QUALIFIED}\\s+(enable|disable)\\s+row\\s+level\\s+security`, 'gi');
6
- const DROP_TABLE = new RegExp(`drop\\s+table\\s+(?:if\\s+exists\\s+)?${QUALIFIED}`, 'gi');
7
- // SELECT ... INTO <table> also creates a table.
8
- const SELECT_INTO = new RegExp(`\\bselect\\b[^;]{0,400}?\\binto\\s+${QUALIFIED}`, 'gis');
2
+ import { lexSql } from '../../util/sql-lex.js';
9
3
  /** Normalize one SQL identifier: quoted keeps case, unquoted folds to lowercase. */
10
- function normIdent(raw) {
11
- if (raw.startsWith('"') && raw.endsWith('"'))
12
- return raw.slice(1, -1);
13
- if (raw.startsWith('`') && raw.endsWith('`'))
14
- return raw.slice(1, -1);
15
- return raw.toLowerCase();
4
+ function normIdent(raw, quoted) {
5
+ return quoted ? raw : raw.toLowerCase();
16
6
  }
17
- const keyOf = (schema, table) => `${schema ? normIdent(schema) : 'public'}.${normIdent(table)}`;
18
- const displayOf = (schema, table) => (schema ? `${schema}.${table}` : table);
19
7
  /**
20
- * Blank out comments and string literals (keeping newlines and offsets) so that
21
- * SQL inside a string — e.g. SELECT 'ALTER TABLE x ENABLE ROW LEVEL SECURITY' —
22
- * or inside a comment is never mistaken for an executed statement.
23
- * Handles -- and block comments, '...' with '' escapes (and E'...'), and
24
- * $$ / $tag$ dollar-quoted strings. Double-quoted identifiers are kept.
8
+ * A table key is `schema NUL table`. Joining with `.` let `public."a.b"` and
9
+ * `"public.a".b` collide into one string; NUL can never occur in an identifier.
25
10
  */
26
- export function maskSql(sql) {
27
- const out = sql.split('');
28
- const n = sql.length;
29
- const blank = (from, to) => {
30
- for (let k = from; k < to && k < n; k++)
31
- if (out[k] !== '\n')
32
- out[k] = ' ';
11
+ const SEP = '\u0000';
12
+ /**
13
+ * Session-private namespace. PostgreSQL looks in pg_temp FIRST for an existing
14
+ * table, so after `CREATE TEMP TABLE orders` an unqualified `DROP TABLE orders`
15
+ * removes the temp one and leaves the permanent `public.orders` — and its RLS
16
+ * state — untouched. Temp tables are modelled for that shadowing only; they are
17
+ * never reported (PostgREST cannot see them).
18
+ */
19
+ const TEMP_SCHEMA = 'pg_temp';
20
+ const keyFor = (schema, table) => `${schema}${SEP}${table}`;
21
+ const isTempKey = (key) => key.startsWith(TEMP_SCHEMA + SEP);
22
+ const isWord = (t, w) => !!t && t.type === 'word' && t.value.toUpperCase() === w;
23
+ const isWordIn = (t, ws) => !!t && t.type === 'word' && ws.includes(t.value.toUpperCase());
24
+ const isPunct = (t, p) => !!t && t.type === 'punct' && t.value === p;
25
+ const isName = (t) => !!t && (t.type === 'word' || t.type === 'quotedIdent');
26
+ const isLiteral = (t) => !!t && (t.type === 'string' || t.type === 'dollarString');
27
+ const nameOf = (t) => ({ text: t.value, quoted: t.type === 'quotedIdent' });
28
+ /** Read `[schema .] table` at `i`; returns the names and the index after them. */
29
+ function readQualified(ts, i) {
30
+ const first = ts[i];
31
+ if (!isName(first))
32
+ return null;
33
+ const dot = ts[i + 1];
34
+ const second = ts[i + 2];
35
+ if (dot && dot.type === 'punct' && dot.value === '.' && isName(second)) {
36
+ return { schema: nameOf(first), table: nameOf(second), next: i + 3 };
37
+ }
38
+ return { table: nameOf(first), next: i + 1 };
39
+ }
40
+ /** Skip an optional `IF EXISTS` / `IF NOT EXISTS`; returns [nextIndex, seen]. */
41
+ function skipIfExists(ts, i) {
42
+ if (!isWord(ts[i], 'IF'))
43
+ return [i, false];
44
+ if (isWord(ts[i + 1], 'NOT') && isWord(ts[i + 2], 'EXISTS'))
45
+ return [i + 3, true];
46
+ if (isWord(ts[i + 1], 'EXISTS'))
47
+ return [i + 2, true];
48
+ return [i, false];
49
+ }
50
+ /**
51
+ * Token spans inside a DO body whose statements are not known to take effect.
52
+ * No interpreter is needed to know that a branch may not be taken, a loop may
53
+ * run zero times, nothing after `RETURN` in the same block runs, and a block
54
+ * with an `EXCEPTION` section is rolled back entirely if the handler fires.
55
+ * Constructs nest, so they are matched with a stack. Returns index ranges.
56
+ */
57
+ function guardedRanges(ts) {
58
+ const ranges = [];
59
+ const stack = [];
60
+ const close = (f, i) => {
61
+ // A plain BEGIN block is straight-line code; only an EXCEPTION section
62
+ // turns its whole body (and the handlers) into a maybe.
63
+ if (f.kind !== 'BEGIN' || f.exception)
64
+ ranges.push([f.at, i]);
65
+ if (f.returnAt !== undefined)
66
+ ranges.push([f.returnAt, i]);
33
67
  };
34
- let i = 0;
35
- while (i < n) {
36
- const ch = sql[i];
37
- const next = sql[i + 1];
38
- if (ch === '-' && next === '-') {
39
- let j = i;
40
- while (j < n && sql[j] !== '\n')
41
- j++;
42
- blank(i, j);
43
- i = j;
68
+ for (let i = 0; i < ts.length; i++) {
69
+ const t = ts[i];
70
+ if (!t || t.type !== 'word')
71
+ continue;
72
+ const W = t.value.toUpperCase();
73
+ if (W === 'END') {
74
+ // `END IF` / `END LOOP` / `END CASE` name what they close; a bare END
75
+ // closes the innermost BEGIN block or CASE expression.
76
+ const f = stack.pop();
77
+ if (f)
78
+ close(f, i);
79
+ if (isWordIn(ts[i + 1], ['IF', 'LOOP', 'CASE']))
80
+ i++;
44
81
  continue;
45
82
  }
46
- if (ch === '/' && next === '*') {
47
- const close = sql.indexOf('*/', i + 2);
48
- const end = close === -1 ? n : close + 2;
49
- blank(i, end);
50
- i = end;
83
+ if (W === 'IF') {
84
+ // `IF EXISTS` / `IF NOT EXISTS` is a DDL clause, not a branch — reading it
85
+ // as one would leave a guard open to the end of the body.
86
+ if (isWord(ts[i + 1], 'EXISTS') || (isWord(ts[i + 1], 'NOT') && isWord(ts[i + 2], 'EXISTS')))
87
+ continue;
88
+ stack.push({ kind: 'IF', at: i });
51
89
  continue;
52
90
  }
53
- if (ch === "'") {
54
- let j = i + 1;
55
- while (j < n) {
56
- if (sql[j] === "'") {
57
- if (sql[j + 1] === "'") {
58
- j += 2;
59
- continue;
60
- }
91
+ if (W === 'LOOP' || W === 'CASE' || W === 'BEGIN') {
92
+ stack.push({ kind: W, at: i });
93
+ continue;
94
+ }
95
+ if (W === 'EXCEPTION' && !isWord(ts[i - 1], 'RAISE')) {
96
+ // The section belongs to the innermost BEGIN block, not to an IF inside it.
97
+ for (let d = stack.length - 1; d >= 0; d--) {
98
+ const f = stack[d];
99
+ if (f && f.kind === 'BEGIN') {
100
+ f.exception = true;
61
101
  break;
62
102
  }
63
- j++;
64
103
  }
65
- const end = Math.min(j + 1, n);
66
- blank(i, end);
67
- i = end;
68
104
  continue;
69
105
  }
70
- if (ch === '$') {
71
- const m = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(i, i + 64));
72
- if (m) {
73
- const tag = m[0];
74
- const close = sql.indexOf(tag, i + tag.length);
75
- const end = close === -1 ? n : close + tag.length;
76
- // `DO $$ ... $$` is executed procedural code: its DDL is real, so keep the
77
- // body visible. Other dollar-quoted strings (EXECUTE format(...)) are blanked.
78
- const isDoBlock = /\bdo\s*$/i.test(sql.slice(Math.max(0, i - 8), i));
79
- if (isDoBlock) {
80
- const bodyStart = i + tag.length;
81
- const bodyEnd = close === -1 ? n : close;
82
- // Mask inside the body too (a string literal there is still a string),
83
- // then jump past the CLOSING delimiter — scanning from just after the
84
- // opening one would read that closing `$$` as a new opening tag and
85
- // blank the entire rest of the file, hiding every later statement.
86
- const inner = maskSql(sql.slice(bodyStart, bodyEnd));
87
- for (let k = 0; k < inner.length; k++)
88
- out[bodyStart + k] = inner[k];
89
- blank(i, bodyStart);
90
- blank(bodyEnd, end);
91
- i = end;
92
- continue;
93
- }
94
- blank(i, end);
95
- i = end;
96
- continue;
97
- }
106
+ if (W === 'RETURN') {
107
+ const f = stack[stack.length - 1];
108
+ if (f && f.returnAt === undefined)
109
+ f.returnAt = i;
98
110
  }
99
- i++;
100
111
  }
101
- return out.join('');
112
+ while (stack.length)
113
+ close(stack.pop(), ts.length);
114
+ return ranges;
102
115
  }
103
116
  /**
104
- * Blank out only comments, preserving length and the `$$` delimiters that
105
- * `maskSql` removes. Guard analysis needs the delimiters to find DO blocks, but
106
- * must not read `-- end if` in a comment as a real block terminator.
117
+ * Flatten a file into tokens, splicing every `DO … ` body in place so its DDL
118
+ * is analyzed as executed code while the surrounding file keeps its own order.
119
+ * Returns the tokens plus the guarded index ranges.
107
120
  */
108
- function maskSqlComments(sql) {
109
- const out = sql.split('');
110
- const n = sql.length;
111
- const blank = (from, to) => {
112
- for (let k = from; k < to && k < n; k++)
113
- if (out[k] !== '\n')
114
- out[k] = ' ';
115
- };
116
- let i = 0;
117
- while (i < n) {
118
- if (sql[i] === '-' && sql[i + 1] === '-') {
119
- let j = i;
120
- while (j < n && sql[j] !== '\n')
121
- j++;
122
- blank(i, j);
123
- i = j;
124
- continue;
125
- }
126
- if (sql[i] === '/' && sql[i + 1] === '*') {
127
- const c = sql.indexOf('*/', i + 2);
128
- const end = c === -1 ? n : c + 2;
129
- blank(i, end);
130
- i = end;
121
+ function flatten(sql) {
122
+ const top = lexSql(sql).filter((t) => t.type !== 'comment');
123
+ const ts = [];
124
+ const guards = [];
125
+ /** Token index ranges that came from a DO body — plpgsql, not plain SQL. */
126
+ const bodies = [];
127
+ for (let i = 0; i < top.length; i++) {
128
+ const t = top[i];
129
+ // A DO body is executed procedural code whether it is dollar-quoted or a
130
+ // plain `'…'` literal: lex it and inline it. `DO $$…$$`, `DO 'BEGIN … END'`,
131
+ // `DO LANGUAGE plpgsql $$…$$` — the language clause may come first.
132
+ const beforeBody = isWord(top[i - 2], 'LANGUAGE') && top[i - 1]?.type === 'word' ? i - 3 : i - 1;
133
+ if (isLiteral(t) && isWord(top[beforeBody], 'DO')) {
134
+ // In a plain literal a quote is written doubled; undo that so the body
135
+ // lexes as the SQL PostgreSQL will actually run.
136
+ const body = t.type === 'string' ? t.value.replace(/''/g, "'") : t.value;
137
+ const inner = lexSql(body, t.bodyStart ?? t.start).filter((x) => x.type !== 'comment');
138
+ const base = ts.length;
139
+ for (const [a, b] of guardedRanges(inner))
140
+ guards.push([base + a, base + b]);
141
+ ts.push(...inner);
142
+ bodies.push([base, ts.length]);
131
143
  continue;
132
144
  }
133
- i++;
145
+ ts.push(t);
134
146
  }
135
- return out.join('');
147
+ return { ts, guards, bodies };
136
148
  }
137
149
  /**
138
- * Spans inside a `DO $$ … $$` body that sit between an `IF … THEN` and its
139
- * `END IF`. DDL there runs only when the condition holds, and deciding that
140
- * needs an interpreter — so statements in these spans are reported as
141
- * "cannot be confirmed" rather than assumed to have run.
142
- *
143
- * IFs nest, so this matches them with a stack: taking the first `END IF` as the
144
- * outer block's terminator ends the guard early and lets a statement after the
145
- * inner `END IF` look unconditional. `ELSIF` is not an opener (`\bif\b` does not
146
- * match inside it). Offsets are preserved, so they line up with `maskSql` output.
150
+ * Text in a dynamic-SQL literal that can change what this checker models: a
151
+ * table's existence, its RLS state, its name, or how unqualified names resolve.
147
152
  */
148
- function conditionalRanges(sql) {
149
- const src = maskSqlComments(sql);
150
- const ranges = [];
151
- for (const m of src.matchAll(/\bdo\s*(\$[A-Za-z_][A-Za-z0-9_]*\$|\$\$)/gi)) {
152
- const tag = m[1];
153
- const bodyStart = (m.index ?? 0) + m[0].length;
154
- const close = src.indexOf(tag, bodyStart);
155
- const bodyEnd = close === -1 ? src.length : close;
156
- const body = src.slice(bodyStart, bodyEnd);
157
- const open = [];
158
- for (const t of body.matchAll(/\bend\s+if\b|\bif\b/gi)) {
159
- const at = t.index ?? 0;
160
- if (/^end/i.test(t[0])) {
161
- const from = open.pop();
162
- if (from !== undefined)
163
- ranges.push([bodyStart + from, bodyStart + at]);
153
+ const STATE_TEXT = /row\s+level\s+security|create\s+(?:\w+\s+)*table\b|drop\s+table\b|alter\s+table\b|search_path/i;
154
+ /**
155
+ * Extract CREATE / ENABLE / DISABLE / DROP / RENAME / SELECT INTO events from
156
+ * one file, statement by statement. Anything that could change a table's
157
+ * existence, name or RLS state and is NOT understood is returned in `unparsed`
158
+ * — it must surface as partial coverage, never vanish into a `continue`.
159
+ */
160
+ function readStatements(sql) {
161
+ const { ts, guards, bodies } = flatten(sql);
162
+ const events = [];
163
+ const unparsed = [];
164
+ const guarded = (i) => guards.some(([a, b]) => i >= a && i < b);
165
+ const inBody = (i) => bodies.some(([a, b]) => i >= a && i < b);
166
+ // Inside plpgsql, `BEGIN ALTER TABLE …` and `IF x THEN ALTER TABLE …` are one
167
+ // `;`-terminated run whose real statement starts after the structure word.
168
+ // Only inside DO bodies: plain SQL uses THEN/ELSE/END in CASE expressions.
169
+ const PLPGSQL_BOUNDARY = ['BEGIN', 'DECLARE', 'THEN', 'ELSE', 'ELSIF', 'LOOP', 'END'];
170
+ const isBoundary = (i) => isPunct(ts[i], ';') || (inBody(i) && isWordIn(ts[i], PLPGSQL_BOUNDARY));
171
+ const mentionsWord = (from, to, w) => {
172
+ for (let m = from; m < to; m++)
173
+ if (isWord(ts[m], w))
174
+ return true;
175
+ return false;
176
+ };
177
+ // --- Name resolution ------------------------------------------------------
178
+ // An unqualified name resolves to the first schema of search_path: `public`
179
+ // until a `SET search_path` earlier in this file says otherwise. SET LOCAL
180
+ // lasts to the end of the transaction. Two `orders` tables in different
181
+ // schemas used to merge into one state because everything resolved to public.
182
+ // An EMPTY search_path (`SET search_path = ''`, what `pg_dump` emits) is a
183
+ // known state too: every name must then be qualified, so an unqualified one
184
+ // cannot be resolved and is reported rather than guessed.
185
+ let sessionSchema = 'public';
186
+ let localSchema = null;
187
+ const currentSchema = () => localSchema ?? sessionSchema;
188
+ /** TEMP tables created so far in this file: normalized name → created under a guard. */
189
+ const tempTables = new Map();
190
+ const resolve = (q, at, opts = {}) => {
191
+ const table = normIdent(q.table.text, q.table.quoted);
192
+ if (q.schema) {
193
+ const schema = normIdent(q.schema.text, q.schema.quoted);
194
+ return { key: keyFor(schema, table), schema, table, display: `${q.schema.text}.${q.table.text}`, shadowed: false };
195
+ }
196
+ if (opts.temp)
197
+ return { key: keyFor(TEMP_SCHEMA, table), schema: TEMP_SCHEMA, table, display: q.table.text, shadowed: false };
198
+ // An existing temp table shadows the permanent one for DROP/ALTER (CREATE
199
+ // always targets the search_path schema). If the temp CREATE itself was
200
+ // guarded, which table this statement hits cannot be known.
201
+ const tempGuarded = tempTables.get(table);
202
+ if (!opts.forCreate && tempGuarded === false) {
203
+ return { key: keyFor(TEMP_SCHEMA, table), schema: TEMP_SCHEMA, table, display: q.table.text, shadowed: false };
204
+ }
205
+ const schema = currentSchema();
206
+ if (schema === '')
207
+ unparsed.push({ offset: at, why: `unqualified table name "${q.table.text}" with an empty search_path cannot be resolved to a schema` });
208
+ const display = schema === 'public' ? q.table.text : `${schema}.${q.table.text}`;
209
+ return { key: keyFor(schema, table), schema, table, display, shadowed: tempGuarded === true };
210
+ };
211
+ const add = (kind, r, at, idx, extra = {}) => {
212
+ events.push({
213
+ kind, key: r.key, ifNotExists: false, display: r.display, offset: at,
214
+ conditional: guarded(idx) || r.shadowed, ...extra,
215
+ });
216
+ };
217
+ const noteTemp = (r, idx) => { if (isTempKey(r.key))
218
+ tempTables.set(r.table, guarded(idx)); };
219
+ // --- Transactions ---------------------------------------------------------
220
+ // Top-level `BEGIN … ROLLBACK` undoes every statement in between; a
221
+ // transaction still open at end of file is committed by some runners and
222
+ // rolled back by others (psql). Either way those statements are "unconfirmed",
223
+ // not a confident PASS and not a confident critical.
224
+ let txStart = null; // index into `events`
225
+ const savepoints = new Map();
226
+ const undoSince = (i) => { for (let m = i; m < events.length; m++)
227
+ events[m].conditional = true; };
228
+ const endTx = () => { txStart = null; savepoints.clear(); localSchema = null; };
229
+ /**
230
+ * Adopt a new search_path given as its schema list (`''` entries are what
231
+ * `SET search_path = ''` leaves: an empty path). `"$user"` depends on the
232
+ * connecting role and a leading pg_temp would make every unqualified CREATE
233
+ * a temp table — neither is decidable here, so those become partial.
234
+ */
235
+ const setSearchPath = (names, local, at, why) => {
236
+ const first = names.map((n) => n.trim()).filter((n) => n !== '')[0] ?? '';
237
+ if (first.startsWith('$') || first === TEMP_SCHEMA) {
238
+ unparsed.push({ offset: at, why });
239
+ return;
240
+ }
241
+ if (local)
242
+ localSchema = first;
243
+ else
244
+ sessionSchema = first;
245
+ };
246
+ /** `SET [LOCAL|SESSION] search_path {TO|=} a [, b …]`. Anything fancier → partial. */
247
+ const applySearchPath = (from, to) => {
248
+ const at = ts[from].start;
249
+ const why = 'SET search_path in a form the analyzer cannot follow; unqualified table names can no longer be resolved';
250
+ const bail = () => unparsed.push({ offset: at, why });
251
+ let j = from + 1;
252
+ const local = isWord(ts[j], 'LOCAL');
253
+ if (local || isWord(ts[j], 'SESSION'))
254
+ j++;
255
+ j++; // search_path
256
+ if (!(isWord(ts[j], 'TO') || isPunct(ts[j], '=')))
257
+ return bail();
258
+ j++;
259
+ const names = [];
260
+ if (isWord(ts[j], 'DEFAULT') && j + 1 === to)
261
+ names.push('public');
262
+ else {
263
+ for (;;) {
264
+ const t = ts[j];
265
+ if (isName(t))
266
+ names.push(normIdent(t.value, t.type === 'quotedIdent'));
267
+ // `'a, b'` as one literal is a whole list PostgreSQL would split; do not guess it.
268
+ else if (t && t.type === 'string' && !t.value.includes(','))
269
+ names.push(t.value);
270
+ else
271
+ return bail();
272
+ j++;
273
+ if (j >= to)
274
+ break;
275
+ if (!isPunct(ts[j], ','))
276
+ return bail();
277
+ j++;
278
+ }
279
+ }
280
+ setSearchPath(names, local, at, why);
281
+ };
282
+ /**
283
+ * `[SELECT] [pg_catalog.]set_config('search_path', '<list>', is_local)` — the
284
+ * form `pg_dump` emits. A literal list is followed like SET; any other value
285
+ * expression is unknown → partial.
286
+ */
287
+ const applySetConfig = (from, to) => {
288
+ const at = ts[from].start;
289
+ const why = 'search_path changed via set_config(); unqualified table names can no longer be resolved';
290
+ let m = from;
291
+ while (m < to && !isWord(ts[m], 'SET_CONFIG'))
292
+ m++;
293
+ const [open, key, comma, val, comma2, flag, close] = ts.slice(m + 1, m + 8);
294
+ const literal = isPunct(open, '(') && key?.type === 'string' && isPunct(comma, ',') && val?.type === 'string' && isPunct(comma2, ',')
295
+ && isWordIn(flag, ['TRUE', 'FALSE']) && isPunct(close, ')');
296
+ if (!literal || !val)
297
+ return unparsed.push({ offset: at, why });
298
+ // Only the list is unquoted and split; a quoted entry inside it is left as
299
+ // written, the same way `SET search_path TO "App"` keeps case.
300
+ const names = val.value.split(',').map((n) => n.trim()).map((n) => (n.startsWith('"') && n.endsWith('"') ? n.slice(1, -1) : n.toLowerCase()));
301
+ setSearchPath(names, isWord(flag, 'TRUE'), at, why);
302
+ };
303
+ /** ENABLE|DISABLE ROW LEVEL SECURITY at `m`? Returns the verb or null. */
304
+ const rlsVerbAt = (m) => {
305
+ const v = ts[m];
306
+ if (!v || v.type !== 'word')
307
+ return null;
308
+ const V = v.value.toUpperCase();
309
+ if (V !== 'ENABLE' && V !== 'DISABLE')
310
+ return null;
311
+ return isWord(ts[m + 1], 'ROW') && isWord(ts[m + 2], 'LEVEL') && isWord(ts[m + 3], 'SECURITY') ? (V === 'DISABLE' ? 'disable' : 'enable') : null;
312
+ };
313
+ const mentionsRls = (from, to) => {
314
+ for (let m = from; m + 2 < to; m++)
315
+ if (isWord(ts[m], 'ROW') && isWord(ts[m + 1], 'LEVEL') && isWord(ts[m + 2], 'SECURITY'))
316
+ return true;
317
+ return false;
318
+ };
319
+ /** `set_config('search_path', …)` anywhere in the statement. */
320
+ const callsSetConfigSearchPath = (from, to) => {
321
+ if (!mentionsWord(from, to, 'SET_CONFIG'))
322
+ return false;
323
+ for (let m = from; m < to; m++) {
324
+ const t = ts[m];
325
+ if (t.type === 'string' && t.value.trim().toLowerCase() === 'search_path')
326
+ return true;
327
+ }
328
+ return false;
329
+ };
330
+ const interpret = (from, to) => {
331
+ const first = ts[from];
332
+ const w = first.type === 'word' ? first.value.toUpperCase() : '';
333
+ const body = inBody(from);
334
+ // From here on unqualified names resolve differently — or, inside a DO
335
+ // block whose control flow is not followed, unknowably.
336
+ if (callsSetConfigSearchPath(from, to)) {
337
+ if (body)
338
+ return unparsed.push({ offset: first.start, why: 'search_path changed via set_config() inside a DO block; unqualified table names can no longer be resolved' });
339
+ return applySetConfig(from, to);
340
+ }
341
+ if (w === 'SET') {
342
+ if (!mentionsWord(from, to, 'SEARCH_PATH'))
343
+ return; // other GUCs do not affect the model
344
+ if (body)
345
+ return unparsed.push({ offset: first.start, why: 'SET search_path inside a DO block; unqualified table names can no longer be resolved' });
346
+ return applySearchPath(from, to);
347
+ }
348
+ if (w === 'RESET' && !body) {
349
+ if (isWordIn(ts[from + 1], ['SEARCH_PATH', 'ALL'])) {
350
+ sessionSchema = 'public';
351
+ localSchema = null;
164
352
  }
165
- else {
166
- open.push(at);
353
+ return;
354
+ }
355
+ if (!body) {
356
+ if (w === 'BEGIN' || (w === 'START' && isWord(ts[from + 1], 'TRANSACTION'))) {
357
+ if (txStart === null)
358
+ txStart = events.length;
359
+ return;
360
+ }
361
+ if (w === 'COMMIT' || w === 'END') {
362
+ endTx();
363
+ return;
364
+ } // top-level END is COMMIT
365
+ if (w === 'SAVEPOINT' && isName(ts[from + 1])) {
366
+ const n = ts[from + 1];
367
+ savepoints.set(normIdent(n.value, n.type === 'quotedIdent'), events.length);
368
+ return;
369
+ }
370
+ if (w === 'ROLLBACK' || w === 'ABORT') {
371
+ if (isWord(ts[from + 1], 'TO')) {
372
+ // ROLLBACK TO [SAVEPOINT] name keeps the transaction open; only the
373
+ // work after the savepoint is undone. Unknown savepoint → undo all.
374
+ let j = from + 2;
375
+ if (isWord(ts[j], 'SAVEPOINT'))
376
+ j++;
377
+ const n = ts[j];
378
+ const at = isName(n) ? savepoints.get(normIdent(n.value, n.type === 'quotedIdent')) : undefined;
379
+ undoSince(at ?? txStart ?? events.length);
380
+ return;
381
+ }
382
+ undoSince(txStart ?? events.length);
383
+ endTx();
384
+ return;
385
+ }
386
+ }
387
+ if (w === 'CREATE') {
388
+ let j = from + 1;
389
+ let temp = false;
390
+ while (isWordIn(ts[j], ['UNLOGGED', 'TEMP', 'TEMPORARY', 'GLOBAL', 'LOCAL'])) {
391
+ if (/^TEMP/i.test(ts[j].value))
392
+ temp = true;
393
+ j++;
394
+ }
395
+ if (!isWord(ts[j], 'TABLE'))
396
+ return dynamic(from, to); // CREATE INDEX/POLICY/… — not a table
397
+ const [k, ine] = skipIfExists(ts, j + 1);
398
+ const q = readQualified(ts, k);
399
+ if (!q)
400
+ return unparsed.push({ offset: first.start, why: 'CREATE TABLE with a table name the analyzer cannot read' });
401
+ const r = resolve(q, first.start, { forCreate: true, temp });
402
+ noteTemp(r, from);
403
+ add('create', r, first.start, from, { ifNotExists: ine });
404
+ return;
405
+ }
406
+ if (w === 'DROP' && isWord(ts[from + 1], 'TABLE')) {
407
+ let [k] = skipIfExists(ts, from + 2);
408
+ // DROP TABLE takes a list: every name in it is gone, not only the first.
409
+ for (;;) {
410
+ const q = readQualified(ts, k);
411
+ if (!q)
412
+ return unparsed.push({ offset: first.start, why: 'DROP TABLE with a table name the analyzer cannot read' });
413
+ const r = resolve(q, first.start);
414
+ add('drop', r, first.start, from);
415
+ if (isTempKey(r.key) && !guarded(from))
416
+ tempTables.delete(r.table);
417
+ k = q.next;
418
+ if (!isPunct(ts[k], ','))
419
+ break;
420
+ k++;
421
+ }
422
+ while (isWordIn(ts[k], ['CASCADE', 'RESTRICT']))
423
+ k++;
424
+ if (k < to)
425
+ unparsed.push({ offset: first.start, why: 'DROP TABLE has a trailing clause the analyzer cannot read' });
426
+ return;
427
+ }
428
+ if (w === 'ALTER' && isWord(ts[from + 1], 'TABLE')) {
429
+ // ALTER TABLE [IF EXISTS] [ONLY] name [*] action [, action ...]
430
+ let [j] = skipIfExists(ts, from + 2);
431
+ if (isWord(ts[j], 'ONLY'))
432
+ j++;
433
+ const q = readQualified(ts, j);
434
+ if (!q)
435
+ return unparsed.push({ offset: first.start, why: 'ALTER TABLE with a table name the analyzer cannot read' });
436
+ const r = resolve(q, first.start);
437
+ let k = q.next;
438
+ if (isPunct(ts[k], '*'))
439
+ k++;
440
+ // RENAME TO / SET SCHEMA move the table's identity. The state must follow
441
+ // it, or a later DISABLE under the new name is never matched to the table.
442
+ if (isWord(ts[k], 'RENAME') && isWord(ts[k + 1], 'TO')) {
443
+ const nq = readQualified(ts, k + 2);
444
+ if (!nq || nq.schema || nq.next !== to)
445
+ return unparsed.push({ offset: first.start, why: 'ALTER TABLE RENAME TO with a name the analyzer cannot read' });
446
+ const table = normIdent(nq.table.text, nq.table.quoted);
447
+ const toDisplay = r.schema === 'public' || r.schema === TEMP_SCHEMA ? nq.table.text : `${r.schema}.${nq.table.text}`;
448
+ add('rename', r, first.start, from, { toKey: keyFor(r.schema, table), toDisplay });
449
+ if (isTempKey(r.key) && !guarded(from)) {
450
+ tempTables.delete(r.table);
451
+ tempTables.set(table, false);
452
+ }
453
+ return;
454
+ }
455
+ if (isWord(ts[k], 'SET') && isWord(ts[k + 1], 'SCHEMA')) {
456
+ const s = ts[k + 2];
457
+ if (!isName(s) || k + 3 !== to)
458
+ return unparsed.push({ offset: first.start, why: 'ALTER TABLE SET SCHEMA with a name the analyzer cannot read' });
459
+ const schema = normIdent(s.value, s.type === 'quotedIdent');
460
+ add('rename', r, first.start, from, { toKey: keyFor(schema, r.table), toDisplay: `${s.value}.${q.table.text}` });
461
+ return;
462
+ }
463
+ // Actions are comma-separated; an RLS toggle may be any of them, so scan
464
+ // the whole statement rather than only the first action.
465
+ let handled = false;
466
+ for (let m = k; m < to; m++) {
467
+ const verb = rlsVerbAt(m);
468
+ if (verb) {
469
+ add(verb, r, first.start, from);
470
+ handled = true;
471
+ m += 3;
472
+ continue;
473
+ }
474
+ // FORCE / NO FORCE ROW LEVEL SECURITY change owner bypass, not whether RLS is on.
475
+ if (isWord(ts[m], 'FORCE') && isWord(ts[m + 1], 'ROW')) {
476
+ handled = true;
477
+ m += 3;
478
+ }
479
+ }
480
+ if (!handled && mentionsRls(k, to)) {
481
+ unparsed.push({ offset: first.start, why: 'ALTER TABLE mentions ROW LEVEL SECURITY in a form the analyzer does not understand' });
482
+ }
483
+ return;
484
+ }
485
+ // A role- or database-level default applies to every LATER session, i.e.
486
+ // to the migrations that follow this one.
487
+ if (w === 'ALTER' && isWordIn(ts[from + 1], ['ROLE', 'USER', 'DATABASE']) && mentionsWord(from, to, 'SEARCH_PATH')) {
488
+ return unparsed.push({ offset: first.start, why: 'ALTER ROLE/DATABASE … SET search_path changes name resolution for later migrations; unqualified table names can no longer be resolved' });
489
+ }
490
+ if (w === 'SELECT') {
491
+ // Top-level `SELECT … INTO <table>` creates a table. Inside plpgsql the
492
+ // same syntax is ALWAYS a variable assignment (a table is made with
493
+ // CREATE TABLE AS), so there it must not be read as a table.
494
+ if (body)
495
+ return;
496
+ for (let m = from + 1; m < to; m++) {
497
+ const u = ts[m];
498
+ if (isWord(u, 'FROM'))
499
+ break;
500
+ if (isWord(u, 'INTO')) {
501
+ let n = m + 1;
502
+ let temp = false;
503
+ while (isWordIn(ts[n], ['TEMP', 'TEMPORARY', 'UNLOGGED', 'TABLE'])) {
504
+ if (/^TEMP/i.test(ts[n].value))
505
+ temp = true;
506
+ n++;
507
+ }
508
+ const q = readQualified(ts, n);
509
+ if (q) {
510
+ const r = resolve(q, first.start, { forCreate: true, temp });
511
+ noteTemp(r, from);
512
+ add('create', r, first.start, from);
513
+ }
514
+ break;
515
+ }
516
+ }
517
+ return;
518
+ }
519
+ return dynamic(from, to);
520
+ };
521
+ /**
522
+ * Dynamic SQL — `EXECUTE 'ALTER TABLE …'`, `EXECUTE cmd`, `EXECUTE format(…)`
523
+ * — builds the statement at run time. A single literal can at least be read
524
+ * and is flagged only when it talks about tables, RLS or search_path. A
525
+ * variable, a `||` concatenation or a function call is unknown by
526
+ * construction: whatever it does to table/RLS state cannot be known here, so
527
+ * it is always missing coverage.
528
+ */
529
+ const dynamic = (from, to) => {
530
+ // Only a leading EXECUTE (or `FOR … IN EXECUTE`) runs SQL; `CREATE TRIGGER
531
+ // … EXECUTE FUNCTION f()` merely names a function.
532
+ let ex = -1;
533
+ for (let m = from; m < to; m++) {
534
+ if (isWord(ts[m], 'EXECUTE') && (m === from || isWord(ts[m - 1], 'IN')) && !isWordIn(ts[m + 1], ['FUNCTION', 'PROCEDURE'])) {
535
+ ex = m;
536
+ break;
537
+ }
538
+ }
539
+ if (ex === -1)
540
+ return;
541
+ let end = to;
542
+ for (let m = ex + 1; m < to; m++)
543
+ if (isWordIn(ts[m], ['INTO', 'USING'])) {
544
+ end = m;
545
+ break;
167
546
  }
547
+ const arg = ts.slice(ex + 1, end);
548
+ const one = arg[0];
549
+ if (arg.length === 1 && one && isLiteral(one)) {
550
+ if (STATE_TEXT.test(one.value))
551
+ unparsed.push({ offset: one.start, why: 'dynamic SQL (EXECUTE with a string) changes table/RLS state and cannot be interpreted statically' });
552
+ return;
553
+ }
554
+ // Top-level EXECUTE runs a PREPAREd statement, which cannot be DDL.
555
+ if (!inBody(ex))
556
+ return;
557
+ // Joining the literal pieces of `'ALTER TABLE x DISABLE ROW ' || 'LEVEL
558
+ // SECURITY'` only sharpens the message; the verdict is the same.
559
+ const pieces = arg.filter(isLiteral).map((t) => t.value).join('');
560
+ unparsed.push({
561
+ offset: ts[ex].start,
562
+ why: STATE_TEXT.test(pieces)
563
+ ? 'dynamic SQL (EXECUTE with a run-time built string that mentions tables/RLS) cannot be interpreted statically'
564
+ : 'dynamic SQL (EXECUTE with a variable or expression) has an effect on table/RLS state that cannot be known statically',
565
+ });
566
+ };
567
+ // Split into statements and interpret each one.
568
+ let s = 0;
569
+ for (let i = 0; i <= ts.length; i++) {
570
+ if (i === ts.length || isBoundary(i)) {
571
+ if (i > s)
572
+ interpret(s, i);
573
+ s = i + 1;
168
574
  }
169
- // An IF left unterminated guards everything to the end of the block.
170
- for (const from of open)
171
- ranges.push([bodyStart + from, bodyEnd]);
172
575
  }
173
- return ranges;
576
+ // Left open at end of file: committed or rolled back depending on the runner.
577
+ if (txStart !== null)
578
+ undoSince(txStart);
579
+ return { events, unparsed };
174
580
  }
175
581
  /**
176
582
  * Flags each table whose latest state after replaying the migrations is
177
583
  * "created and RLS not enabled". Migrations are replayed in apply order
178
- * (files by name, statements by offset); CREATE / ENABLE / DISABLE / DROP are
179
- * all modeled; CREATE IF NOT EXISTS on an existing table is a no-op; comments
180
- * and string literals are ignored; quoted and schema-qualified names work.
584
+ * (files by name, statements by offset); CREATE / ENABLE / DISABLE / DROP /
585
+ * RENAME are all modeled; CREATE IF NOT EXISTS on an existing table is a
586
+ * no-op; comments and string literals are ignored; quoted and schema-qualified
587
+ * names work.
181
588
  */
182
589
  export const rlsMigrationsChecker = {
183
590
  id: 'rls-migrations',
@@ -185,45 +592,139 @@ export const rlsMigrationsChecker = {
185
592
  level: 0,
186
593
  run(ctx) {
187
594
  const sqlFiles = ctx.files.filter((f) => f.ext === '.sql').sort((a, b) => a.rel.localeCompare(b.rel));
188
- if (sqlFiles.length === 0)
189
- return [];
190
- // RLS is a PostgreSQL feature. Do not tell a MySQL/SQLite project to enable it.
191
- const allSql = sqlFiles.map((f) => f.content).join('\n');
192
- const pkg = ctx.files.find((f) => f.rel === 'package.json')?.content ?? '';
193
- const postgresish = ctx.detection.backends.includes('supabase') ||
194
- /\b(pg|postgres|postgresql|@supabase\/|drizzle-orm|postgres\.js|node-postgres)\b/i.test(pkg) ||
195
- /(enable\s+row\s+level\s+security|gen_random_uuid|\bserial\b|::\s*\w+|\bjsonb\b)/i.test(allSql) ||
196
- /\b(psycopg|asyncpg|sqlalchemy\+postgres)\b/i.test(ctx.files.find((f) => f.rel === 'requirements.txt')?.content ?? '');
197
- const otherEngine = /(engine\s*=\s*innodb|auto_increment|\bpragma\b|`\w+`\s*varchar)/i.test(allSql) ||
198
- /\b(mysql2?|sqlite3|better-sqlite3|mariadb)\b/i.test(pkg);
199
- if (otherEngine && !postgresish)
200
- return [];
595
+ if (sqlFiles.length === 0) {
596
+ // A project that clearly talks to Supabase but ships no .sql migrations
597
+ // (schema managed from the dashboard, or migrations live in another repo)
598
+ // used to just say nothing — a silent, undeserved clean score on the exact
599
+ // check the tool exists for. Say what could not be checked instead.
600
+ const looksSupabase = ctx.detection.backends.includes('supabase') ||
601
+ ctx.files.some((f) => !/\.(md|mdx|txt|rst)$/i.test(f.rel) &&
602
+ /(SUPABASE_(?:ANON|PUBLISHABLE)_KEY|sb_publishable_|\/rest\/v1\b|postgrest)/i.test(f.content));
603
+ if (!looksSupabase)
604
+ return [];
605
+ return [{
606
+ id: 'rls_unverifiable_no_migrations',
607
+ severity: 'info',
608
+ title: 'No SQL migrations found — RLS could not be checked statically',
609
+ detail: 'This project talks to Supabase, but no .sql migration files exist in this repository (the schema may be managed from the dashboard, or migrations live in a different repo). A static scan can only see table/RLS state from files it can read, so no table here could be verified this way — this is missing information, not a clean result.',
610
+ fix: 'Run the live probe (consent + --i-own-this) to enumerate which tables the anon key can read, or check pg_class.relrowsecurity and pg_policies directly in the Supabase SQL editor.',
611
+ checker: 'rls-migrations',
612
+ level: 0,
613
+ }];
614
+ }
615
+ // RLS is a PostgreSQL feature. The engine is decided per FILE, not per
616
+ // project: a monorepo can hold a Postgres schema next to a Cloudflare D1
617
+ // (SQLite) one, and a SQLite schema must never be told to enable RLS — that
618
+ // engine has no such thing. A real D1 project once got five "critical" RLS
619
+ // findings and a 0/100 because the old gate only knew MySQL's
620
+ // `auto_increment` (with the underscore) and never heard of D1/wrangler.
621
+ // A monorepo's dependencies often live in apps/*/package.json, not the
622
+ // workspace root. Reading only the root file missed a D1/SQLite worker's
623
+ // `wrangler`/`better-sqlite3` signal whenever the SQL itself carried no
624
+ // dialect marker either, and defaulted it to Postgres — a phantom critical
625
+ // on a database that has no RLS to enable.
626
+ const pkg = ctx.files.filter((f) => f.rel.endsWith('package.json')).map((f) => f.content).join('\n');
627
+ const reqs = ctx.files.filter((f) => f.rel === 'requirements.txt' || f.rel.endsWith('/requirements.txt')).map((f) => f.content).join('\n');
628
+ // Checked with .some() against the files themselves, not a pre-joined
629
+ // string: joining nearly the whole project's non-doc/non-SQL content into
630
+ // one string on every scan doubled peak memory for the full scanned corpus
631
+ // to answer what is, per project, a handful of existence checks that can
632
+ // each stop at the first matching file.
633
+ const codeFiles = ctx.files.filter((f) => !/\.(md|mdx|txt|rst|sql)$/i.test(f.rel));
634
+ const matchesAnyCodeFile = (re) => codeFiles.some((f) => re.test(f.content));
635
+ const pgProject = ctx.detection.backends.includes('supabase') ||
636
+ /\b(pg|postgres|postgresql|@supabase\/|postgres\.js|node-postgres|pg-promise)\b/i.test(pkg) ||
637
+ /\b(psycopg|asyncpg|sqlalchemy\+postgres)\b/i.test(reqs);
638
+ const hasD1 = ctx.files.some((f) => /(^|\/)wrangler\.(toml|jsonc?)$/.test(f.rel) && /d1_databases/.test(f.content));
639
+ const sqliteProject = hasD1 ||
640
+ /"(better-sqlite3|sqlite3|@libsql\/client)"/i.test(pkg) ||
641
+ matchesAnyCodeFile(/from\s+['"](bun:sqlite|drizzle-orm\/(d1|better-sqlite3|libsql))['"]/);
642
+ const mysqlProject = /"(mysql2?|mariadb)"/i.test(pkg);
643
+ const sqliteLabel = hasD1 ? 'Cloudflare D1 (SQLite)' : 'SQLite';
644
+ const PG_MARK = /(row\s+level\s+security|gen_random_uuid|\b(?:big)?serial\b|::\s*\w+|\bjsonb\b|\bplpgsql\b|create\s+policy|uuid_generate)/i;
645
+ const SQLITE_MARK = /(\bautoincrement\b|integer\s+primary\s+key|\bpragma\b|without\s+rowid)/i;
646
+ const MYSQL_MARK = /(engine\s*=\s*innodb|\bauto_increment\b|`\w+`\s*varchar)/i;
647
+ /** The engine a file is written for, or null when it is (or may as well be) PostgreSQL. */
648
+ const foreignEngine = (sql) => {
649
+ if (PG_MARK.test(sql))
650
+ return null;
651
+ if (SQLITE_MARK.test(sql))
652
+ return sqliteLabel;
653
+ if (MYSQL_MARK.test(sql))
654
+ return 'MySQL';
655
+ // No dialect marker in the file: fall back to what the project uses. With
656
+ // no signal either way this is treated as Postgres — the tool's home turf.
657
+ if (sqliteProject && !pgProject)
658
+ return sqliteLabel;
659
+ if (mysqlProject && !pgProject)
660
+ return 'MySQL';
661
+ return null;
662
+ };
663
+ const skipped = [];
664
+ const analyzed = sqlFiles.filter((f) => {
665
+ const engine = foreignEngine(f.content);
666
+ if (engine)
667
+ skipped.push({ file: f.rel, engine });
668
+ return !engine;
669
+ });
670
+ // Skipping is visible, not silent: an info finding names the files and says
671
+ // where access control lives for that engine. Info does not move the gate —
672
+ // the check does not apply, so the verdict must not become "incomplete".
673
+ const notApplicable = skipped.length === 0 ? [] : [{
674
+ id: 'rls_not_applicable',
675
+ severity: 'info',
676
+ title: `RLS check skipped for ${skipped.length} ${[...new Set(skipped.map((s) => s.engine))].join('/')} schema file(s) — Row Level Security is PostgreSQL-only`,
677
+ detail: `${skipped.slice(0, 5).map((s) => s.file).join(', ')}${skipped.length > 5 ? ` (+${skipped.length - 5} more)` : ''}: this engine has no row-level security, so "table without RLS" does not apply here. Access control for this data must live in your server/Worker code.`,
678
+ fix: 'Nothing to change in the schema. Make sure every query your API/Worker runs checks the caller\'s identity before reading or writing rows — that is where access control lives for this database.',
679
+ checker: 'rls-migrations',
680
+ level: 0,
681
+ file: skipped[0]?.file ?? '',
682
+ line: 1,
683
+ }];
684
+ if (analyzed.length === 0)
685
+ return notApplicable;
686
+ // "No RLS" is a hole only when untrusted clients reach the database directly
687
+ // (Supabase/PostgREST with the anon key, Hasura). A Postgres that only server
688
+ // code talks to has no RLS by default and is not exposed by that — a critical
689
+ // there would be noise. With no signal either way the exposed reading wins:
690
+ // the tool's audience is mostly Supabase, and unknown must not read as safe.
691
+ const exposed = ctx.detection.backends.includes('supabase') ||
692
+ /@supabase\//i.test(pkg) ||
693
+ matchesAnyCodeFile(/(\/rest\/v1\b|postgrest|hasura|SUPABASE_(?:ANON|PUBLISHABLE)_KEY|sb_publishable_)/i);
694
+ const serverOnly = !exposed &&
695
+ (/"(pg|postgres|pg-promise|@prisma\/client|drizzle-orm|knex|kysely|typeorm|sequelize)"/i.test(pkg) ||
696
+ /\b(psycopg2?|asyncpg|sqlalchemy)\b/i.test(reqs));
697
+ const missingSeverity = exposed || !serverOnly ? 'critical' : 'warning';
201
698
  const events = [];
202
- sqlFiles.forEach((f, fileIdx) => {
203
- const masked = maskSql(f.content);
204
- // Computed on the raw text: maskSql blanks the `$$` delimiters themselves,
205
- // so the DO blocks are no longer findable there. It preserves length, so
206
- // offsets from `masked` line up with these ranges exactly.
207
- const guards = conditionalRanges(f.content);
208
- const push = (kind, schema, table, offset, ifNotExists = false) => events.push({
209
- kind, key: keyOf(schema, table), ifNotExists, file: f.rel, line: lineAt(f.content, offset),
210
- display: displayOf(schema, table), fileIdx, offset,
211
- conditional: guards.some(([a, b]) => offset >= a && offset < b),
212
- });
213
- for (const m of masked.matchAll(CREATE_TABLE))
214
- push('create', m[2], m[3] ?? '', m.index ?? 0, !!m[1]);
215
- for (const m of masked.matchAll(RLS_STMT))
216
- push((m[3] ?? '').toLowerCase() === 'disable' ? 'disable' : 'enable', m[1], m[2] ?? '', m.index ?? 0);
217
- for (const m of masked.matchAll(DROP_TABLE))
218
- push('drop', m[1], m[2] ?? '', m.index ?? 0);
219
- for (const m of masked.matchAll(SELECT_INTO))
220
- push('create', m[1], m[2] ?? '', m.index ?? 0);
699
+ const notUnderstood = [];
700
+ analyzed.forEach((f, fileIdx) => {
701
+ const { events: evs, unparsed } = readStatements(f.content);
702
+ for (const e of evs)
703
+ events.push({ ...e, file: f.rel, fileIdx, line: lineAt(f.content, e.offset) });
704
+ for (const u of unparsed)
705
+ notUnderstood.push(`${f.rel}:${lineAt(f.content, u.offset)} — ${u.why}`);
221
706
  });
222
707
  // True apply order: by migration file, then by statement position in the file.
223
708
  events.sort((a, b) => a.fileIdx - b.fileIdx || a.offset - b.offset);
224
709
  const state = new Map();
225
710
  for (const e of events) {
226
- const cur = state.get(e.key) ?? { created: false, enabled: false, file: e.file, line: e.line, display: e.display, stateFile: e.file, guarded: false };
711
+ const cur = state.get(e.key) ?? { created: false, enabled: false, file: e.file, line: e.line, display: e.display, stateFile: e.file, guarded: false, keys: new Set([e.key]) };
712
+ if (e.kind === 'rename') {
713
+ const to = e.toKey;
714
+ cur.keys.add(to);
715
+ if (e.conditional) {
716
+ // Which name the table ends up under is unknown: keep it under both, both in doubt.
717
+ cur.guarded = true;
718
+ cur.stateFile = e.file;
719
+ state.set(e.key, cur);
720
+ if (!state.has(to))
721
+ state.set(to, { ...cur, keys: new Set(cur.keys), display: e.toDisplay ?? cur.display });
722
+ continue;
723
+ }
724
+ state.delete(e.key);
725
+ state.set(to, { ...cur, display: e.toDisplay ?? cur.display, stateFile: e.file });
726
+ continue;
727
+ }
227
728
  // Doubt is a property of ANY guarded statement, not just a guarded ENABLE.
228
729
  // A conditional DROP used to delete the table from the model outright, so
229
730
  // a table left unprotected vanished from the report entirely. A later
@@ -290,35 +791,48 @@ export const rlsMigrationsChecker = {
290
791
  const turnsOff = (k) => k === 'disable' || k === 'create';
291
792
  const findings = [];
292
793
  for (const [key, s] of state) {
293
- if (!s.created)
294
- continue;
295
- const ambiguous = events.some((e) => e.key === key && dirOf(e.file) !== dirOf(s.stateFile) && (s.enabled ? turnsOff(e.kind) : e.kind === 'enable'));
794
+ if (!s.created || isTempKey(key))
795
+ continue; // temp tables are session-only: PostgREST never sees them
796
+ const ambiguous = events.some((e) => s.keys.has(e.key) && dirOf(e.file) !== dirOf(s.stateFile) && (s.enabled ? turnsOff(e.kind) : e.kind === 'enable'));
296
797
  if (s.enabled && !ambiguous && !s.guarded)
297
798
  continue; // provably protected
298
799
  // `guarded` means a statement we had to GUESS about decided this table's
299
- // state — a conditional ENABLE, DISABLE, CREATE or DROP. Calling that
300
- // "critical" would be the same false confidence as calling it clean, so it
301
- // is reported as unconfirmed. A warning still fails CI (exit 1); it just
302
- // does not claim to know what only the database can tell.
800
+ // state — a conditional ENABLE, DISABLE, CREATE, DROP or RENAME. Calling
801
+ // that "critical" would be the same false confidence as calling it clean,
802
+ // so it is reported as unconfirmed. A warning still fails CI (exit 1); it
803
+ // just does not claim to know what only the database can tell.
303
804
  const kind = ambiguous ? 'order' : s.guarded ? 'guarded' : 'missing';
304
805
  findings.push({
305
806
  id: 'rls_missing',
306
- severity: kind === 'missing' ? 'critical' : 'warning',
307
- title: kind === 'missing' ? `Table "${s.display}" created without RLS`
807
+ severity: kind === 'missing' ? missingSeverity : 'warning',
808
+ title: kind === 'missing'
809
+ ? (missingSeverity === 'critical'
810
+ ? `Table "${s.display}" created without RLS`
811
+ : `Table "${s.display}" has no RLS (server-only database)`)
308
812
  : kind === 'order' ? `Table "${s.display}" may end up without RLS (migration order unclear)`
309
813
  : `Table "${s.display}" has an unconfirmed RLS state (conditional block)`,
310
814
  detail: kind === 'missing'
311
- ? `"${s.display}" is created in a migration and its latest state does not enable Row Level Security. If this table holds user data on Supabase, the anon key can read every row.`
815
+ ? (missingSeverity === 'critical'
816
+ ? `"${s.display}" is created in a migration and its latest state does not enable Row Level Security. On Supabase the anon role has full table privileges by default, so with RLS off the public key can READ, INSERT, UPDATE and DELETE every row through the REST API. Static view only: this covers tables created by migrations in this repository — tables created from the dashboard or elsewhere are not listed here; the live probe enumerates all of them.`
817
+ : `"${s.display}" is created without Row Level Security. Only server code (pg/Prisma/…) appears to talk to this database, so nothing hands its rows to clients directly — that is normal, not a hole. It becomes CRITICAL the moment a client-facing data API (Supabase/PostgREST anon key, Hasura) is put in front of the same database.`)
312
818
  : kind === 'order'
313
819
  ? `"${s.display}" has statements in directories other than "${s.stateFile}" that contradict its final RLS state. Files in separate directories have no reliable apply order, so this cannot be decided statically — check the deployed state.`
314
- : `"${s.display}" has RLS statements inside an "IF … THEN" guard in a DO block. Whether that branch runs cannot be decided without executing the migration, so its RLS state is NOT confirmed — check the deployed state.`,
315
- fix: `ALTER TABLE ${s.display} ENABLE ROW LEVEL SECURITY; then add an owner/tenant policy, and drop any permissive "USING (true)" policy (policies are OR-ed). This is a static hint — confirm the deployed state.`,
820
+ : `"${s.display}" has a table or RLS statement whose execution cannot be confirmed statically: inside an IF/LOOP branch or a block with an EXCEPTION handler, after a RETURN, in a rolled-back or unterminated transaction, or on a name a TEMP table may shadow. Whether it took effect cannot be decided without running the migration, so its RLS state is NOT confirmed — check the deployed state.`,
821
+ fix: `ALTER TABLE ${s.display} ENABLE ROW LEVEL SECURITY; then add an owner/tenant policy, and drop any permissive "USING (true)" policy (policies are OR-ed). Until policies exist, stop the bleeding without breaking reads: REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON ${s.display} FROM anon; This is a static hint — confirm the deployed state.`,
316
822
  checker: 'rls-migrations',
317
823
  level: 0,
318
824
  file: s.file,
319
825
  line: s.line,
320
826
  });
321
827
  }
322
- return findings;
828
+ const all = [...findings, ...notApplicable];
829
+ if (notUnderstood.length === 0)
830
+ return all;
831
+ // Statements that can change table or RLS state and were not understood are
832
+ // missing coverage. Returning them as `partial` makes the verdict incomplete
833
+ // instead of letting an unknown construct read as clean.
834
+ const shown = notUnderstood.slice(0, 5).join('; ');
835
+ const more = notUnderstood.length > 5 ? ` (+${notUnderstood.length - 5} more)` : '';
836
+ return { findings: all, partial: `${notUnderstood.length} SQL statement(s) affecting table/RLS state could not be interpreted: ${shown}${more}` };
323
837
  },
324
838
  };