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,14 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>frontend</title>
8
+ <script type="module" crossorigin src="/app.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/app.css">
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ </body>
14
+ </html>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Migflow
4
+ module Api
5
+ class DiffController < ApplicationController
6
+ def show
7
+ return render_error("params 'from' and 'to' are required") unless from_version && to_version
8
+
9
+ migrations = Parsers::MigrationParser.call(migrations_path: migrations_path)
10
+ from_data = find_migration(migrations, from_version)
11
+ to_data = find_migration(migrations, to_version)
12
+
13
+ return render_error("One or both migrations not found", status: :not_found) unless from_data && to_data
14
+
15
+ result = build_diff(migrations, from_data, to_data)
16
+ render_json(diff: serialize_diff(result))
17
+ end
18
+
19
+ private
20
+
21
+ def from_version = params[:from]
22
+ def to_version = params[:to]
23
+
24
+ def find_migration(migrations, version)
25
+ migrations.find { |m| m[:version] == version }
26
+ end
27
+
28
+ def build_diff(migrations, from_data, to_data)
29
+ ordered = migrations.sort_by { |migration| migration[:version] }
30
+ from_idx = ordered.find_index { |migration| migration[:version] == from_data[:version] }
31
+ previous_from = from_idx&.positive? ? ordered[from_idx - 1] : nil
32
+
33
+ from_tables = if previous_from
34
+ previous_result = Services::SnapshotBuilder.call(migrations: migrations,
35
+ up_to_version: previous_from[:version])
36
+ previous_result[:schema_after][:tables]
37
+ else
38
+ {}
39
+ end
40
+ to_result = Services::SnapshotBuilder.call(migrations: migrations, up_to_version: to_data[:version])
41
+
42
+ diff = Services::DiffBuilder.call(
43
+ from_tables: from_tables,
44
+ to_tables: to_result[:schema_after][:tables],
45
+ from_version: from_data[:version],
46
+ to_version: to_data[:version]
47
+ )
48
+
49
+ {
50
+ diff: diff,
51
+ from_tables: from_tables,
52
+ to_tables: to_result[:schema_after][:tables]
53
+ }
54
+ end
55
+
56
+ def serialize_diff(result)
57
+ diff = result[:diff]
58
+ changed_tables = diff.changes.map(&:table).uniq
59
+
60
+ {
61
+ from_version: diff.from_version,
62
+ to_version: diff.to_version,
63
+ changes: diff.changes.map { |c| { type: c.type, table: c.table, detail: c.detail } },
64
+ **serialize_schema_patches(
65
+ from_tables: result[:from_tables],
66
+ to_tables: result[:to_tables],
67
+ changed_tables: changed_tables
68
+ )
69
+ }
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Migflow
4
+ module Api
5
+ class MigrationsController < ApplicationController
6
+ def index
7
+ migrations = Parsers::MigrationParser.call(migrations_path: migrations_path)
8
+ render_json(migrations: migrations.map { |m| serialize_summary(m, migrations) })
9
+ end
10
+
11
+ def show
12
+ migrations = Parsers::MigrationParser.call(migrations_path: migrations_path)
13
+ migration = migrations.find { |m| m[:version] == params[:id] }
14
+
15
+ return render_error("Migration not found", status: :not_found) unless migration
16
+
17
+ result = Services::SnapshotBuilder.call(migrations: migrations, up_to_version: params[:id])
18
+ snapshot = snapshot_model_from(result[:schema_after], migration[:version])
19
+ warnings = Services::ScopedMigrationWarnings.call(snapshot: snapshot, migration: migration, diff: result[:diff])
20
+ risk = Services::RiskScorer.new.call(warnings)
21
+
22
+ render_json(migration: serialize_detail(migration, result, warnings, risk))
23
+ end
24
+
25
+ private
26
+
27
+ def snapshot_model_from(schema_after, version)
28
+ Models::MigrationSnapshot.new(
29
+ version: version,
30
+ name: "Historical schema",
31
+ tables: schema_after[:tables],
32
+ raw_content: ""
33
+ )
34
+ end
35
+
36
+ def serialize_summary(migration, all_migrations)
37
+ risk = compute_risk(migration, all_migrations)
38
+ {
39
+ version: migration[:version],
40
+ name: migration[:name],
41
+ filename: migration[:filename],
42
+ summary: Services::MigrationSummaryBuilder.call(
43
+ raw_content: migration[:raw_content],
44
+ version: migration[:version]
45
+ ),
46
+ risk_score: risk[:score],
47
+ risk_level: risk[:level]
48
+ }
49
+ end
50
+
51
+ def serialize_detail(migration, snapshot_result, warnings, risk)
52
+ schema_before_tables = snapshot_result[:schema_before][:tables]
53
+ schema_after_tables = snapshot_result[:schema_after][:tables]
54
+ diff = snapshot_result[:diff]
55
+ changed_tables = (
56
+ diff[:added_tables] +
57
+ diff[:removed_tables] +
58
+ diff[:modified_tables].keys
59
+ ).uniq
60
+
61
+ {
62
+ version: migration[:version],
63
+ name: migration[:name],
64
+ raw_content: Migflow.configuration.expose_raw_content ? migration[:raw_content] : nil,
65
+ schema_after: { tables: schema_after_tables },
66
+ diff: diff,
67
+ **serialize_schema_patches(
68
+ from_tables: schema_before_tables,
69
+ to_tables: schema_after_tables,
70
+ changed_tables: changed_tables
71
+ ),
72
+ warnings: warnings.map { |w| serialize_warning(w) },
73
+ risk_score: risk[:score],
74
+ risk_level: risk[:level],
75
+ risk_factors: risk[:factors]
76
+ }
77
+ end
78
+
79
+ def serialize_warning(warning)
80
+ {
81
+ rule: warning.rule,
82
+ severity: warning.severity,
83
+ table: warning.table,
84
+ column: warning.column,
85
+ message: warning.message
86
+ }
87
+ end
88
+
89
+ def compute_risk(migration, all_migrations)
90
+ result = Services::SnapshotBuilder.call(migrations: all_migrations, up_to_version: migration[:version])
91
+ snapshot = snapshot_model_from(result[:schema_after], migration[:version])
92
+ warnings = Services::ScopedMigrationWarnings.call(snapshot: snapshot, migration: migration, diff: result[:diff])
93
+ Services::RiskScorer.new.call(warnings)
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Migflow
4
+ class ApplicationController < Migflow.configuration.parent_controller.constantize
5
+ layout false
6
+ protect_from_forgery with: :null_session
7
+ before_action :run_user_before_action
8
+
9
+ def index
10
+ render "migflow/application/index"
11
+ end
12
+
13
+ private
14
+
15
+ def run_user_before_action
16
+ hook = Migflow.configuration.authentication_hook
17
+ instance_exec(&hook) if hook
18
+ end
19
+
20
+ def request_authentication
21
+ redir = Migflow.configuration.unauthenticated_redirect
22
+ if redir
23
+ session[:return_to_after_authenticating] = request.url
24
+ redirect_to instance_exec(&redir)
25
+ else
26
+ super
27
+ end
28
+ end
29
+
30
+ def migrations_path
31
+ Migflow.configuration.resolved_migrations_path
32
+ end
33
+
34
+ def schema_path
35
+ Migflow.configuration.resolved_schema_path
36
+ end
37
+
38
+ def render_json(status: :ok, **data)
39
+ render json: data, status: status
40
+ end
41
+
42
+ def render_error(message, status: :unprocessable_entity)
43
+ render json: { error: message }, status: status
44
+ end
45
+
46
+ def serialize_schema_patches(from_tables:, to_tables:, changed_tables: nil)
47
+ {
48
+ schema_patch: Services::SchemaPatchBuilder.call(
49
+ from_tables: from_tables,
50
+ to_tables: to_tables,
51
+ changed_tables: changed_tables,
52
+ include_unchanged: false
53
+ ),
54
+ schema_patch_full: Services::SchemaPatchBuilder.call(
55
+ from_tables: from_tables,
56
+ to_tables: to_tables,
57
+ include_unchanged: true
58
+ )
59
+ }
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,16 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Migflow</title>
7
+ <%= stylesheet_link_tag "migflow/app" %>
8
+ </head>
9
+ <body>
10
+ <div
11
+ id="schema-trail-root"
12
+ data-api-base="<%= migflow.root_path %>api"
13
+ ></div>
14
+ <%= javascript_include_tag "migflow/app", defer: true %>
15
+ </body>
16
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ Migflow::Engine.routes.draw do
4
+ root to: "application#index"
5
+
6
+ namespace :api do
7
+ resources :migrations, only: %i[index show]
8
+ get "diff", to: "diff#show"
9
+ end
10
+ end
@@ -0,0 +1,130 @@
1
+ # Architecture
2
+
3
+ Migflow is a mountable Rails engine. The backend parses migration files and `schema.rb` on each request — no database tables, no background jobs. The frontend is a React SPA served as static assets from the engine's asset pipeline.
4
+
5
+ ## High-level structure
6
+
7
+ ```
8
+ Host Rails app
9
+ └── Migflow::Engine (mounted at /migflow)
10
+ ├── app/controllers/migflow/api/ # JSON REST API
11
+ ├── app/assets/migflow/ # Compiled frontend (React SPA)
12
+ └── lib/migflow/
13
+ ├── parsers/ # File I/O and extraction
14
+ ├── models/ # Immutable value objects (Data.define)
15
+ ├── services/ # Business logic
16
+ ├── analyzers/rules/ # Audit rules
17
+ └── reporters/ # CI report output formats
18
+ ```
19
+
20
+ ## Data flow
21
+
22
+ ```
23
+ db/migrate/*.rb ──► MigrationParser ──► Array<MigrationSnapshot>
24
+ db/schema.rb ──► SchemaParser ──► Hash<table, columns+indexes>
25
+
26
+ MigrationSnapshot
27
+ └──► MigrationDslScanner ──► SnapshotBuilder
28
+ (replays DSL calls to reconstruct
29
+ schema state at each point in history)
30
+
31
+ before_snapshot + after_snapshot
32
+ ├──► DiffBuilder ──► SchemaDiff (added/removed tables, columns, indexes)
33
+ ├──► SchemaPatchBuilder ──► unified diff hunks (schema.rb format)
34
+ ├──► AuditAnalyzer ──► Array<Warning>
35
+ ├──► RiskScorer ──► score (0–100) + level
36
+ └──► MigrationSummaryBuilder ──► human-readable one-liner
37
+ ```
38
+
39
+ ## Layers
40
+
41
+ ### Parsers (`lib/migflow/parsers/`)
42
+
43
+ Read files on disk and return structured data. No business logic.
44
+
45
+ | Class | Input | Output |
46
+ |---|---|---|
47
+ | `MigrationParser` | `db/migrate/*.rb` | `Array<MigrationSnapshot>` |
48
+ | `SchemaParser` | `db/schema.rb` | `Hash` of tables → columns + indexes |
49
+
50
+ ### Models (`lib/migflow/models/`)
51
+
52
+ Immutable value objects using `Data.define` (Ruby 3.2+). No methods beyond accessors. Never subclass or add callbacks.
53
+
54
+ Key types: `MigrationSnapshot`, `SchemaDiff`, `Warning`, `MigrationDetail`.
55
+
56
+ ### Services (`lib/migflow/services/`)
57
+
58
+ One class, one concern. Each takes plain Ruby values in and returns plain Ruby values out.
59
+
60
+ | Class | Responsibility |
61
+ |---|---|
62
+ | `SnapshotBuilder` | Replays DSL calls via `MigrationDslScanner` to reconstruct schema at any historical point. The most complex class in the codebase — read it first before touching snapshot or diff logic. |
63
+ | `DiffBuilder` | Compares two schema snapshots and produces a `SchemaDiff` |
64
+ | `SchemaPatchBuilder` | Generates unified diff hunks in `schema.rb` format |
65
+ | `MigrationSummaryBuilder` | Produces a one-line human summary from a `SchemaDiff` |
66
+ | `ReportGenerator` | Orchestrates the full pipeline for CI report output |
67
+
68
+ ### Analyzers (`lib/migflow/analyzers/rules/`)
69
+
70
+ Six rule classes, each implementing `#check(migration_content, tables:) → Array<Warning>`.
71
+
72
+ | Rule | What it catches |
73
+ |---|---|
74
+ | `MissingIndexRule` | Foreign-key columns without an index |
75
+ | `MissingForeignKeyRule` | `_id` columns without a DB-level foreign key constraint |
76
+ | `StringWithoutLimitRule` | String columns declared without `:limit` |
77
+ | `MissingTimestampsRule` | Tables missing `created_at` / `updated_at` |
78
+ | `DangerousMigrationRule` | Destructive operations: `remove_column`, `drop_table`, `rename_column` |
79
+ | `NullColumnWithoutDefaultRule` | `null: false` column added without a `:default` |
80
+
81
+ Adding a new rule: create a class in `lib/migflow/analyzers/rules/`, inherit from `BaseRule`, implement `#check`, and register it in `AuditAnalyzer`.
82
+
83
+ ### Controllers (`app/controllers/migflow/api/`)
84
+
85
+ Thin. Deserialize params, delegate to services, serialize JSON. No business logic lives here.
86
+
87
+ | Endpoint | Controller |
88
+ |---|---|
89
+ | `GET /api/migrations` | `MigrationsController#index` |
90
+ | `GET /api/migrations/:version` | `MigrationsController#show` |
91
+ | `GET /api/diff` | `DiffController#show` |
92
+
93
+ ### Reporters (`lib/migflow/reporters/`)
94
+
95
+ Consumed by the `migflow:report` Rake task. Each reporter receives a `ReportGenerator` result and renders it to a string.
96
+
97
+ | Class | Format |
98
+ |---|---|
99
+ | `MarkdownReporter` | Human-readable table for terminal / GitHub Step Summary |
100
+ | `JsonReporter` | Machine-readable JSON for downstream tooling |
101
+
102
+ ## Frontend
103
+
104
+ Built with React + TypeScript, bundled by Vite into `app/assets/migflow/`.
105
+
106
+ ```
107
+ frontend/src/
108
+ ├── api/client.ts # fetch wrappers for the three REST endpoints
109
+ ├── store/useSchemaStore.ts # Zustand store — selected version, compare target, view mode
110
+ ├── hooks/
111
+ │ └── useSchemaComparisonData.ts # central data hook — all React Query subscriptions
112
+ ├── components/
113
+ │ ├── Timeline.tsx # left-side migration list
114
+ │ ├── CompareBar.tsx # base/target selectors and view mode switch
115
+ │ ├── SchemaCanvas.tsx # ERD canvas (ReactFlow / @xyflow/react)
116
+ │ ├── SchemaDiffView.tsx # unified diff panel (react-diff-view)
117
+ │ └── DetailPanel.tsx # right-side code + warnings panel
118
+ └── types/migration.ts # domain types shared across components
119
+ ```
120
+
121
+ **State management:** Zustand for UI state (selected version, compare target, view mode, collapsed tables). React Query (`@tanstack/react-query`) for server state with a 30-second stale time.
122
+
123
+ **`useSchemaComparisonData`** is the single hook that all canvas/diff components consume. It derives `comparePairValid`, fetches the three or four queries needed, and returns ready-to-use data plus loading flags. Avoid duplicating query logic in individual components — extend this hook instead.
124
+
125
+ ## Key design decisions
126
+
127
+ - **No database.** Everything is derived from files on disk. This means zero setup beyond mounting the engine and zero schema migrations to run in the host app.
128
+ - **Immutable models.** `Data.define` value objects keep services easy to test in isolation — pass data in, get data out, no shared mutable state.
129
+ - **`SnapshotBuilder` replays history.** Rather than storing pre-computed snapshots, the engine replays migration DSL calls in order using `MigrationDslScanner`. This trades CPU for simplicity — no persistence layer, no cache invalidation problem.
130
+ - **Asset pipeline over CDN.** The frontend is served as compiled static files by the engine's asset pipeline. No CDN dependency, works in air-gapped environments.
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rules/base_rule"
4
+ require_relative "rules/missing_index_rule"
5
+ require_relative "rules/missing_foreign_key_rule"
6
+ require_relative "rules/string_without_limit_rule"
7
+ require_relative "rules/missing_timestamps_rule"
8
+ require_relative "rules/dangerous_migration_rule"
9
+ require_relative "rules/null_column_without_default_rule"
10
+
11
+ module Migflow
12
+ module Analyzers
13
+ class AuditAnalyzer
14
+ RULES = [
15
+ Rules::MissingIndexRule,
16
+ Rules::MissingForeignKeyRule,
17
+ Rules::StringWithoutLimitRule,
18
+ Rules::MissingTimestampsRule,
19
+ Rules::DangerousMigrationRule,
20
+ Rules::NullColumnWithoutDefaultRule
21
+ ].freeze
22
+
23
+ def self.call(snapshot:, raw_migrations: [])
24
+ new(snapshot: snapshot, raw_migrations: raw_migrations).analyze
25
+ end
26
+
27
+ def initialize(snapshot:, raw_migrations:)
28
+ @snapshot = snapshot
29
+ @raw_migrations = raw_migrations
30
+ end
31
+
32
+ def analyze
33
+ schema_warnings + migration_warnings
34
+ end
35
+
36
+ private
37
+
38
+ def schema_warnings
39
+ RULES.flat_map do |rule_class|
40
+ rule_class.new.call(@snapshot.tables)
41
+ rescue StandardError => e
42
+ Rails.logger.error "[Migflow] Rule #{rule_class} failed: #{e.message}"
43
+ []
44
+ end
45
+ end
46
+
47
+ def migration_warnings
48
+ [
49
+ Rules::DangerousMigrationRule.new.call_with_migrations(@raw_migrations),
50
+ Rules::NullColumnWithoutDefaultRule.new.call_with_migrations(@raw_migrations)
51
+ ].flatten
52
+ rescue StandardError => e
53
+ Rails.logger.error "[Migflow] Migration analysis failed: #{e.message}"
54
+ []
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Migflow
4
+ module Analyzers
5
+ module Rules
6
+ class BaseRule
7
+ def call(tables)
8
+ raise NotImplementedError
9
+ end
10
+
11
+ private
12
+
13
+ def warning(table:, message:, column: nil, severity: :warning)
14
+ Models::Warning.new(
15
+ rule: rule_name,
16
+ severity: severity,
17
+ table: table,
18
+ column: column,
19
+ message: message
20
+ )
21
+ end
22
+
23
+ def rule_name
24
+ short = self.class.name.split("::").last
25
+ short.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
26
+ .gsub(/([a-z])([A-Z])/, '\1_\2')
27
+ .downcase
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Migflow
4
+ module Analyzers
5
+ module Rules
6
+ class DangerousMigrationRule < BaseRule
7
+ DANGEROUS_OPERATIONS = {
8
+ remove_column: "Removes a column — may break running app instances",
9
+ drop_table: "Drops a table — destructive and irreversible",
10
+ rename_column: "Renames a column — breaks existing queries and code"
11
+ }.freeze
12
+
13
+ def call(_tables)
14
+ []
15
+ end
16
+
17
+ def call_with_migrations(raw_migrations)
18
+ raw_migrations.flat_map do |migration|
19
+ detect_dangers(migration[:raw_content], migration[:filename])
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def detect_dangers(content, filename)
26
+ DANGEROUS_OPERATIONS.filter_map do |operation, message|
27
+ next unless content.match?(/\b#{operation}\b/)
28
+
29
+ warning(
30
+ table: extract_table(content, operation),
31
+ message: "#{filename}: #{message}",
32
+ severity: :error
33
+ )
34
+ end
35
+ end
36
+
37
+ def extract_table(content, operation)
38
+ match = content.match(/#{operation}\s*[:(]\s*[:"']?(\w+)/)
39
+ match ? match[1] : "unknown"
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Migflow
4
+ module Analyzers
5
+ module Rules
6
+ class MissingForeignKeyRule < BaseRule
7
+ FOREIGN_KEY_PATTERN = /foreign_key.*references/
8
+
9
+ def call(tables)
10
+ tables.flat_map do |table_name, table|
11
+ foreign_key_columns(table).map do |col|
12
+ warning(
13
+ table: table_name,
14
+ column: col[:name],
15
+ message: "Column '#{col[:name]}' looks like a foreign key but has no foreign key constraint",
16
+ severity: :info
17
+ )
18
+ end
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def foreign_key_columns(table)
25
+ table[:columns].select { |col| col[:name].end_with?("_id") }
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Migflow
4
+ module Analyzers
5
+ module Rules
6
+ class MissingIndexRule < BaseRule
7
+ def call(tables)
8
+ tables.flat_map do |table_name, table|
9
+ foreign_key_columns(table).reject { |col| indexed?(col, table) }.map do |col|
10
+ warning(
11
+ table: table_name,
12
+ column: col[:name],
13
+ message: "Column '#{col[:name]}' looks like a foreign key but has no index",
14
+ severity: :warning
15
+ )
16
+ end
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def foreign_key_columns(table)
23
+ table[:columns].select { |col| col[:name].end_with?("_id") }
24
+ end
25
+
26
+ def indexed?(column, table)
27
+ table[:indexes].any? { |idx| idx[:columns].include?(column[:name]) }
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Migflow
4
+ module Analyzers
5
+ module Rules
6
+ class MissingTimestampsRule < BaseRule
7
+ TIMESTAMP_COLUMNS = %w[created_at updated_at].freeze
8
+ MAX_JOIN_TABLE_COLUMNS = 3
9
+
10
+ def call(tables)
11
+ tables.reject { |_, table| join_table?(table) }.filter_map do |table_name, table|
12
+ next if has_timestamps?(table)
13
+
14
+ warning(
15
+ table: table_name,
16
+ message: "Table '#{table_name}' is missing created_at and/or updated_at",
17
+ severity: :warning
18
+ )
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def has_timestamps?(table)
25
+ column_names = table[:columns].map { |c| c[:name] }
26
+ TIMESTAMP_COLUMNS.all? { |ts| column_names.include?(ts) }
27
+ end
28
+
29
+ def join_table?(table)
30
+ columns = table[:columns]
31
+ return false if columns.size > MAX_JOIN_TABLE_COLUMNS
32
+
33
+ columns.all? { |col| col[:name].end_with?("_id") }
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end