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/README.md +68 -48
- package/dist/cli.js +804 -57
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +206 -43
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
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: { ...
|
|
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
|
|
791
|
-
creates.push({ type: "table", name:
|
|
792
|
-
createdTableNames.add(
|
|
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
|
|
2333
|
-
const isNewTable = ctx.createdTables.has(
|
|
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 "${
|
|
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
|
-
|
|
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 (
|
|
3405
|
-
if (
|
|
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 (
|
|
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 ${
|
|
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(`
|