migflow 0.2.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.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +44 -0
  3. data/.ruby-version +1 -0
  4. data/CHANGELOG.md +45 -0
  5. data/CLAUDE.md +124 -0
  6. data/CODE_OF_CONDUCT.md +132 -0
  7. data/CONTRIBUTING.md +157 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +218 -0
  10. data/Rakefile +11 -0
  11. data/SECURITY.md +27 -0
  12. data/app/assets/migflow/app.css +1 -0
  13. data/app/assets/migflow/app.js +28 -0
  14. data/app/assets/migflow/index.html +14 -0
  15. data/app/assets/migflow/vite.svg +1 -0
  16. data/app/controllers/migflow/api/diff_controller.rb +73 -0
  17. data/app/controllers/migflow/api/migrations_controller.rb +97 -0
  18. data/app/controllers/migflow/application_controller.rb +62 -0
  19. data/app/views/migflow/application/index.html.erb +16 -0
  20. data/config/routes.rb +10 -0
  21. data/docs/architecture.md +130 -0
  22. data/lib/migflow/analyzers/audit_analyzer.rb +58 -0
  23. data/lib/migflow/analyzers/rules/base_rule.rb +32 -0
  24. data/lib/migflow/analyzers/rules/dangerous_migration_rule.rb +44 -0
  25. data/lib/migflow/analyzers/rules/missing_foreign_key_rule.rb +30 -0
  26. data/lib/migflow/analyzers/rules/missing_index_rule.rb +32 -0
  27. data/lib/migflow/analyzers/rules/missing_timestamps_rule.rb +38 -0
  28. data/lib/migflow/analyzers/rules/null_column_without_default_rule.rb +46 -0
  29. data/lib/migflow/analyzers/rules/string_without_limit_rule.rb +28 -0
  30. data/lib/migflow/app/assets/migflow/app.css +1 -0
  31. data/lib/migflow/app/assets/migflow/app.js +17 -0
  32. data/lib/migflow/app/assets/migflow/index.html +14 -0
  33. data/lib/migflow/app/assets/migflow/vite.svg +1 -0
  34. data/lib/migflow/configuration.rb +36 -0
  35. data/lib/migflow/engine.rb +14 -0
  36. data/lib/migflow/models/migration_snapshot.rb +15 -0
  37. data/lib/migflow/models/schema_diff.rb +9 -0
  38. data/lib/migflow/models/warning.rb +7 -0
  39. data/lib/migflow/parsers/migration_parser.rb +52 -0
  40. data/lib/migflow/parsers/schema_parser.rb +105 -0
  41. data/lib/migflow/reporters/json_reporter.rb +13 -0
  42. data/lib/migflow/reporters/markdown_reporter.rb +58 -0
  43. data/lib/migflow/reporters.rb +38 -0
  44. data/lib/migflow/services/diff_builder.rb +77 -0
  45. data/lib/migflow/services/migration_dsl_scanner.rb +161 -0
  46. data/lib/migflow/services/migration_summary_builder.rb +43 -0
  47. data/lib/migflow/services/report_generator.rb +76 -0
  48. data/lib/migflow/services/risk_scorer.rb +38 -0
  49. data/lib/migflow/services/schema_builder.rb +25 -0
  50. data/lib/migflow/services/schema_patch_builder.rb +237 -0
  51. data/lib/migflow/services/scoped_migration_warnings.rb +93 -0
  52. data/lib/migflow/services/snapshot_builder.rb +542 -0
  53. data/lib/migflow/services/touched_tables_from_migration.rb +60 -0
  54. data/lib/migflow/version.rb +5 -0
  55. data/lib/migflow.rb +20 -0
  56. data/lib/tasks/migflow.rake +31 -0
  57. data/sig/migflow.rbs +3 -0
  58. metadata +124 -0
@@ -0,0 +1,542 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "migration_dsl_scanner"
4
+
5
+ module Migflow
6
+ module Services
7
+ class SnapshotBuilder
8
+ def self.call(migrations:, up_to_version:)
9
+ new(migrations: migrations, up_to_version: up_to_version).build
10
+ end
11
+
12
+ def initialize(migrations:, up_to_version:)
13
+ @migrations = migrations.sort_by { |m| m[:version] }
14
+ @up_to_version = up_to_version
15
+ end
16
+
17
+ def build
18
+ before = { tables: {} }
19
+ after = { tables: {} }
20
+
21
+ @migrations.each do |migration|
22
+ break if migration[:version] > @up_to_version
23
+
24
+ before = deep_copy(after)
25
+ after = apply_migration(after, migration[:raw_content])
26
+ end
27
+
28
+ { schema_before: before, schema_after: after, diff: calculate_diff(before, after) }
29
+ end
30
+
31
+ private
32
+
33
+ def deep_copy(state)
34
+ Marshal.load(Marshal.dump(state))
35
+ end
36
+
37
+ def apply_migration(state, content)
38
+ s = deep_copy(state)
39
+ scanner = MigrationDslScanner.new(content)
40
+ apply_create_tables(s, scanner)
41
+ apply_drop_tables(s, scanner)
42
+ apply_add_columns(s, scanner)
43
+ apply_remove_columns(s, scanner)
44
+ apply_add_references(s, scanner)
45
+ apply_remove_references(s, scanner)
46
+ apply_rename_columns(s, scanner)
47
+ apply_rename_indexes(s, scanner)
48
+ apply_rename_tables(s, scanner)
49
+ apply_change_columns(s, scanner)
50
+ apply_change_column_defaults(s, scanner)
51
+ apply_change_column_nulls(s, scanner)
52
+ apply_change_column_comments(s, scanner)
53
+ apply_add_indexes(s, scanner)
54
+ apply_remove_indexes(s, scanner)
55
+ apply_add_foreign_keys(s, scanner)
56
+ apply_remove_foreign_keys(s, scanner)
57
+ apply_add_check_constraints(s, scanner)
58
+ apply_remove_check_constraints(s, scanner)
59
+ apply_change_table_blocks(s, scanner)
60
+ s
61
+ end
62
+
63
+ def apply_create_tables(state, scanner)
64
+ scanner.create_table_blocks.each do |table, block|
65
+ state[:tables][table] = {
66
+ columns: parse_block_columns(block),
67
+ indexes: parse_block_indexes(block),
68
+ foreign_keys: [],
69
+ check_constraints: []
70
+ }
71
+ end
72
+ end
73
+
74
+ def apply_drop_tables(state, scanner)
75
+ scanner.drop_tables.each { |table| state[:tables].delete(table) }
76
+ end
77
+
78
+ def apply_add_columns(state, scanner)
79
+ scanner.add_columns.each do |table, col, type, opts|
80
+ ensure_table(state, table)
81
+ state[:tables][table][:columns] << build_column(col, type, opts)
82
+ end
83
+ end
84
+
85
+ def apply_remove_columns(state, scanner)
86
+ scanner.remove_column.each do |table, col|
87
+ next unless state[:tables][table]
88
+
89
+ state[:tables][table][:columns].reject! { |c| c[:name] == col }
90
+ end
91
+
92
+ scanner.remove_columns.each do |table, cols_raw|
93
+ next unless state[:tables][table]
94
+
95
+ extract_columns_list(cols_raw).each do |col|
96
+ state[:tables][table][:columns].reject! { |c| c[:name] == col }
97
+ end
98
+ end
99
+ end
100
+
101
+ def apply_add_references(state, scanner)
102
+ scanner.add_references.each do |table, ref, opts|
103
+ ensure_table(state, table)
104
+ build_reference_columns(ref, opts).each do |column|
105
+ state[:tables][table][:columns] << column
106
+ end
107
+ end
108
+ end
109
+
110
+ def apply_remove_references(state, scanner)
111
+ scanner.remove_references.each do |table, ref, opts|
112
+ next unless state[:tables][table]
113
+
114
+ remove_reference_columns(state[:tables][table], ref, opts)
115
+ end
116
+ end
117
+
118
+ def apply_rename_columns(state, scanner)
119
+ scanner.rename_columns.each do |table, from, to|
120
+ next unless state[:tables][table]
121
+
122
+ col = state[:tables][table][:columns].find { |c| c[:name] == from }
123
+ col[:name] = to if col
124
+ end
125
+ end
126
+
127
+ def apply_rename_indexes(state, scanner)
128
+ scanner.rename_indexes.each do |table, from, to|
129
+ next unless state[:tables][table]
130
+
131
+ from_name = clean_identifier(from)
132
+ to_name = clean_identifier(to)
133
+ idx = state[:tables][table][:indexes].find { |index| index[:name] == from_name }
134
+ idx[:name] = to_name if idx
135
+ end
136
+ end
137
+
138
+ def apply_rename_tables(state, scanner)
139
+ scanner.rename_tables.each do |from, to|
140
+ next unless state[:tables][from]
141
+
142
+ state[:tables][to] = state[:tables].delete(from)
143
+ end
144
+ end
145
+
146
+ def apply_change_columns(state, scanner)
147
+ scanner.change_columns.each do |table, col, type|
148
+ next unless state[:tables][table]
149
+
150
+ existing = state[:tables][table][:columns].find { |c| c[:name] == col }
151
+ existing[:type] = type if existing
152
+ end
153
+ end
154
+
155
+ def apply_change_column_defaults(state, scanner)
156
+ scanner.change_column_defaults.each do |table, col, options|
157
+ next unless state[:tables][table]
158
+
159
+ existing = state[:tables][table][:columns].find { |c| c[:name] == col }
160
+ next unless existing
161
+
162
+ existing[:default] = extract_default_value(options)
163
+ end
164
+ end
165
+
166
+ def apply_change_column_nulls(state, scanner)
167
+ scanner.change_column_nulls.each do |table, col, nullable, default_value|
168
+ next unless state[:tables][table]
169
+
170
+ existing = state[:tables][table][:columns].find { |c| c[:name] == col }
171
+ next unless existing
172
+
173
+ existing[:null] = nullable == "true"
174
+ existing[:default] = extract_default_value(default_value) if default_value
175
+ end
176
+ end
177
+
178
+ def apply_change_column_comments(state, scanner)
179
+ scanner.change_column_comments.each do |table, col, comment|
180
+ next unless state[:tables][table]
181
+
182
+ existing = state[:tables][table][:columns].find { |c| c[:name] == col }
183
+ existing[:comment] = extract_default_value(comment) if existing
184
+ end
185
+ end
186
+
187
+ def apply_add_indexes(state, scanner)
188
+ scanner.add_indexes.each do |table, cols_raw, opts|
189
+ next unless state[:tables][table]
190
+
191
+ state[:tables][table][:indexes] << build_index(cols_raw, opts)
192
+ end
193
+ end
194
+
195
+ def apply_remove_indexes(state, scanner)
196
+ scanner.remove_indexes.each do |table, args|
197
+ next unless state[:tables][table]
198
+
199
+ remove_index_from_table(state[:tables][table], args)
200
+ end
201
+ end
202
+
203
+ def apply_add_foreign_keys(state, scanner)
204
+ scanner.add_foreign_keys.each do |table, to_table, opts|
205
+ next unless state[:tables][table]
206
+
207
+ state[:tables][table][:foreign_keys] << build_foreign_key(to_table, opts)
208
+ end
209
+ end
210
+
211
+ def apply_remove_foreign_keys(state, scanner)
212
+ scanner.remove_foreign_keys.each do |table, args|
213
+ next unless state[:tables][table]
214
+
215
+ remove_foreign_key_from_table(state[:tables][table], args)
216
+ end
217
+ end
218
+
219
+ def apply_add_check_constraints(state, scanner)
220
+ scanner.add_check_constraints.each do |table, expression, opts|
221
+ next unless state[:tables][table]
222
+
223
+ state[:tables][table][:check_constraints] << build_check_constraint(expression, opts)
224
+ end
225
+ end
226
+
227
+ def apply_remove_check_constraints(state, scanner)
228
+ scanner.remove_check_constraints.each do |table, args|
229
+ next unless state[:tables][table]
230
+
231
+ remove_check_constraint_from_table(state[:tables][table], args)
232
+ end
233
+ end
234
+
235
+ def apply_change_table_blocks(state, scanner)
236
+ scanner.change_table_blocks.each do |table, block|
237
+ next unless state[:tables][table]
238
+
239
+ apply_block_table_changes(state[:tables][table], block)
240
+ end
241
+ end
242
+
243
+ def apply_block_table_changes(table_state, block)
244
+ scanner = MigrationDslScanner.new(block)
245
+
246
+ add_block_columns_to_table(table_state, scanner, block)
247
+ add_block_indexes_to_table(table_state, scanner, block)
248
+ apply_block_column_changes(table_state, scanner, block)
249
+ remove_block_columns_from_table(table_state, scanner, block)
250
+ apply_block_check_constraints(table_state, scanner, block)
251
+ end
252
+
253
+ def add_block_columns_to_table(table_state, scanner, block)
254
+ scanner.block_column_definitions(block).each do |definition|
255
+ if definition.first == :column
256
+ _, name, type, opts = definition
257
+ table_state[:columns] << build_column(name, type, opts)
258
+ next
259
+ end
260
+
261
+ type, name, opts = definition
262
+ if reference_type?(type)
263
+ build_reference_columns(name, opts).each { |column| table_state[:columns] << column }
264
+ next
265
+ end
266
+ table_state[:columns] << build_column(name, type, opts)
267
+ end
268
+
269
+ return unless scanner.block_has_timestamps?(block)
270
+
271
+ table_state[:columns] << { name: "created_at", type: "datetime", null: false, default: nil }
272
+ table_state[:columns] << { name: "updated_at", type: "datetime", null: false, default: nil }
273
+ end
274
+
275
+ def add_block_indexes_to_table(table_state, scanner, block)
276
+ scanner.block_add_indexes(block).each do |cols_raw, opts|
277
+ table_state[:indexes] << build_index(cols_raw, opts)
278
+ end
279
+ scanner.block_remove_indexes(block).each do |args|
280
+ remove_index_from_table(table_state, args)
281
+ end
282
+ end
283
+
284
+ def apply_block_column_changes(table_state, scanner, block)
285
+ scanner.block_change_defaults(block).each do |col, options|
286
+ existing = table_state[:columns].find { |column| column[:name] == col }
287
+ next unless existing
288
+
289
+ existing[:default] = extract_default_value(options)
290
+ end
291
+
292
+ scanner.block_change_nulls(block).each do |col, nullable, default_value|
293
+ existing = table_state[:columns].find { |column| column[:name] == col }
294
+ next unless existing
295
+
296
+ existing[:null] = nullable == "true"
297
+ existing[:default] = extract_default_value(default_value) if default_value
298
+ end
299
+
300
+ scanner.block_rename_indexes(block).each do |from, to|
301
+ from_name = clean_identifier(from)
302
+ to_name = clean_identifier(to)
303
+ idx = table_state[:indexes].find { |index| index[:name] == from_name }
304
+ idx[:name] = to_name if idx
305
+ end
306
+ end
307
+
308
+ def remove_block_columns_from_table(table_state, scanner, block)
309
+ scanner.block_remove_columns(block).each do |col|
310
+ table_state[:columns].reject! { |c| c[:name] == col }
311
+ end
312
+ scanner.block_remove_columns_plural(block).each do |cols_raw|
313
+ extract_columns_list(cols_raw).each do |col|
314
+ table_state[:columns].reject! { |c| c[:name] == col }
315
+ end
316
+ end
317
+ scanner.block_remove_references(block).each do |ref, opts|
318
+ remove_reference_columns(table_state, ref, opts)
319
+ end
320
+ end
321
+
322
+ def apply_block_check_constraints(table_state, scanner, block)
323
+ scanner.block_add_check_constraints(block).each do |expression, opts|
324
+ table_state[:check_constraints] << build_check_constraint(expression, opts)
325
+ end
326
+ scanner.block_remove_check_constraints(block).each do |args|
327
+ remove_check_constraint_from_table(table_state, args)
328
+ end
329
+ end
330
+
331
+ def ensure_table(state, table)
332
+ state[:tables][table] ||= { columns: [], indexes: [], foreign_keys: [], check_constraints: [] }
333
+ end
334
+
335
+ def parse_block_columns(block)
336
+ columns = []
337
+ block.scan(/t\.column\s+[:"'](\w+)[:"']?,\s*:?(?:["'])?(\w+)(?:["'])?([^\n]*)/) do |name, type, opts|
338
+ columns << build_column(name, type, opts)
339
+ end
340
+
341
+ block.scan(/t\.(\w+)\s+[:"'](\w+)[:"']?([^\n]*)/) do |type, name, opts|
342
+ next if %w[index timestamps].include?(type)
343
+ next if type == "column"
344
+
345
+ if reference_type?(type)
346
+ build_reference_columns(name, opts).each { |column| columns << column }
347
+ next
348
+ end
349
+ columns << build_column(name, type, opts)
350
+ end
351
+ if block =~ /t\.timestamps/
352
+ columns << { name: "created_at", type: "datetime", null: false, default: nil }
353
+ columns << { name: "updated_at", type: "datetime", null: false, default: nil }
354
+ end
355
+ columns
356
+ end
357
+
358
+ def parse_block_indexes(block)
359
+ MigrationDslScanner.new(block).block_add_indexes(block).map do |cols_raw, opts|
360
+ build_index(cols_raw, opts)
361
+ end
362
+ end
363
+
364
+ def reference_type?(type)
365
+ %w[references belongs_to].include?(type)
366
+ end
367
+
368
+ def build_reference_columns(name, opts = "")
369
+ columns = [{
370
+ name: "#{name}_id",
371
+ type: reference_id_type(opts),
372
+ null: null_value_for_reference_options(opts),
373
+ default: nil
374
+ }]
375
+ if opts =~ /polymorphic:\s*true/
376
+ columns << {
377
+ name: "#{name}_type",
378
+ type: "string",
379
+ null: null_value_for_reference_options(opts),
380
+ default: nil
381
+ }
382
+ end
383
+ columns
384
+ end
385
+
386
+ def remove_reference_columns(table_state, ref, opts = "")
387
+ table_state[:columns].reject! { |c| c[:name] == "#{ref}_id" }
388
+ return unless opts =~ /polymorphic:\s*true/
389
+
390
+ table_state[:columns].reject! { |c| c[:name] == "#{ref}_type" }
391
+ end
392
+
393
+ def reference_id_type(opts)
394
+ type_match = /type:\s*:?(?:["'])?(\w+)(?:["'])?/.match(opts)
395
+ type_match ? type_match[1] : "bigint"
396
+ end
397
+
398
+ def null_value_for_reference_options(opts)
399
+ null_match = /null:\s*(true|false)/.match(opts)
400
+ null_match ? null_match[1] == "true" : true
401
+ end
402
+
403
+ def build_column(name, type, opts = "")
404
+ null_match = /null:\s*(true|false)/.match(opts)
405
+ default_match = /default:\s*([^,\n]+)/.match(opts)
406
+ limit_match = /limit:\s*(\d+)/.match(opts)
407
+ col = {
408
+ name: name,
409
+ type: type,
410
+ null: null_match ? null_match[1] == "true" : true,
411
+ default: default_match ? default_match[1].strip : nil
412
+ }
413
+ col[:limit] = limit_match[1].to_i if limit_match
414
+ col[:precision] = extract_numeric_option(opts, "precision")
415
+ col[:scale] = extract_numeric_option(opts, "scale")
416
+ col[:comment] = extract_string_option(opts, "comment")
417
+ col
418
+ end
419
+
420
+ def build_index(cols_raw, opts)
421
+ name_match = /name:\s*"([^"]+)"/.match(opts)
422
+ unique = /unique:\s*true/.match?(opts)
423
+ cols = if cols_raw.start_with?("[")
424
+ cols_raw.scan(/[:"'](\w+)[:"']?/).flatten
425
+ else
426
+ [cols_raw.gsub(/['":,\s]/, "")]
427
+ end
428
+ { name: name_match&.[](1), columns: cols, unique: unique }
429
+ end
430
+
431
+ def build_foreign_key(to_table, opts)
432
+ {
433
+ to_table: to_table,
434
+ column: extract_string_option(opts, "column"),
435
+ primary_key: extract_string_option(opts, "primary_key"),
436
+ name: extract_string_option(opts, "name")
437
+ }
438
+ end
439
+
440
+ def build_check_constraint(expression, opts)
441
+ {
442
+ expression: expression,
443
+ name: extract_string_option(opts, "name")
444
+ }
445
+ end
446
+
447
+ def remove_index_from_table(table_state, args)
448
+ name_match = /name:\s*["']([^"']+)["']/.match(args)
449
+ return table_state[:indexes].reject! { |idx| idx[:name] == name_match[1] } if name_match
450
+
451
+ column_match = /column:\s*(\[.*?\]|[:"']\w+[:"']?)/.match(args)
452
+ cols_raw = column_match ? column_match[1] : args
453
+ columns = parse_columns_arg(cols_raw)
454
+ table_state[:indexes].reject! { |idx| idx[:columns] == columns }
455
+ end
456
+
457
+ def remove_foreign_key_from_table(table_state, args)
458
+ name_match = /name:\s*["']([^"']+)["']/.match(args)
459
+ if name_match
460
+ table_state[:foreign_keys].reject! { |fk| fk[:name] == name_match[1] }
461
+ return
462
+ end
463
+
464
+ to_table_match = /[:"'](\w+)[:"']?/.match(args)
465
+ return unless to_table_match
466
+
467
+ table_state[:foreign_keys].reject! { |fk| fk[:to_table] == to_table_match[1] }
468
+ end
469
+
470
+ def remove_check_constraint_from_table(table_state, args)
471
+ name_match = /name:\s*["']([^"']+)["']/.match(args)
472
+ if name_match
473
+ table_state[:check_constraints].reject! { |cc| cc[:name] == name_match[1] }
474
+ return
475
+ end
476
+
477
+ expr_match = /["'](.+?)["']/.match(args)
478
+ return unless expr_match
479
+
480
+ table_state[:check_constraints].reject! { |cc| cc[:expression] == expr_match[1] }
481
+ end
482
+
483
+ def clean_identifier(raw)
484
+ raw.to_s.gsub(/['":,\s]/, "")
485
+ end
486
+
487
+ def extract_default_value(raw)
488
+ return nil unless raw
489
+
490
+ to_match = /to:\s*([^,}\n]+)/.match(raw)
491
+ value = to_match ? to_match[1].strip : raw.strip
492
+ value.gsub(/\A["']|["']\z/, "")
493
+ end
494
+
495
+ def extract_string_option(raw, key)
496
+ return nil unless raw
497
+
498
+ match = /#{key}:\s*(?:["']([^"']+)["']|:(\w+)|([^,\n]+))/.match(raw)
499
+ return nil unless match
500
+
501
+ (match[1] || match[2] || match[3]).to_s.strip
502
+ end
503
+
504
+ def extract_numeric_option(raw, key)
505
+ return nil unless raw
506
+
507
+ match = /#{key}:\s*(\d+)/.match(raw)
508
+ match ? match[1].to_i : nil
509
+ end
510
+
511
+ def extract_columns_list(raw)
512
+ columns_part = raw.split(/,\s*\w+:\s*/).first.to_s
513
+ columns_part.scan(/[:"'](\w+)[:"']?/).flatten
514
+ end
515
+
516
+ def parse_columns_arg(cols_raw)
517
+ return cols_raw.scan(/[:"'](\w+)[:"']?/).flatten if cols_raw.start_with?("[")
518
+
519
+ [cols_raw.gsub(/['":,\s]/, "")]
520
+ end
521
+
522
+ def calculate_diff(before, after)
523
+ {
524
+ added_tables: after[:tables].keys - before[:tables].keys,
525
+ removed_tables: before[:tables].keys - after[:tables].keys,
526
+ modified_tables: modified_tables_diff(before, after)
527
+ }
528
+ end
529
+
530
+ def modified_tables_diff(before, after)
531
+ common = before[:tables].keys & after[:tables].keys
532
+ common.each_with_object({}) do |table, result|
533
+ before_cols = before[:tables][table][:columns].map { |c| c[:name] }
534
+ after_cols = after[:tables][table][:columns].map { |c| c[:name] }
535
+ added = after_cols - before_cols
536
+ removed = before_cols - after_cols
537
+ result[table] = { added_columns: added, removed_columns: removed } if added.any? || removed.any?
538
+ end
539
+ end
540
+ end
541
+ end
542
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "migration_dsl_scanner"
4
+
5
+ module Migflow
6
+ module Services
7
+ class TouchedTablesFromMigration
8
+ SCANNER_FIRST_COLUMN_TABLE_METHODS = %i[
9
+ add_columns
10
+ remove_column
11
+ remove_columns
12
+ rename_columns
13
+ change_columns
14
+ change_column_defaults
15
+ change_column_nulls
16
+ change_column_comments
17
+ add_references
18
+ remove_references
19
+ add_indexes
20
+ remove_indexes
21
+ rename_indexes
22
+ add_foreign_keys
23
+ remove_foreign_keys
24
+ add_check_constraints
25
+ remove_check_constraints
26
+ ].freeze
27
+
28
+ def self.call(raw_content:)
29
+ new(raw_content: raw_content).call
30
+ end
31
+
32
+ def initialize(raw_content:)
33
+ @raw_content = raw_content.to_s
34
+ end
35
+
36
+ def call
37
+ scanner = MigrationDslScanner.new(@raw_content)
38
+ names = base_table_names(scanner) + scanner_method_table_names(scanner)
39
+ names.map(&:to_s).reject(&:empty?).uniq
40
+ end
41
+
42
+ private
43
+
44
+ def base_table_names(scanner)
45
+ names = []
46
+ names.concat(scanner.create_table_blocks.map(&:first))
47
+ names.concat(scanner.change_table_blocks.map(&:first))
48
+ names.concat(scanner.drop_tables)
49
+ names.concat(scanner.rename_tables.flatten)
50
+ names
51
+ end
52
+
53
+ def scanner_method_table_names(scanner)
54
+ SCANNER_FIRST_COLUMN_TABLE_METHODS.flat_map do |method_name|
55
+ scanner.public_send(method_name).map(&:first)
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Migflow
4
+ VERSION = "0.2.0"
5
+ end
data/lib/migflow.rb ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "migflow/version"
4
+ require "migflow/engine"
5
+ require "migflow/configuration"
6
+ require "migflow/parsers/migration_parser"
7
+ require "migflow/parsers/schema_parser"
8
+ require "migflow/analyzers/audit_analyzer"
9
+ require "migflow/models/migration_snapshot"
10
+ require "migflow/models/schema_diff"
11
+ require "migflow/models/warning"
12
+ require "migflow/services/schema_builder"
13
+ require "migflow/services/diff_builder"
14
+ require "migflow/services/migration_dsl_scanner"
15
+ require "migflow/services/migration_summary_builder"
16
+ require "migflow/services/snapshot_builder"
17
+ require "migflow/services/schema_patch_builder"
18
+ require "migflow/services/touched_tables_from_migration"
19
+ require "migflow/services/scoped_migration_warnings"
20
+ require "migflow/services/risk_scorer"
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "migflow/services/report_generator"
4
+ require "migflow/reporters"
5
+
6
+ namespace :migflow do
7
+ desc "Analyze migrations and output a report. Options: FORMAT=markdown|json, FAIL_ON=<level|score>, OUTPUT=<path>"
8
+ task report: :environment do
9
+ format = ENV.fetch("FORMAT", "markdown")
10
+ fail_on = ENV.fetch("FAIL_ON", nil)
11
+ output = ENV.fetch("OUTPUT", nil)
12
+
13
+ report = Migflow::Services::ReportGenerator.new.call(
14
+ migrations_path: Migflow.configuration.resolved_migrations_path
15
+ )
16
+ rendered = Migflow::Reporters.for(format).render(report)
17
+
18
+ if output
19
+ File.write(output, rendered)
20
+ $stdout.puts "Report written to #{output}"
21
+ else
22
+ $stdout.puts rendered
23
+ end
24
+
25
+ threshold = Migflow::Reporters.resolve_threshold(fail_on)
26
+ if threshold && report[:summary][:highest_risk_score] >= threshold
27
+ warn "migflow: gate failed — highest risk score #{report[:summary][:highest_risk_score]} >= #{threshold}"
28
+ exit 1
29
+ end
30
+ end
31
+ end
data/sig/migflow.rbs ADDED
@@ -0,0 +1,3 @@
1
+ module Migflow
2
+ VERSION: String
3
+ end