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,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../parsers/migration_parser"
4
+ require_relative "snapshot_builder"
5
+ require_relative "scoped_migration_warnings"
6
+ require_relative "risk_scorer"
7
+
8
+ module Migflow
9
+ module Services
10
+ class ReportGenerator
11
+ def call(migrations_path:)
12
+ migrations = Parsers::MigrationParser.call(migrations_path: migrations_path)
13
+ scorer = RiskScorer.new
14
+
15
+ analyzed = migrations.map { |m| analyze(m, migrations, scorer) }
16
+
17
+ {
18
+ generated_at: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
19
+ summary: build_summary(analyzed),
20
+ migrations: analyzed
21
+ }
22
+ end
23
+
24
+ private
25
+
26
+ def analyze(migration, all_migrations, scorer)
27
+ result = SnapshotBuilder.call(migrations: all_migrations, up_to_version: migration[:version])
28
+ snapshot = snapshot_model(result[:schema_after], migration[:version])
29
+ warnings = ScopedMigrationWarnings.call(snapshot: snapshot, migration: migration, diff: result[:diff])
30
+ risk = scorer.call(warnings)
31
+
32
+ {
33
+ version: migration[:version],
34
+ name: migration[:name],
35
+ risk_score: risk[:score],
36
+ risk_level: risk[:level],
37
+ warnings: warnings.map { |w| serialize_warning(w) }
38
+ }
39
+ end
40
+
41
+ def snapshot_model(schema_after, version)
42
+ Models::MigrationSnapshot.new(
43
+ version: version,
44
+ name: "Historical schema",
45
+ tables: schema_after[:tables],
46
+ raw_content: ""
47
+ )
48
+ end
49
+
50
+ def serialize_warning(warning)
51
+ {
52
+ rule: warning.rule,
53
+ severity: warning.severity.to_s,
54
+ table: warning.table,
55
+ column: warning.column,
56
+ message: warning.message
57
+ }
58
+ end
59
+
60
+ def build_summary(analyzed)
61
+ with_warnings = analyzed.count { |m| m[:warnings].any? }
62
+ with_errors = analyzed.count { |m| m[:warnings].any? { |w| w[:severity] == "error" } }
63
+ max_score = analyzed.map { |m| m[:risk_score] }.max || 0
64
+ max_level = analyzed.max_by { |m| m[:risk_score] }&.fetch(:risk_level) || "safe"
65
+
66
+ {
67
+ total_migrations: analyzed.size,
68
+ migrations_with_warnings: with_warnings,
69
+ migrations_with_errors: with_errors,
70
+ highest_risk_score: max_score,
71
+ highest_risk_level: max_level
72
+ }
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Migflow
4
+ module Services
5
+ class RiskScorer
6
+ RULE_WEIGHTS = {
7
+ "dangerous_migration_rule" => 40,
8
+ "missing_index_rule" => 15,
9
+ "missing_foreign_key_rule" => 20,
10
+ "string_without_limit_rule" => 5,
11
+ "null_column_without_default_rule" => 20,
12
+ "missing_timestamps_rule" => 5
13
+ }.freeze
14
+
15
+ LEVELS = [
16
+ { min: 71, max: 100, level: "high" },
17
+ { min: 31, max: 70, level: "medium" },
18
+ { min: 1, max: 30, level: "low" },
19
+ { min: 0, max: 0, level: "safe" }
20
+ ].freeze
21
+
22
+ def call(warnings)
23
+ factors = warnings.filter_map do |w|
24
+ weight = RULE_WEIGHTS[w.rule]
25
+ next unless weight
26
+
27
+ { rule: w.rule, message: w.message, weight: weight }
28
+ end
29
+
30
+ raw = factors.sum { |f| f[:weight] }
31
+ score = [raw, 100].min
32
+ level = LEVELS.find { |l| score.between?(l[:min], l[:max]) }&.fetch(:level, "safe")
33
+
34
+ { score: score, level: level, factors: factors }
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Migflow
4
+ module Services
5
+ class SchemaBuilder
6
+ def self.call(schema_path:)
7
+ new(schema_path: schema_path).build
8
+ end
9
+
10
+ def initialize(schema_path:)
11
+ @schema_path = schema_path
12
+ end
13
+
14
+ def build
15
+ parsed = Parsers::SchemaParser.call(schema_path: @schema_path)
16
+ Models::MigrationSnapshot.new(
17
+ version: parsed[:version],
18
+ name: "Current schema",
19
+ tables: parsed[:tables],
20
+ raw_content: Pathname.new(@schema_path).read
21
+ )
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Migflow
4
+ module Services
5
+ class SchemaPatchBuilder
6
+ def self.call(from_tables:, to_tables:, changed_tables: nil, include_unchanged: false)
7
+ new(
8
+ from_tables: from_tables || {},
9
+ to_tables: to_tables || {},
10
+ changed_tables: changed_tables,
11
+ include_unchanged: include_unchanged
12
+ ).build
13
+ end
14
+
15
+ def initialize(from_tables:, to_tables:, changed_tables:, include_unchanged:)
16
+ @from_tables = from_tables
17
+ @to_tables = to_tables
18
+ @changed_tables = changed_tables&.to_set
19
+ @include_unchanged = include_unchanged
20
+ end
21
+
22
+ def build
23
+ document = build_document
24
+ lines = document[:lines]
25
+ has_diff = lines.any? { |line| ["+", "-"].include?(line[:prefix]) }
26
+ return "" unless has_diff
27
+
28
+ return build_full_patch(lines) if @include_unchanged
29
+
30
+ build_collapsed_patch(lines, document[:sections])
31
+ end
32
+
33
+ private
34
+
35
+ def build_full_patch(lines)
36
+ old_count = lines.count { |line| line[:prefix] != "+" }
37
+ new_count = lines.count { |line| line[:prefix] != "-" }
38
+
39
+ [
40
+ "diff --git a/schema.rb b/schema.rb",
41
+ "--- a/schema.rb",
42
+ "+++ b/schema.rb",
43
+ "@@ -1,#{old_count} +1,#{new_count} @@",
44
+ *lines.map { |line| "#{line[:prefix]}#{line[:content]}" },
45
+ ""
46
+ ].join("\n")
47
+ end
48
+
49
+ def build_collapsed_patch(lines, sections)
50
+ hunk_ranges = table_hunk_ranges(lines, sections)
51
+ return "" if hunk_ranges.empty?
52
+
53
+ hunk_blocks = hunk_ranges.map do |range|
54
+ old_start = line_number_before(lines, range.begin, :old) + 1
55
+ new_start = line_number_before(lines, range.begin, :new) + 1
56
+ hunk_lines = lines[range]
57
+ old_count = hunk_lines.count { |line| line[:prefix] != "+" }
58
+ new_count = hunk_lines.count { |line| line[:prefix] != "-" }
59
+
60
+ [
61
+ "@@ -#{old_start},#{old_count} +#{new_start},#{new_count} @@",
62
+ *hunk_lines.map { |line| "#{line[:prefix]}#{line[:content]}" }
63
+ ].join("\n")
64
+ end
65
+
66
+ [
67
+ "diff --git a/schema.rb b/schema.rb",
68
+ "--- a/schema.rb",
69
+ "+++ b/schema.rb",
70
+ *hunk_blocks,
71
+ ""
72
+ ].join("\n")
73
+ end
74
+
75
+ def table_hunk_ranges(lines, sections)
76
+ sections.each_with_object([]) do |section, ranges|
77
+ next unless section_changed?(lines, section)
78
+
79
+ # Keep schema block anchor visible so columns are not "floating"
80
+ # Always include full table block so each `create_table` has its
81
+ # matching `end` and changes never look nested/confusing.
82
+ ranges << (section[:start]..section[:end])
83
+ end
84
+ end
85
+
86
+ def line_number_before(lines, idx_exclusive, side)
87
+ slice = idx_exclusive.zero? ? [] : lines[0...idx_exclusive]
88
+ case side
89
+ when :old
90
+ slice.count { |line| line[:prefix] != "+" }
91
+ when :new
92
+ slice.count { |line| line[:prefix] != "-" }
93
+ else
94
+ 0
95
+ end
96
+ end
97
+
98
+ def section_changed?(lines, section)
99
+ has_diff_lines = (section[:start]..section[:end]).any? { |idx| %w[+ -].include?(lines[idx][:prefix]) }
100
+ return has_diff_lines if @changed_tables.nil?
101
+ return false unless @changed_tables.include?(section[:table_name])
102
+
103
+ has_diff_lines
104
+ end
105
+
106
+ def build_document
107
+ lines = []
108
+ sections = []
109
+ all_table_names.each_with_index do |table_name, idx|
110
+ from_t = @from_tables[table_name]
111
+ to_t = @to_tables[table_name]
112
+ section_start = lines.length
113
+ section_lines = table_lines(table_name, from_t, to_t)
114
+ lines.concat(section_lines)
115
+ section_end = lines.length - 1
116
+ sections << { table_name: table_name, start: section_start, end: section_end }
117
+ lines << { prefix: " ", content: "" } if idx < all_table_names.length - 1
118
+ end
119
+ { lines: lines, sections: sections }
120
+ end
121
+
122
+ def all_table_names
123
+ (@from_tables.keys + @to_tables.keys).uniq.sort
124
+ end
125
+
126
+ def table_lines(table_name, from_t, to_t)
127
+ lines = []
128
+ is_added = from_t.nil? && !to_t.nil?
129
+ is_removed = !from_t.nil? && to_t.nil?
130
+
131
+ header_prefix = if is_added
132
+ "+"
133
+ else
134
+ is_removed ? "-" : " "
135
+ end
136
+ lines << { prefix: header_prefix, content: "create_table \"#{table_name}\" do |t|" }
137
+
138
+ from_columns = columns_map(from_t)
139
+ to_columns = columns_map(to_t)
140
+ (from_columns.keys + to_columns.keys).uniq.sort.each do |col_name|
141
+ from_col = from_columns[col_name]
142
+ to_col = to_columns[col_name]
143
+ if from_col.nil?
144
+ lines << { prefix: "+", content: " #{format_column(to_col)}" }
145
+ next
146
+ end
147
+
148
+ if to_col.nil?
149
+ lines << { prefix: "-", content: " #{format_column(from_col)}" }
150
+ next
151
+ end
152
+
153
+ if equivalent_column?(from_col, to_col)
154
+ lines << { prefix: " ", content: " #{format_column(to_col)}" }
155
+ else
156
+ lines << { prefix: "-", content: " #{format_column(from_col)}" }
157
+ lines << { prefix: "+", content: " #{format_column(to_col)}" }
158
+ end
159
+ end
160
+
161
+ from_indexes = indexes_map(from_t)
162
+ to_indexes = indexes_map(to_t)
163
+ (from_indexes.keys + to_indexes.keys).uniq.sort.each do |idx_key|
164
+ from_idx = from_indexes[idx_key]
165
+ to_idx = to_indexes[idx_key]
166
+ if from_idx.nil?
167
+ lines << { prefix: "+", content: " #{format_index(to_idx)}" }
168
+ next
169
+ end
170
+
171
+ if to_idx.nil?
172
+ lines << { prefix: "-", content: " #{format_index(from_idx)}" }
173
+ next
174
+ end
175
+
176
+ if equivalent_index?(from_idx, to_idx)
177
+ lines << { prefix: " ", content: " #{format_index(to_idx)}" }
178
+ else
179
+ lines << { prefix: "-", content: " #{format_index(from_idx)}" }
180
+ lines << { prefix: "+", content: " #{format_index(to_idx)}" }
181
+ end
182
+ end
183
+
184
+ lines << { prefix: " ", content: "end" }
185
+ lines
186
+ end
187
+
188
+ def columns_map(table)
189
+ return {} unless table
190
+
191
+ table[:columns].to_h { |col| [col[:name], col] }
192
+ end
193
+
194
+ def indexes_map(table)
195
+ return {} unless table
196
+
197
+ table[:indexes].each_with_object({}) do |idx, memo|
198
+ key = idx[:name] || idx[:columns].join("_")
199
+ memo[key] = idx
200
+ end
201
+ end
202
+
203
+ def format_column(col)
204
+ type = col[:type] || "string"
205
+ opts = []
206
+ opts << "null: false" if col[:null] == false
207
+ opts << "default: #{col[:default]}" unless col[:default].nil?
208
+ opts << "limit: #{col[:limit]}" unless col[:limit].nil?
209
+ base = "t.#{type} \"#{col[:name]}\""
210
+ opts.empty? ? base : "#{base}, #{opts.join(", ")}"
211
+ end
212
+
213
+ def format_index(idx)
214
+ columns = "[#{idx[:columns].map { |c| "\"#{c}\"" }.join(", ")}]"
215
+ opts = []
216
+ opts << "name: \"#{idx[:name]}\"" if idx[:name]
217
+ opts << "unique: true" if idx[:unique]
218
+ base = "t.index #{columns}"
219
+ opts.empty? ? base : "#{base}, #{opts.join(", ")}"
220
+ end
221
+
222
+ def equivalent_column?(one, other)
223
+ one[:name] == other[:name] &&
224
+ one[:type] == other[:type] &&
225
+ one[:null] == other[:null] &&
226
+ one[:default] == other[:default] &&
227
+ one[:limit] == other[:limit]
228
+ end
229
+
230
+ def equivalent_index?(one, other)
231
+ one[:name] == other[:name] &&
232
+ one[:unique] == other[:unique] &&
233
+ one[:columns] == other[:columns]
234
+ end
235
+ end
236
+ end
237
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../analyzers/audit_analyzer"
4
+ require_relative "../models/warning"
5
+ require_relative "touched_tables_from_migration"
6
+
7
+ module Migflow
8
+ module Services
9
+ class ScopedMigrationWarnings
10
+ MIGRATION_LEVEL_RULES = %w[
11
+ dangerous_migration_rule
12
+ null_column_without_default_rule
13
+ ].freeze
14
+
15
+ NOOP_INFO_RULE = "no_schema_change_migration_rule"
16
+
17
+ def self.call(snapshot:, migration:, diff: nil)
18
+ new(snapshot: snapshot, migration: migration, diff: diff).call
19
+ end
20
+
21
+ def initialize(snapshot:, migration:, diff: nil)
22
+ @snapshot = snapshot
23
+ @migration = migration
24
+ @diff = diff
25
+ end
26
+
27
+ def call
28
+ warnings = Analyzers::AuditAnalyzer.call(snapshot: @snapshot, raw_migrations: [@migration])
29
+ touched_tables = TouchedTablesFromMigration.call(raw_content: @migration[:raw_content])
30
+
31
+ if touched_tables.empty?
32
+ result = warnings.select { migration_level_warning?(_1) }
33
+ result << noop_migration_warning if noop_migration?(@migration[:raw_content].to_s)
34
+ return result
35
+ end
36
+
37
+ warnings.select do |warning|
38
+ migration_level_warning?(warning) || relevant_schema_warning?(warning, touched_tables)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def relevant_schema_warning?(warning, touched_tables)
45
+ table = warning.table.to_s
46
+ return false unless touched_tables.include?(table)
47
+ return true if @diff.nil?
48
+
49
+ change_scoped_relevant?(warning, table)
50
+ end
51
+
52
+ def change_scoped_relevant?(warning, table)
53
+ return true if added_table?(table)
54
+
55
+ col = warning.column.to_s
56
+ return false if col.empty?
57
+
58
+ added_columns_for(table).include?(col)
59
+ end
60
+
61
+ def added_table?(table)
62
+ (@diff[:added_tables] || []).map(&:to_s).include?(table)
63
+ end
64
+
65
+ def added_columns_for(table)
66
+ entry = (@diff[:modified_tables] || {})[table] || {}
67
+ (entry[:added_columns] || []).map(&:to_s)
68
+ end
69
+
70
+ def migration_level_warning?(warning)
71
+ MIGRATION_LEVEL_RULES.include?(warning.rule.to_s)
72
+ end
73
+
74
+ def noop_migration?(content)
75
+ return false if content.match?(/\bexecute\b/)
76
+ return false if content.match?(/\benable_extension\b/)
77
+ return false if content.match?(/\bcreate_extension\b/)
78
+
79
+ true
80
+ end
81
+
82
+ def noop_migration_warning
83
+ Models::Warning.new(
84
+ rule: NOOP_INFO_RULE,
85
+ severity: :info,
86
+ table: "_",
87
+ column: nil,
88
+ message: "No schema operations detected (empty migration or DSL not recognized by Migflow)."
89
+ )
90
+ end
91
+ end
92
+ end
93
+ end