@everystack/cli 0.4.41 → 0.4.44

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.
@@ -220,6 +220,20 @@ function argsWiden(from: number[], to: number[]): boolean {
220
220
  return to.every((v, i) => from[i] == null || v >= from[i]);
221
221
  }
222
222
 
223
+ /**
224
+ * Types whose entire meaning survives a trip through `text` — scalars and strings whose
225
+ * printed form IS their value. Everything else (postgis geometry, tsvector, hstore, ltree,
226
+ * arrays, composites, ranges, and any extension type) carries structure the cast discards,
227
+ * so it is deliberately NOT listed: the default for an unknown base is "not free".
228
+ */
229
+ const TEXT_ROUND_TRIPS = new Set([
230
+ 'text', 'character varying', 'character', 'citext', 'name', 'uuid', 'boolean',
231
+ 'smallint', 'integer', 'bigint', 'numeric', 'decimal', 'real', 'double precision',
232
+ 'date', 'time', 'time without time zone', 'time with time zone',
233
+ 'timestamp', 'timestamp without time zone', 'timestamp with time zone',
234
+ 'json', 'jsonb', 'inet', 'cidr', 'macaddr', 'bytea', 'interval',
235
+ ]);
236
+
223
237
  /**
224
238
  * Classify a column type change. Conservative by construction — anything not provably safe
225
239
  * or merely lossy falls through to `risky`, so a dangerous conversion is never mistaken for
@@ -230,7 +244,18 @@ export function classifyTypeChange(from: string, to: string): TypeChangeRisk {
230
244
  const f = parseType(from);
231
245
  const t = parseType(to);
232
246
 
233
- if (t.base === 'text') return 'safe'; // text is the universal sink: every type assignment-casts to it losslessly
247
+ // `→ text` is only free from a type text can round-trip. It IS the universal sink for the
248
+ // printable VALUE — but a structured type loses its meaning, not its characters: postgis
249
+ // `geometry` → text takes every spatial index and `ST_*` call with it, `tsvector` → text
250
+ // kills the full-text index, `hstore` → text destroys the key/value structure. None of that
251
+ // is recoverable by casting back.
252
+ //
253
+ // This mattered far past taxonomy. A `safe` verdict emits a BARE `SET DATA TYPE` with no
254
+ // WARNING, and the plan classifier only counts a narrowing when it sees the `USING` cast a
255
+ // non-safe verdict adds — so 48 postgis/tsvector/hstore/`timestamp(6)` rewrites in a real
256
+ // adoption plan were reported as additive with `destructive: 0`, which is also the number
257
+ // that gates `--confirm` + snapshot. Calling these lossy is what arms the gate.
258
+ if (t.base === 'text') return TEXT_ROUND_TRIPS.has(f.base) ? 'safe' : 'lossy';
234
259
 
235
260
  const fr = NUMERIC_RANK[f.base];
236
261
  const tr = NUMERIC_RANK[t.base];
@@ -89,6 +89,94 @@ export interface DestructiveBreakdown {
89
89
  strips: string[];
90
90
  }
91
91
 
92
+ /**
93
+ * Statements that positively ADD — the only ones a plan may call additive.
94
+ *
95
+ * `additive` used to be computed as `executable.length - destructive`, which made it the
96
+ * DEFAULT rather than a finding: any statement the classifier did not recognize was reported
97
+ * as safe. A real adoption plan carrying 38 DROP POLICY, 43 REVOKE and 48 column rewrites
98
+ * described itself as `additive: 158, destructive: 0`, and since `destructive > 0` is what
99
+ * gates `--confirm` + snapshot + the approver set, the false zero did not merely mislabel the
100
+ * plan — it disarmed the gate.
101
+ *
102
+ * So the default is inverted. A statement is additive only when it matches one of these; a
103
+ * statement nobody recognized is `unclassified`, and `unclassified` is never additive. This is
104
+ * the same rule the reconciler already applies to grants: absence of knowledge means LEAVE IT
105
+ * ALONE and say so, never "assume it is fine".
106
+ */
107
+ const ADDITIVE_MATCHERS: RegExp[] = [
108
+ /^CREATE\s+(TABLE|POLICY|INDEX|UNIQUE\s+INDEX|SEQUENCE|EXTENSION|TYPE|SCHEMA)\b/,
109
+ /^GRANT\b/,
110
+ /^COMMENT\s+ON\b/,
111
+ /^ALTER\s+TABLE\s+[\s\S]+?\sADD\s+(COLUMN|CONSTRAINT|PRIMARY\s+KEY)\b/,
112
+ // Enabling/forcing RLS only ever RESTRICTS; it cannot widen access or lose a row.
113
+ /^ALTER\s+TABLE\s+[\s\S]+?\s(ENABLE|FORCE)\s+ROW\s+LEVEL\s+SECURITY\b/,
114
+ // Relaxing nullability and setting a default add capability; they remove nothing.
115
+ /^ALTER\s+TABLE\s+[\s\S]+?\sALTER\s+COLUMN\s+[\s\S]+?\sDROP\s+NOT\s+NULL\b/,
116
+ /^ALTER\s+TABLE\s+[\s\S]+?\sALTER\s+COLUMN\s+[\s\S]+?\sSET\s+DEFAULT\b/,
117
+ ];
118
+
119
+ /**
120
+ * Statements that REMOVE an authorization without losing a row.
121
+ *
122
+ * Deliberately NOT folded into `destructive`, which means data loss and whose taxonomy is
123
+ * defended above — a dropped policy or grant re-declares from the models without touching a
124
+ * row. But it is emphatically not ADDITIVE either, and that was the lie: a `DROP POLICY` that
125
+ * removes a table's only read policy leaves the table returning zero rows, which is the single
126
+ * highest-consequence thing an adoption plan can carry and it was reported as an addition.
127
+ */
128
+ const AUTHZ_REMOVAL_MATCHERS: RegExp[] = [
129
+ /^DROP\s+POLICY\b/,
130
+ /^REVOKE\b/,
131
+ /^ALTER\s+TABLE\s+[\s\S]+?\s(DISABLE|NO\s+FORCE)\s+ROW\s+LEVEL\s+SECURITY\b/,
132
+ ];
133
+
134
+ /** The full partition of an executable stream. Every statement lands in exactly one bucket. */
135
+ export interface StatementPartition {
136
+ additive: string[];
137
+ drops: string[];
138
+ narrowings: string[];
139
+ strips: string[];
140
+ /** Authorization removed, no data lost — visible, never additive. */
141
+ authzRemovals: string[];
142
+ /** Not positively recognized. NEVER additive; needs a human before this plan is applied. */
143
+ unclassified: string[];
144
+ }
145
+
146
+ /** The first non-comment line, upper-cased — WARNING prologues must not hide the verb. */
147
+ function statementHead(statement: string): string {
148
+ return (statement.split('\n').find((l) => l.trim() !== '' && !l.trim().startsWith('--')) ?? '')
149
+ .trim()
150
+ .toUpperCase();
151
+ }
152
+
153
+ /**
154
+ * Partition an executable stream into exactly one bucket per statement.
155
+ *
156
+ * Order matters: the data-loss buckets are consulted FIRST so a statement that both drops and
157
+ * revokes is counted at its most severe reading, and `unclassified` is the fallthrough rather
158
+ * than `additive`.
159
+ */
160
+ export function partitionStatements(
161
+ executable: string[],
162
+ opts: { declaredGrantees?: ReadonlySet<string> } = {},
163
+ ): StatementPartition {
164
+ const { drops, narrowings, strips } = classifyDestructive(executable, opts);
165
+ const destructive = new Set([...drops, ...narrowings, ...strips]);
166
+ const additive: string[] = [];
167
+ const authzRemovals: string[] = [];
168
+ const unclassified: string[] = [];
169
+
170
+ for (const statement of executable) {
171
+ if (destructive.has(statement)) continue;
172
+ const head = statementHead(statement);
173
+ if (AUTHZ_REMOVAL_MATCHERS.some((re) => re.test(head))) authzRemovals.push(statement);
174
+ else if (ADDITIVE_MATCHERS.some((re) => re.test(head))) additive.push(statement);
175
+ else unclassified.push(statement);
176
+ }
177
+ return { additive, drops, narrowings, strips, authzRemovals, unclassified };
178
+ }
179
+
92
180
  /** `REVOKE … ON <table> FROM <grantee>;` → the grantee, or null when it is not a revoke. */
93
181
  export function revokeTarget(statement: string): string | null {
94
182
  // A quoted identifier can hold anything (`"Odd-Role"`, a reserved word, mixed case), and