migraguard 0.6.2 → 0.7.0

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.
package/dist/cli.js CHANGED
@@ -9,6 +9,7 @@ import { dirname, resolve, join } from 'path';
9
9
  import { existsSync } from 'fs';
10
10
  import { randomBytes, createHash } from 'crypto';
11
11
  import libpg from 'libpg-query';
12
+ import { Parser } from 'node-sql-parser';
12
13
  import { pathToFileURL } from 'url';
13
14
  import { diffLines } from 'diff';
14
15
  import { tmpdir } from 'os';
@@ -189,43 +190,66 @@ var DEFAULT_DUMP = {
189
190
  excludeOwners: true,
190
191
  excludePrivileges: true
191
192
  };
193
+ var DEFAULT_PG_LINT_RULES = {
194
+ "require-concurrent-index": "error",
195
+ "require-if-not-exists": "error",
196
+ "require-lock-timeout": "error",
197
+ "ban-concurrent-index-in-transaction": "error",
198
+ "adding-not-nullable-field": "error",
199
+ "constraint-missing-not-valid": "error",
200
+ "require-analyze-after-index": "error",
201
+ "require-create-or-replace-view": "error",
202
+ "ban-drop-cascade": "error",
203
+ "require-statement-timeout": "error",
204
+ "require-reset-timeouts": "error",
205
+ "ban-truncate": "error",
206
+ "ban-update-without-where": "error",
207
+ "ban-delete-without-where": "error",
208
+ "ban-drop-column": "error",
209
+ "ban-alter-column-type": "error",
210
+ "require-drop-index-concurrently": "error",
211
+ "require-unique-via-concurrent-index": "error",
212
+ "ban-validate-constraint-same-file": "error",
213
+ "ban-bare-analyze": "error",
214
+ "require-if-not-exists-materialized-view": "error",
215
+ "ban-refresh-materialized-view-in-migration": "error",
216
+ "ban-select-star-in-view": "error",
217
+ "ban-rename-column": "error",
218
+ "ban-rename-table": "error",
219
+ "ban-drop-table": "error",
220
+ "require-pk-via-concurrent-index": "error",
221
+ "ban-set-not-null": "error",
222
+ "ban-alter-enum-in-transaction": "error",
223
+ "ban-vacuum-full": "error",
224
+ "ban-cluster": "error",
225
+ "ban-reindex": "error",
226
+ "ban-alter-system": "error",
227
+ "ban-set-session-replication-role": "error"
228
+ };
229
+ var DEFAULT_GENERIC_LINT_RULES = {
230
+ "require-if-not-exists": "error",
231
+ "ban-drop-column": "error",
232
+ "ban-alter-column-type": "error",
233
+ "ban-rename-column": "error",
234
+ "ban-rename-table": "error",
235
+ "ban-drop-table": "error",
236
+ "ban-update-without-where": "error",
237
+ "ban-delete-without-where": "error",
238
+ "ban-truncate": "error",
239
+ "adding-not-nullable-field": "error",
240
+ "require-create-or-replace-view": "error",
241
+ "ban-select-star-in-view": "error",
242
+ "ban-drop-cascade": "error",
243
+ "backfill-requires-where-clause": "error",
244
+ "backfill-ban-ddl": "error",
245
+ "contract-requires-allow-directive": "error",
246
+ "expand-requires-idempotent-pattern": "error"
247
+ };
248
+ function getDefaultLintRules(dialect) {
249
+ return dialect === "postgresql" ? DEFAULT_PG_LINT_RULES : DEFAULT_GENERIC_LINT_RULES;
250
+ }
192
251
  var DEFAULT_LINT = {
193
- rules: {
194
- "require-concurrent-index": "error",
195
- "require-if-not-exists": "error",
196
- "require-lock-timeout": "error",
197
- "ban-concurrent-index-in-transaction": "error",
198
- "adding-not-nullable-field": "error",
199
- "constraint-missing-not-valid": "error",
200
- "require-analyze-after-index": "error",
201
- "require-create-or-replace-view": "error",
202
- "ban-drop-cascade": "error",
203
- "require-statement-timeout": "error",
204
- "require-reset-timeouts": "error",
205
- "ban-truncate": "error",
206
- "ban-update-without-where": "error",
207
- "ban-delete-without-where": "error",
208
- "ban-drop-column": "error",
209
- "ban-alter-column-type": "error",
210
- "require-drop-index-concurrently": "error",
211
- "require-unique-via-concurrent-index": "error",
212
- "ban-validate-constraint-same-file": "error",
213
- "ban-bare-analyze": "error",
214
- "require-if-not-exists-materialized-view": "error",
215
- "ban-refresh-materialized-view-in-migration": "error",
216
- "ban-select-star-in-view": "error",
217
- "ban-rename-column": "error",
218
- "ban-rename-table": "error",
219
- "ban-drop-table": "error",
220
- "require-pk-via-concurrent-index": "error",
221
- "ban-set-not-null": "error",
222
- "ban-alter-enum-in-transaction": "error",
223
- "ban-vacuum-full": "error",
224
- "ban-cluster": "error",
225
- "ban-reindex": "error",
226
- "ban-alter-system": "error",
227
- "ban-set-session-replication-role": "error"
228
- }
252
+ rules: DEFAULT_PG_LINT_RULES
229
253
  };
230
254
  function applyEnvOverrides(connection) {
231
255
  return {
@@ -261,12 +285,15 @@ function findConfigFile(startDir) {
261
285
  }
262
286
  }
263
287
  function buildConfig(raw, configDir) {
288
+ const dialect = raw.dialect ?? "postgresql";
264
289
  const connection = {
265
290
  ...DEFAULT_CONNECTION,
266
291
  ...raw.connection
267
292
  };
293
+ const defaultRules = getDefaultLintRules(dialect);
268
294
  return {
269
295
  configDir,
296
+ dialect,
270
297
  ...raw.model ? { model: raw.model } : {},
271
298
  migrationsDirs: resolveMigrationsDirs(raw),
272
299
  schemaFile: raw.schemaFile ?? "db/schema.sql",
@@ -277,7 +304,7 @@ function buildConfig(raw, configDir) {
277
304
  lint: {
278
305
  ...DEFAULT_LINT,
279
306
  ...raw.lint,
280
- rules: { ...DEFAULT_LINT.rules, ...raw.lint?.rules }
307
+ rules: { ...defaultRules, ...raw.lint?.rules }
281
308
  }
282
309
  };
283
310
  }
@@ -745,6 +772,142 @@ function isMetadataJson(data) {
745
772
  (m) => typeof m === "object" && m !== null && typeof m["file"] === "string" && typeof m["checksum"] === "string"
746
773
  );
747
774
  }
775
+ function toParserDatabase(dialect) {
776
+ return dialect === "mysql" ? "MySQL" : "SQLite";
777
+ }
778
+ function analyzeGenericSql(sql, dialect) {
779
+ const creates = [];
780
+ const references = [];
781
+ const createdTableNames = /* @__PURE__ */ new Set();
782
+ const parser = new Parser();
783
+ let stmts;
784
+ try {
785
+ const ast = parser.astify(sql, { database: toParserDatabase(dialect) });
786
+ stmts = Array.isArray(ast) ? ast : [ast];
787
+ } catch {
788
+ return { creates, references };
789
+ }
790
+ for (const stmt of stmts) {
791
+ const type = stmt.type;
792
+ const keyword = stmt.keyword;
793
+ if (type === "create" && keyword === "table") {
794
+ extractCreateTable(stmt, creates, references, createdTableNames);
795
+ } else if (type === "create" && keyword === "index") {
796
+ extractCreateIndex(stmt, references);
797
+ } else if (type === "alter") {
798
+ extractAlter(stmt, references);
799
+ } else if (type === "create" && keyword === "view") {
800
+ extractCreateView(stmt, creates, references);
801
+ } else if (type === "drop") {
802
+ extractDrop(stmt, references);
803
+ }
804
+ }
805
+ const filteredRefs = references.filter(
806
+ (ref) => !createdTableNames.has(ref.name)
807
+ );
808
+ return { creates, references: filteredRefs };
809
+ }
810
+ function tableName(table) {
811
+ if (!table?.table) return "";
812
+ if (table.db) return `${table.db}.${table.table}`;
813
+ return table.table;
814
+ }
815
+ function extractCreateTable(stmt, creates, references, createdTableNames) {
816
+ const tables = stmt.table;
817
+ const name = tableName(tables?.[0]);
818
+ if (!name) return;
819
+ creates.push({ type: "table", name });
820
+ createdTableNames.add(name);
821
+ const defs = stmt.create_definitions;
822
+ if (!defs) return;
823
+ for (const def of defs) {
824
+ if (def.resource === "column") {
825
+ extractColumnFk(def, references);
826
+ }
827
+ if (def.constraint_type === "FOREIGN KEY") {
828
+ extractFkRef(def, references);
829
+ }
830
+ }
831
+ }
832
+ function extractColumnFk(colDef, references) {
833
+ const refDef = colDef.reference_definition;
834
+ if (!refDef) return;
835
+ extractFkRef(refDef, references);
836
+ }
837
+ function extractFkRef(def, references) {
838
+ const refDef = def.reference_definition ?? def;
839
+ const refTables = refDef.table;
840
+ const name = tableName(refTables?.[0]);
841
+ if (name) {
842
+ references.push({ type: "table", name });
843
+ }
844
+ }
845
+ function extractCreateIndex(stmt, references) {
846
+ const table = stmt.table;
847
+ const name = tableName(table);
848
+ if (name) {
849
+ references.push({ type: "table", name });
850
+ }
851
+ }
852
+ function extractAlter(stmt, references) {
853
+ const tables = stmt.table;
854
+ const name = tableName(tables?.[0]);
855
+ if (name) {
856
+ references.push({ type: "table", name });
857
+ }
858
+ const exprs = stmt.expr;
859
+ if (!exprs) return;
860
+ for (const expr of exprs) {
861
+ if (expr.action === "add" && expr.resource === "constraint") {
862
+ const createDefs = expr.create_definitions;
863
+ if (createDefs) {
864
+ extractFkRef(createDefs, references);
865
+ }
866
+ }
867
+ if (expr.action === "add" && expr.resource === "column") {
868
+ extractColumnFk(expr, references);
869
+ }
870
+ }
871
+ }
872
+ function extractCreateView(stmt, creates, references) {
873
+ const view = stmt.view;
874
+ if (view?.view) {
875
+ creates.push({ type: "view", name: view.view });
876
+ }
877
+ const select = stmt.select;
878
+ if (select) {
879
+ collectFromTables(select, references);
880
+ }
881
+ }
882
+ function collectFromTables(node, references) {
883
+ const from = node.from;
884
+ if (from) {
885
+ for (const entry of from) {
886
+ const name = entry.table;
887
+ if (name) {
888
+ references.push({ type: "table", name });
889
+ }
890
+ }
891
+ }
892
+ }
893
+ function extractDrop(stmt, references) {
894
+ const keyword = stmt.keyword;
895
+ let objType = "table";
896
+ if (keyword === "table") objType = "table";
897
+ else if (keyword === "view") objType = "view";
898
+ else if (keyword === "index") objType = "index";
899
+ else return;
900
+ const names = stmt.name;
901
+ if (!names) return;
902
+ for (const entry of names) {
903
+ const name = tableName(entry);
904
+ if (name) {
905
+ references.push({ type: objType, name });
906
+ }
907
+ }
908
+ }
909
+
910
+ // src/deps.ts
748
911
  function normalizeTableName(name, schema) {
749
912
  if (!name) return "";
750
913
  if (schema && schema !== "public") {
@@ -787,9 +950,9 @@ async function analyzeSql(sql) {
787
950
  function extractCreateStmt(node, creates, references, createdTableNames) {
788
951
  const rel = node.relation;
789
952
  if (!rel?.relname) return;
790
- const tableName = normalizeTableName(rel.relname, rel.schemaname);
791
- creates.push({ type: "table", name: tableName });
792
- createdTableNames.add(tableName);
953
+ const tableName2 = normalizeTableName(rel.relname, rel.schemaname);
954
+ creates.push({ type: "table", name: tableName2 });
955
+ createdTableNames.add(tableName2);
793
956
  const tableElts = node.tableElts;
794
957
  if (!tableElts) return;
795
958
  for (const elt of tableElts) {
@@ -961,9 +1124,9 @@ function parseExplicitDepsFromConfig(config) {
961
1124
  }
962
1125
  return result;
963
1126
  }
964
- async function analyzeFile(filePath, fileName) {
1127
+ async function analyzeFile(filePath, fileName, dialect) {
965
1128
  const sql = await readFile(filePath, "utf-8");
966
- const { creates, references } = await analyzeSql(sql);
1129
+ const { creates, references } = dialect && dialect !== "postgresql" ? analyzeGenericSql(sql, dialect) : await analyzeSql(sql);
967
1130
  return { fileName, creates, references };
968
1131
  }
969
1132
  async function buildDependencyGraph(config) {
@@ -974,7 +1137,7 @@ async function buildDependencyGraphFromFiles(files, config) {
974
1137
  const fileNames = files.map((f) => f.fileName);
975
1138
  const fileDeps = /* @__PURE__ */ new Map();
976
1139
  for (const file of files) {
977
- const deps = await analyzeFile(file.filePath, file.fileName);
1140
+ const deps = await analyzeFile(file.filePath, file.fileName, config.dialect);
978
1141
  fileDeps.set(file.fileName, deps);
979
1142
  }
980
1143
  const objectCreators = /* @__PURE__ */ new Map();
@@ -2329,11 +2492,11 @@ var requireConcurrentIndex = {
2329
2492
  return {
2330
2493
  IndexStmt(node, ctx) {
2331
2494
  const rel = node.relation;
2332
- const tableName = rel?.relname ?? "(unknown)";
2333
- const isNewTable = ctx.createdTables.has(tableName);
2495
+ const tableName2 = rel?.relname ?? "(unknown)";
2496
+ const isNewTable = ctx.createdTables.has(tableName2);
2334
2497
  if (!node.concurrent && !isNewTable) {
2335
2498
  ctx.report({
2336
- message: `CREATE INDEX on "${tableName}" without CONCURRENTLY`,
2499
+ message: `CREATE INDEX on "${tableName2}" without CONCURRENTLY`,
2337
2500
  hint: "Use CREATE INDEX CONCURRENTLY to avoid blocking writes"
2338
2501
  });
2339
2502
  }
@@ -3334,6 +3497,538 @@ var ALL_RULES = [
3334
3497
  backfillBanDdl,
3335
3498
  contractRequiresAllowDirective
3336
3499
  ];
3500
+ var ALLOW_DIRECTIVE_RE2 = /^--\s*migraguard:allow\s+(.+)$/gm;
3501
+ function parseAllowDirectives2(sql) {
3502
+ const allowed = /* @__PURE__ */ new Set();
3503
+ let match;
3504
+ while ((match = ALLOW_DIRECTIVE_RE2.exec(sql)) !== null) {
3505
+ for (const id of match[1].split(/[,\s]+/).filter(Boolean)) {
3506
+ allowed.add(id);
3507
+ }
3508
+ }
3509
+ ALLOW_DIRECTIVE_RE2.lastIndex = 0;
3510
+ return allowed;
3511
+ }
3512
+ function toParserDatabase2(dialect) {
3513
+ return dialect === "mysql" ? "MySQL" : "SQLite";
3514
+ }
3515
+ function stmtKey(stmt) {
3516
+ const type = stmt.type;
3517
+ if (type === "create") {
3518
+ const kw = stmt.keyword;
3519
+ if (kw === "table") return "create_table";
3520
+ if (kw === "index") return "create_index";
3521
+ if (kw === "view") return "create_view";
3522
+ return null;
3523
+ }
3524
+ if (type === "alter") return "alter";
3525
+ if (type === "drop") return "drop";
3526
+ if (type === "update") return "update";
3527
+ if (type === "delete") return "delete";
3528
+ if (type === "truncate") return "truncate";
3529
+ if (type === "transaction") return "transaction";
3530
+ return null;
3531
+ }
3532
+ async function runGenericRules(sql, rules, dialect) {
3533
+ const violations = [];
3534
+ const allowed = parseAllowDirectives2(sql);
3535
+ const activeRules = rules.filter((r) => !allowed.has(r.id));
3536
+ if (activeRules.length === 0) return violations;
3537
+ const visitors = [];
3538
+ for (const rule of activeRules) {
3539
+ visitors.push({ ruleId: rule.id, handlers: rule.create() });
3540
+ }
3541
+ const createdTables = /* @__PURE__ */ new Set();
3542
+ let inTransaction = false;
3543
+ const parser = new Parser();
3544
+ let stmts = [];
3545
+ try {
3546
+ const ast = parser.astify(sql, { database: toParserDatabase2(dialect) });
3547
+ stmts = Array.isArray(ast) ? ast : [ast];
3548
+ } catch {
3549
+ }
3550
+ for (const stmt of stmts) {
3551
+ const type = stmt.type;
3552
+ const keyword = stmt.keyword;
3553
+ if (type === "create" && keyword === "table") {
3554
+ const tables = stmt.table;
3555
+ if (tables?.[0]?.table) createdTables.add(tables[0].table);
3556
+ }
3557
+ if (type === "transaction") {
3558
+ const expr = stmt.expr;
3559
+ const action = expr?.action;
3560
+ const val = action?.value?.toLowerCase();
3561
+ if (val === "begin" || val === "start") inTransaction = true;
3562
+ else if (val === "commit" || val === "rollback") inTransaction = false;
3563
+ }
3564
+ const key = stmtKey(stmt);
3565
+ if (!key) continue;
3566
+ const ctx = {
3567
+ report: null,
3568
+ createdTables,
3569
+ inTransaction,
3570
+ rawSql: sql
3571
+ };
3572
+ for (const { ruleId, handlers } of visitors) {
3573
+ const handler = handlers[key];
3574
+ if (!handler) continue;
3575
+ ctx.report = (v) => violations.push({ rule: ruleId, ...v });
3576
+ handler(stmt, ctx);
3577
+ }
3578
+ }
3579
+ const endCtx = {
3580
+ report: null,
3581
+ createdTables,
3582
+ inTransaction,
3583
+ rawSql: sql
3584
+ };
3585
+ for (const { ruleId, handlers } of visitors) {
3586
+ const endHandler = handlers["_End"];
3587
+ if (!endHandler) continue;
3588
+ endCtx.report = (v) => violations.push({ rule: ruleId, ...v });
3589
+ endHandler({}, endCtx);
3590
+ }
3591
+ return violations;
3592
+ }
3593
+
3594
+ // src/generic/rules/require-if-not-exists.ts
3595
+ var requireIfNotExists2 = {
3596
+ id: "require-if-not-exists",
3597
+ description: "CREATE must use IF NOT EXISTS, DROP must use IF EXISTS",
3598
+ create() {
3599
+ return {
3600
+ create_table(node, ctx) {
3601
+ if (!node.if_not_exists) {
3602
+ const tables = node.table;
3603
+ ctx.report({
3604
+ message: `CREATE TABLE ${tables?.[0]?.table ?? "(unknown)"} without IF NOT EXISTS`,
3605
+ hint: "Use CREATE TABLE IF NOT EXISTS for idempotent migrations"
3606
+ });
3607
+ }
3608
+ },
3609
+ create_index(node, ctx) {
3610
+ if (!node.if_not_exists) {
3611
+ const index = typeof node.index === "string" ? node.index : node.index?.name ?? "";
3612
+ ctx.report({
3613
+ message: `CREATE INDEX ${index} without IF NOT EXISTS`,
3614
+ hint: "Use CREATE INDEX IF NOT EXISTS for idempotent migrations"
3615
+ });
3616
+ }
3617
+ },
3618
+ drop(node, ctx) {
3619
+ if (!node.prefix) {
3620
+ ctx.report({
3621
+ message: "DROP without IF EXISTS",
3622
+ hint: "Use DROP ... IF EXISTS for idempotent migrations"
3623
+ });
3624
+ }
3625
+ }
3626
+ };
3627
+ }
3628
+ };
3629
+
3630
+ // src/generic/rules/ban-drop-column.ts
3631
+ var banDropColumn2 = {
3632
+ id: "ban-drop-column",
3633
+ description: "DROP COLUMN is irreversible and may break dependent objects",
3634
+ create() {
3635
+ return {
3636
+ alter(node, ctx) {
3637
+ const exprs = node.expr;
3638
+ if (!exprs) return;
3639
+ for (const expr of exprs) {
3640
+ if (expr.action !== "drop" || expr.resource !== "column") continue;
3641
+ const tables = node.table;
3642
+ const col = expr.column;
3643
+ ctx.report({
3644
+ message: `DROP COLUMN "${col?.column ?? "(unknown)"}" on "${tables?.[0]?.table ?? "(unknown)"}"`,
3645
+ hint: "DROP COLUMN is irreversible. Consider deprecating the column first, then dropping in a later migration"
3646
+ });
3647
+ }
3648
+ }
3649
+ };
3650
+ }
3651
+ };
3652
+
3653
+ // src/generic/rules/ban-alter-column-type.ts
3654
+ var banAlterColumnType2 = {
3655
+ id: "ban-alter-column-type",
3656
+ description: "ALTER COLUMN TYPE may rewrite the table and acquire long locks",
3657
+ create() {
3658
+ return {
3659
+ alter(node, ctx) {
3660
+ const exprs = node.expr;
3661
+ if (!exprs) return;
3662
+ for (const expr of exprs) {
3663
+ if (expr.action !== "modify") continue;
3664
+ const tables = node.table;
3665
+ const col = expr.column;
3666
+ ctx.report({
3667
+ message: `ALTER COLUMN TYPE on "${tables?.[0]?.table ?? "(unknown)"}".${col?.column ?? "(unknown)"}`,
3668
+ hint: "Type changes may rewrite the table. Use add-column \u2192 backfill \u2192 swap \u2192 drop-column instead"
3669
+ });
3670
+ }
3671
+ }
3672
+ };
3673
+ }
3674
+ };
3675
+
3676
+ // src/generic/rules/ban-rename-column.ts
3677
+ var banRenameColumn2 = {
3678
+ id: "ban-rename-column",
3679
+ description: "Renaming a column may break existing clients",
3680
+ create() {
3681
+ return {
3682
+ alter(node, ctx) {
3683
+ const exprs = node.expr;
3684
+ if (!exprs) return;
3685
+ for (const expr of exprs) {
3686
+ if (expr.action !== "rename" || expr.resource !== "column") continue;
3687
+ const tables = node.table;
3688
+ const oldCol = expr.old_column;
3689
+ const newCol = expr.column;
3690
+ ctx.report({
3691
+ message: `Renaming column "${oldCol?.column ?? "(unknown)"}" to "${newCol?.column ?? "(unknown)"}" on "${tables?.[0]?.table ?? "(unknown)"}"`,
3692
+ hint: "Column renames break existing queries and application code. Consider adding a new column and deprecating the old one"
3693
+ });
3694
+ }
3695
+ }
3696
+ };
3697
+ }
3698
+ };
3699
+
3700
+ // src/generic/rules/ban-rename-table.ts
3701
+ var banRenameTable2 = {
3702
+ id: "ban-rename-table",
3703
+ description: "Renaming a table may break existing clients",
3704
+ create() {
3705
+ return {
3706
+ alter(node, ctx) {
3707
+ const exprs = node.expr;
3708
+ if (!exprs) return;
3709
+ for (const expr of exprs) {
3710
+ if (expr.action !== "rename" || expr.resource !== "table") continue;
3711
+ const tables = node.table;
3712
+ const newName = expr.table;
3713
+ ctx.report({
3714
+ message: `Renaming table "${tables?.[0]?.table ?? "(unknown)"}" to "${newName ?? "(unknown)"}"`,
3715
+ hint: "Table renames break existing queries. Consider using a VIEW to alias the new name"
3716
+ });
3717
+ }
3718
+ }
3719
+ };
3720
+ }
3721
+ };
3722
+
3723
+ // src/generic/rules/ban-drop-table.ts
3724
+ var banDropTable2 = {
3725
+ id: "ban-drop-table",
3726
+ description: "DROP TABLE is irreversible and may break existing clients",
3727
+ create() {
3728
+ return {
3729
+ drop(node, ctx) {
3730
+ if (node.keyword !== "table") return;
3731
+ const names = node.name;
3732
+ const tableNames = names?.map((n) => n.table).filter(Boolean);
3733
+ ctx.report({
3734
+ message: `DROP TABLE${tableNames?.length ? ` "${tableNames.join('", "')}"` : ""}`,
3735
+ hint: "DROP TABLE is irreversible. Ensure the table is no longer referenced by application code before dropping"
3736
+ });
3737
+ }
3738
+ };
3739
+ }
3740
+ };
3741
+
3742
+ // src/generic/rules/ban-update-without-where.ts
3743
+ var banUpdateWithoutWhere2 = {
3744
+ id: "ban-update-without-where",
3745
+ description: "UPDATE without WHERE affects all rows",
3746
+ create() {
3747
+ return {
3748
+ update(node, ctx) {
3749
+ if (node.where !== null && node.where !== void 0) return;
3750
+ const tables = node.table;
3751
+ ctx.report({
3752
+ message: `UPDATE on "${tables?.[0]?.table ?? "(unknown)"}" without WHERE clause`,
3753
+ hint: "Add a WHERE clause to limit affected rows"
3754
+ });
3755
+ }
3756
+ };
3757
+ }
3758
+ };
3759
+
3760
+ // src/generic/rules/ban-delete-without-where.ts
3761
+ var banDeleteWithoutWhere2 = {
3762
+ id: "ban-delete-without-where",
3763
+ description: "DELETE without WHERE affects all rows",
3764
+ create() {
3765
+ return {
3766
+ delete(node, ctx) {
3767
+ if (node.where !== null && node.where !== void 0) return;
3768
+ const from = node.from;
3769
+ ctx.report({
3770
+ message: `DELETE on "${from?.[0]?.table ?? "(unknown)"}" without WHERE clause`,
3771
+ hint: "Add a WHERE clause to limit affected rows"
3772
+ });
3773
+ }
3774
+ };
3775
+ }
3776
+ };
3777
+
3778
+ // src/generic/rules/ban-truncate.ts
3779
+ var banTruncate2 = {
3780
+ id: "ban-truncate",
3781
+ description: "TRUNCATE is irreversible",
3782
+ create() {
3783
+ return {
3784
+ truncate(_node, ctx) {
3785
+ ctx.report({
3786
+ message: "TRUNCATE is not allowed in migrations",
3787
+ hint: "Use DELETE with a WHERE clause, or manage data separately from schema migrations"
3788
+ });
3789
+ }
3790
+ };
3791
+ }
3792
+ };
3793
+
3794
+ // src/generic/rules/adding-not-nullable-field.ts
3795
+ var addingNotNullableField2 = {
3796
+ id: "adding-not-nullable-field",
3797
+ description: "Adding a NOT NULL column requires a DEFAULT value",
3798
+ create() {
3799
+ return {
3800
+ alter(node, ctx) {
3801
+ const exprs = node.expr;
3802
+ if (!exprs) return;
3803
+ for (const expr of exprs) {
3804
+ if (expr.action !== "add" || expr.resource !== "column") continue;
3805
+ const nullable = expr.nullable;
3806
+ const hasNotNull = nullable?.type === "not null";
3807
+ const hasDefault = expr.default_val !== void 0 && expr.default_val !== null;
3808
+ if (hasNotNull && !hasDefault) {
3809
+ const col = expr.column;
3810
+ ctx.report({
3811
+ message: `Adding NOT NULL column "${col?.column ?? "(unknown)"}" without DEFAULT`,
3812
+ hint: "Add a DEFAULT value or add the column as nullable first, then backfill, then set NOT NULL"
3813
+ });
3814
+ }
3815
+ }
3816
+ }
3817
+ };
3818
+ }
3819
+ };
3820
+
3821
+ // src/generic/rules/require-create-or-replace-view.ts
3822
+ var requireCreateOrReplaceView2 = {
3823
+ id: "require-create-or-replace-view",
3824
+ description: "CREATE VIEW should use CREATE OR REPLACE VIEW (or IF NOT EXISTS for SQLite)",
3825
+ create() {
3826
+ return {
3827
+ create_view(node, ctx) {
3828
+ if (node.replace) return;
3829
+ if (node.if_not_exists) return;
3830
+ const view = node.view;
3831
+ ctx.report({
3832
+ message: `CREATE VIEW ${view?.view ?? ""} without OR REPLACE / IF NOT EXISTS`,
3833
+ hint: "Use CREATE OR REPLACE VIEW (MySQL) or CREATE VIEW IF NOT EXISTS (SQLite) for idempotent migrations"
3834
+ });
3835
+ }
3836
+ };
3837
+ }
3838
+ };
3839
+
3840
+ // src/generic/rules/ban-select-star-in-view.ts
3841
+ function hasSelectStar(select) {
3842
+ const columns = select.columns;
3843
+ if (!columns) return false;
3844
+ for (const col of columns) {
3845
+ const expr = col.expr;
3846
+ if (expr?.column === "*") return true;
3847
+ }
3848
+ return false;
3849
+ }
3850
+ var banSelectStarInView2 = {
3851
+ id: "ban-select-star-in-view",
3852
+ description: "SELECT * in VIEW definitions makes schema changes unsafe",
3853
+ create() {
3854
+ return {
3855
+ create_view(node, ctx) {
3856
+ const select = node.select;
3857
+ if (!select) return;
3858
+ if (!hasSelectStar(select)) return;
3859
+ const view = node.view;
3860
+ ctx.report({
3861
+ message: `SELECT * in VIEW "${view?.view ?? "(unknown)"}"`,
3862
+ hint: "List columns explicitly \u2014 SELECT * breaks migrations when base table columns change"
3863
+ });
3864
+ }
3865
+ };
3866
+ }
3867
+ };
3868
+
3869
+ // src/generic/rules/ban-drop-cascade.ts
3870
+ var CASCADE_RE = /\bDROP\s+(?:TABLE|INDEX|VIEW|SEQUENCE|FUNCTION|TRIGGER)\b[^;]*\bCASCADE\b/gi;
3871
+ var banDropCascade2 = {
3872
+ id: "ban-drop-cascade",
3873
+ description: "DROP ... CASCADE is dangerous \u2014 dependencies are silently dropped",
3874
+ create() {
3875
+ return {
3876
+ _End(_node, ctx) {
3877
+ CASCADE_RE.lastIndex = 0;
3878
+ if (CASCADE_RE.test(ctx.rawSql)) {
3879
+ ctx.report({
3880
+ message: "DROP with CASCADE",
3881
+ hint: "Avoid CASCADE \u2014 drop dependent objects explicitly to maintain traceability"
3882
+ });
3883
+ }
3884
+ }
3885
+ };
3886
+ }
3887
+ };
3888
+
3889
+ // src/generic/rules/backfill-requires-where-clause.ts
3890
+ var backfillRequiresWhereClause2 = {
3891
+ id: "backfill-requires-where-clause",
3892
+ description: "Backfill phase UPDATE/DELETE must have WHERE clause",
3893
+ applicablePhases: ["backfill"],
3894
+ create() {
3895
+ return {
3896
+ update(node, ctx) {
3897
+ if (node.where !== null && node.where !== void 0) return;
3898
+ const tables = node.table;
3899
+ ctx.report({
3900
+ message: `UPDATE on "${tables?.[0]?.table ?? "(unknown)"}" without WHERE clause in backfill phase`,
3901
+ hint: "Add a WHERE clause to batch backfill operations safely"
3902
+ });
3903
+ },
3904
+ delete(node, ctx) {
3905
+ if (node.where !== null && node.where !== void 0) return;
3906
+ const from = node.from;
3907
+ ctx.report({
3908
+ message: `DELETE on "${from?.[0]?.table ?? "(unknown)"}" without WHERE clause in backfill phase`,
3909
+ hint: "Add a WHERE clause to batch operations safely"
3910
+ });
3911
+ }
3912
+ };
3913
+ }
3914
+ };
3915
+
3916
+ // src/generic/rules/backfill-ban-ddl.ts
3917
+ var backfillBanDdl2 = {
3918
+ id: "backfill-ban-ddl",
3919
+ description: "Backfill phase must not contain DDL statements",
3920
+ applicablePhases: ["backfill"],
3921
+ create() {
3922
+ return {
3923
+ create_table(_node, ctx) {
3924
+ ctx.report({
3925
+ message: "CREATE TABLE is not allowed in backfill phase",
3926
+ hint: "DDL changes belong in the expand or contract phase, not backfill"
3927
+ });
3928
+ },
3929
+ create_index(_node, ctx) {
3930
+ ctx.report({
3931
+ message: "CREATE INDEX is not allowed in backfill phase",
3932
+ hint: "DDL changes belong in the expand phase, not backfill"
3933
+ });
3934
+ },
3935
+ alter(_node, ctx) {
3936
+ ctx.report({
3937
+ message: "ALTER TABLE is not allowed in backfill phase",
3938
+ hint: "DDL changes belong in the expand or contract phase, not backfill"
3939
+ });
3940
+ },
3941
+ drop(_node, ctx) {
3942
+ ctx.report({
3943
+ message: "DROP is not allowed in backfill phase",
3944
+ hint: "DDL changes belong in the contract phase, not backfill"
3945
+ });
3946
+ }
3947
+ };
3948
+ }
3949
+ };
3950
+
3951
+ // src/generic/rules/contract-requires-allow-directive.ts
3952
+ var contractRequiresAllowDirective2 = {
3953
+ id: "contract-requires-allow-directive",
3954
+ description: "Contract phase DROP operations must have migraguard:allow directives",
3955
+ applicablePhases: ["contract"],
3956
+ create() {
3957
+ return {
3958
+ drop(node, ctx) {
3959
+ const keyword = node.keyword;
3960
+ if (keyword === "table" || keyword === "index") {
3961
+ ctx.report({
3962
+ message: "DROP statement in contract phase requires explicit migraguard:allow directive",
3963
+ hint: 'Add "-- migraguard:allow ban-drop-table" or similar before this statement'
3964
+ });
3965
+ }
3966
+ },
3967
+ alter(node, ctx) {
3968
+ const exprs = node.expr;
3969
+ if (!exprs) return;
3970
+ for (const expr of exprs) {
3971
+ if (expr.action === "drop" && expr.resource === "column") {
3972
+ ctx.report({
3973
+ message: "ALTER TABLE DROP COLUMN in contract phase requires explicit migraguard:allow directive",
3974
+ hint: 'Add "-- migraguard:allow ban-drop-column" before this statement'
3975
+ });
3976
+ }
3977
+ }
3978
+ }
3979
+ };
3980
+ }
3981
+ };
3982
+
3983
+ // src/generic/rules/expand-requires-idempotent-pattern.ts
3984
+ var expandRequiresIdempotentPattern2 = {
3985
+ id: "expand-requires-idempotent-pattern",
3986
+ description: "Expand phase must use idempotent patterns (IF NOT EXISTS)",
3987
+ applicablePhases: ["expand"],
3988
+ create() {
3989
+ return {
3990
+ create_table(node, ctx) {
3991
+ if (!node.if_not_exists) {
3992
+ const tables = node.table;
3993
+ ctx.report({
3994
+ message: `CREATE TABLE ${tables?.[0]?.table ?? ""} without IF NOT EXISTS in expand phase`,
3995
+ hint: "Use CREATE TABLE IF NOT EXISTS for idempotent expand migrations"
3996
+ });
3997
+ }
3998
+ },
3999
+ create_index(node, ctx) {
4000
+ if (!node.if_not_exists) {
4001
+ const index = typeof node.index === "string" ? node.index : node.index?.name ?? "";
4002
+ ctx.report({
4003
+ message: `CREATE INDEX ${index} without IF NOT EXISTS in expand phase`,
4004
+ hint: "Use CREATE INDEX IF NOT EXISTS for idempotent expand migrations"
4005
+ });
4006
+ }
4007
+ }
4008
+ };
4009
+ }
4010
+ };
4011
+
4012
+ // src/generic/rules/index.ts
4013
+ var ALL_GENERIC_RULES = [
4014
+ requireIfNotExists2,
4015
+ banDropColumn2,
4016
+ banAlterColumnType2,
4017
+ banRenameColumn2,
4018
+ banRenameTable2,
4019
+ banDropTable2,
4020
+ banUpdateWithoutWhere2,
4021
+ banDeleteWithoutWhere2,
4022
+ banTruncate2,
4023
+ addingNotNullableField2,
4024
+ requireCreateOrReplaceView2,
4025
+ banSelectStarInView2,
4026
+ banDropCascade2,
4027
+ backfillRequiresWhereClause2,
4028
+ backfillBanDdl2,
4029
+ contractRequiresAllowDirective2,
4030
+ expandRequiresIdempotentPattern2
4031
+ ];
3337
4032
 
3338
4033
  // src/commands/lint.ts
3339
4034
  async function loadCustomRules(config) {
@@ -3366,6 +4061,12 @@ function getSeverity(config, ruleId) {
3366
4061
  return config.lint.rules[ruleId] ?? "error";
3367
4062
  }
3368
4063
  async function commandLint(config) {
4064
+ if (config.dialect !== "postgresql") {
4065
+ return commandLintGeneric(config);
4066
+ }
4067
+ return commandLintPg(config);
4068
+ }
4069
+ async function commandLintPg(config) {
3369
4070
  const files = await scanMigrations(config);
3370
4071
  if (files.length === 0) {
3371
4072
  console.log(chalk.yellow("No migration files to lint."));
@@ -3399,12 +4100,64 @@ async function commandLint(config) {
3399
4100
  totalWarnings += fileWarnings;
3400
4101
  printViolations(f.fileName, violations);
3401
4102
  }
3402
- if (totalErrors > 0 || totalWarnings > 0) {
4103
+ printSummary(files.length, totalErrors, totalWarnings);
4104
+ return {
4105
+ ok: totalErrors === 0,
4106
+ filesLinted: files.length,
4107
+ errors: totalErrors,
4108
+ warnings: totalWarnings
4109
+ };
4110
+ }
4111
+ async function commandLintGeneric(config) {
4112
+ const dialect = config.dialect;
4113
+ const files = await scanMigrations(config);
4114
+ if (files.length === 0) {
4115
+ console.log(chalk.yellow("No migration files to lint."));
4116
+ return { ok: true, filesLinted: 0, errors: 0, warnings: 0 };
4117
+ }
4118
+ const activeRules = ALL_GENERIC_RULES.filter(
4119
+ (r) => getSeverity(config, r.id) !== "off"
4120
+ );
4121
+ if (activeRules.length === 0) {
4122
+ console.log(chalk.yellow("All lint rules are disabled."));
4123
+ return { ok: true, filesLinted: files.length, errors: 0, warnings: 0 };
4124
+ }
4125
+ let totalErrors = 0;
4126
+ let totalWarnings = 0;
4127
+ for (const f of files) {
4128
+ const sql = await readFile(f.filePath, "utf-8");
4129
+ const fileRules = activeRules.filter((r) => {
4130
+ if (!r.applicablePhases) return true;
4131
+ if (!f.phase) return false;
4132
+ return r.applicablePhases.includes(f.phase);
4133
+ });
4134
+ const raw = await runGenericRules(sql, fileRules, dialect);
4135
+ if (raw.length === 0) continue;
4136
+ const violations = raw.map((v) => ({
4137
+ ...v,
4138
+ severity: getSeverity(config, v.rule) === "warn" ? "warn" : "error"
4139
+ }));
4140
+ const fileErrors = violations.filter((v) => v.severity === "error").length;
4141
+ const fileWarnings = violations.filter((v) => v.severity === "warn").length;
4142
+ totalErrors += fileErrors;
4143
+ totalWarnings += fileWarnings;
4144
+ printViolations(f.fileName, violations);
4145
+ }
4146
+ printSummary(files.length, totalErrors, totalWarnings);
4147
+ return {
4148
+ ok: totalErrors === 0,
4149
+ filesLinted: files.length,
4150
+ errors: totalErrors,
4151
+ warnings: totalWarnings
4152
+ };
4153
+ }
4154
+ function printSummary(filesCount, errors, warnings) {
4155
+ if (errors > 0 || warnings > 0) {
3403
4156
  const parts = [];
3404
- if (totalErrors > 0) parts.push(`${totalErrors} error(s)`);
3405
- if (totalWarnings > 0) parts.push(`${totalWarnings} warning(s)`);
4157
+ if (errors > 0) parts.push(`${errors} error(s)`);
4158
+ if (warnings > 0) parts.push(`${warnings} warning(s)`);
3406
4159
  const summary = parts.join(", ");
3407
- if (totalErrors > 0) {
4160
+ if (errors > 0) {
3408
4161
  console.error(chalk.red(`
3409
4162
  Lint failed: ${summary}.`));
3410
4163
  } else {
@@ -3412,14 +4165,8 @@ Lint failed: ${summary}.`));
3412
4165
  Lint: ${summary}.`));
3413
4166
  }
3414
4167
  } else {
3415
- console.log(chalk.green(`\u2713 ${files.length} file(s) passed lint.`));
4168
+ console.log(chalk.green(`\u2713 ${filesCount} file(s) passed lint.`));
3416
4169
  }
3417
- return {
3418
- ok: totalErrors === 0,
3419
- filesLinted: files.length,
3420
- errors: totalErrors,
3421
- warnings: totalWarnings
3422
- };
3423
4170
  }
3424
4171
  function printViolations(fileName, violations) {
3425
4172
  console.error(chalk.red(`