rigortype 0.1.10 → 0.1.12

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 (144) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/baseline.rb +51 -15
  3. data/lib/rigor/analysis/erb_template_detector.rb +38 -0
  4. data/lib/rigor/analysis/runner.rb +6 -1
  5. data/lib/rigor/analysis/worker_session.rb +6 -1
  6. data/lib/rigor/cli/baseline_command.rb +4 -3
  7. data/lib/rigor/cli/plugins_command.rb +308 -0
  8. data/lib/rigor/cli/plugins_renderer.rb +173 -0
  9. data/lib/rigor/cli.rb +44 -3
  10. data/lib/rigor/inference/block_parameter_binder.rb +35 -0
  11. data/lib/rigor/inference/expression_typer.rb +69 -30
  12. data/lib/rigor/inference/indexed_narrowing.rb +187 -0
  13. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
  14. data/lib/rigor/inference/method_dispatcher.rb +23 -0
  15. data/lib/rigor/inference/mutation_widening.rb +285 -0
  16. data/lib/rigor/inference/narrowing.rb +72 -4
  17. data/lib/rigor/inference/scope_indexer.rb +409 -12
  18. data/lib/rigor/inference/statement_evaluator.rb +256 -4
  19. data/lib/rigor/scope.rb +181 -4
  20. data/lib/rigor/version.rb +1 -1
  21. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
  22. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
  23. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
  24. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
  25. data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
  26. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +199 -0
  27. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +398 -0
  28. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +86 -0
  29. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +183 -0
  30. data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
  31. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +713 -0
  32. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +201 -0
  33. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +226 -0
  34. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +261 -0
  35. data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
  36. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
  37. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
  38. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
  39. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
  40. data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
  41. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +283 -0
  42. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
  43. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
  44. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
  45. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +250 -0
  46. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +98 -0
  47. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +590 -0
  48. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
  49. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
  50. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
  51. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
  52. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
  53. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
  54. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
  55. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +37 -0
  56. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
  57. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +478 -0
  58. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
  59. data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
  60. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
  61. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
  62. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
  63. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
  64. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
  65. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
  66. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
  67. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
  68. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
  69. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
  70. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
  71. data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
  72. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
  73. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
  74. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
  75. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
  76. data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
  77. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
  78. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
  79. data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
  80. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
  81. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
  82. data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
  83. data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
  84. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
  85. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
  86. data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
  87. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
  88. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
  89. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
  90. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
  91. data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
  92. data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
  93. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +353 -0
  94. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
  95. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
  96. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +175 -0
  97. data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
  98. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +350 -0
  99. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
  100. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
  101. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
  102. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +164 -0
  103. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1538 -0
  104. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +235 -0
  105. data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
  106. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
  107. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
  108. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
  109. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
  110. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
  111. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
  112. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
  113. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
  114. data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
  115. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
  116. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
  117. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
  118. data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
  119. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
  120. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
  121. data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
  122. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
  123. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
  124. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
  125. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
  126. data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
  127. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
  128. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
  129. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
  130. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
  131. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
  132. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
  133. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
  134. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
  135. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
  136. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
  137. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
  138. data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
  139. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
  140. data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
  141. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
  142. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
  143. data/sig/rigor/scope.rbs +22 -0
  144. metadata +157 -1
@@ -0,0 +1,283 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ class Activerecord < Rigor::Plugin::Base
8
+ # Per-file AST walker. For each `Model.find(...)` /
9
+ # `Model.find_by(...)` / `Model.where(...)` call where
10
+ # `Model` is a name in the {ModelIndex}, emits diagnostics:
11
+ #
12
+ # | Method | Recognised arg shape | Validation |
13
+ # | --- | --- | --- |
14
+ # | `Model.find(id)` | any positional | arity check (1+ args) |
15
+ # | `Model.find_by(col: v, ...)` | keyword args | each key must be a column |
16
+ # | `Model.where(col: v, ...)` | keyword args | each key must be a column |
17
+ # | `Model.where(string)` | String literal | parser-side; not validated |
18
+ #
19
+ # Successful matches surface as `:info` diagnostics naming
20
+ # the resolved table; unknown columns surface as `:error`.
21
+ # Calls whose receiver is not a model in the index, and
22
+ # calls with non-keyword arguments to `where` / `find_by`,
23
+ # stay silent.
24
+ class Analyzer
25
+ # Methods that take a column → value Hash and need each key
26
+ # validated against the receiver's column set. The bang
27
+ # variants (`find_by!` raises instead of returning nil;
28
+ # `find_or_create_by!` raises on a validation failure) and
29
+ # `create_or_find_by` / `create_or_find_by!` take the
30
+ # identical column-hash argument shape.
31
+ COLUMN_HASH_METHODS = %i[
32
+ where
33
+ find_by find_by!
34
+ find_or_create_by find_or_create_by!
35
+ find_or_initialize_by
36
+ create_or_find_by create_or_find_by!
37
+ ].freeze
38
+
39
+ DID_YOU_MEAN_DISTANCE = 3
40
+
41
+ attr_reader :diagnostics
42
+
43
+ def initialize(path:, model_index:)
44
+ @path = path
45
+ @model_index = model_index
46
+ @diagnostics = []
47
+ end
48
+
49
+ def analyze(root)
50
+ walk(root) { |node| visit_call(node) if node.is_a?(Prism::CallNode) }
51
+ self
52
+ end
53
+
54
+ private
55
+
56
+ def walk(node, &)
57
+ return if node.nil?
58
+
59
+ yield node
60
+ node.compact_child_nodes.each { |child| walk(child, &) }
61
+ end
62
+
63
+ def visit_call(node)
64
+ model_name = constant_receiver_name(node.receiver)
65
+ return if model_name.nil?
66
+
67
+ entry = @model_index.find(model_name) ||
68
+ @model_index.find("::#{model_name}")
69
+ return if entry.nil?
70
+
71
+ case node.name
72
+ when :find then validate_find(node, entry)
73
+ when *COLUMN_HASH_METHODS then validate_column_hash_call(node, entry)
74
+ end
75
+ end
76
+
77
+ def validate_find(node, entry)
78
+ arity = call_argument_count(node)
79
+ if arity.zero?
80
+ push_error(node, "wrong-arity",
81
+ "`#{entry.class_name}.find` expects at least 1 argument, got 0")
82
+ return
83
+ end
84
+
85
+ push_info(node, "model-call",
86
+ "`#{entry.class_name}.find` returns #{entry.class_name} (table: `#{entry.table_name}`)")
87
+ end
88
+
89
+ def validate_column_hash_call(node, entry)
90
+ keyword_pairs = keyword_argument_pairs(node)
91
+ return push_recognised(node, entry) if keyword_pairs.empty?
92
+
93
+ # Models with no schema-side columns are virtual — backed
94
+ # by a database VIEW (Mastodon's `Instance` model wraps a
95
+ # SQL view that isn't in `db/schema.rb`), seeded from an
96
+ # external source, or otherwise opaque to our schema
97
+ # parser. Without a column set we cannot meaningfully
98
+ # check query keys; surface the call as recognised and
99
+ # skip column validation entirely rather than firing a
100
+ # false `unknown-column` against every key.
101
+ return push_recognised(node, entry, keyword_pairs.map { |p| p[:key] }) if entry.column_names.empty?
102
+
103
+ unknown = keyword_pairs.reject { |pair| valid_query_key?(entry, pair[:key]) }
104
+ if unknown.empty?
105
+ keyword_pairs.each { |pair| validate_enum_value(node, entry, pair) }
106
+ push_recognised(node, entry, keyword_pairs.map { |p| p[:key] })
107
+ else
108
+ unknown.each do |pair|
109
+ key = pair[:key]
110
+ suggestion = closest_column(key, entry.column_names)
111
+ hint = suggestion ? " (did you mean `:#{suggestion}`?)" : ""
112
+ push_error(node, "unknown-column",
113
+ "`#{entry.class_name}.#{node.name}(#{key}: ...)` references " \
114
+ "unknown column `#{key}` on table `#{entry.table_name}`#{hint}")
115
+ end
116
+ end
117
+ end
118
+
119
+ # ActiveRecord's `where` / `find_by` / `find_or_initialize_by`
120
+ # accept either a column name *or* a singular association
121
+ # name (belongs_to / has_one). The latter resolves to the
122
+ # association's FK column behind the scenes:
123
+ #
124
+ # AccountPin.find_by(account: x)
125
+ # # → SELECT … WHERE account_id = x.id
126
+ #
127
+ # Without this allowance, every `find_by(<assoc>:)` call
128
+ # surfaces as an `unknown-column` false positive — Mastodon
129
+ # saw ~100 such hits across the API controllers, all of
130
+ # which were the canonical Rails idiom rather than typos.
131
+ #
132
+ # `alias_attribute` names resolve to their target before
133
+ # the column / association check — querying by an aliased
134
+ # attribute is a valid Rails idiom and must not surface as
135
+ # an `unknown-column`.
136
+ def valid_query_key?(entry, key)
137
+ key = entry.resolve_alias(key) if entry.alias?(key)
138
+
139
+ return true if entry.column?(key)
140
+
141
+ assoc = entry.association(key)
142
+ assoc && assoc[:kind] == :singular
143
+ end
144
+
145
+ # When the column is an enum-bearing column AND the
146
+ # passed value is a Symbol literal, the value MUST be
147
+ # one of the declared enum values. Non-Symbol values
148
+ # (variables, expressions) decline so dynamic call
149
+ # sites stay silent. Sequences (`status: [:active,
150
+ # :archived]`) are intentionally NOT walked here —
151
+ # the relation-shape check belongs in a future track.
152
+ def validate_enum_value(node, entry, pair)
153
+ return unless entry.enum?(pair[:key])
154
+ return unless pair[:value].is_a?(Prism::SymbolNode)
155
+
156
+ value = pair[:value].unescaped
157
+ values = entry.enum_values(pair[:key])
158
+ return if values.include?(value)
159
+
160
+ suggestion = closest_column(value, values)
161
+ hint = suggestion ? " (did you mean `:#{suggestion}`?)" : ""
162
+ push_error(node, "unknown-enum-value",
163
+ "`#{entry.class_name}.#{node.name}(#{pair[:key]}: :#{value})` references " \
164
+ "unknown enum value `:#{value}` (declared: #{values.map { |v| ":#{v}" }.join(', ')})#{hint}")
165
+ end
166
+
167
+ def push_recognised(node, entry, keys = nil)
168
+ msg = "`#{entry.class_name}.#{node.name}`"
169
+ msg += " (#{keys.map { |k| ":#{k}" }.join(', ')})" if keys && !keys.empty?
170
+ msg += " on table `#{entry.table_name}`"
171
+ push_info(node, "model-call", msg)
172
+ end
173
+
174
+ def constant_receiver_name(node)
175
+ case node
176
+ when Prism::ConstantReadNode
177
+ node.name.to_s
178
+ when Prism::ConstantPathNode
179
+ constant_path_name(node)
180
+ end
181
+ end
182
+
183
+ def constant_path_name(node)
184
+ parts = []
185
+ current = node
186
+ while current.is_a?(Prism::ConstantPathNode)
187
+ parts.unshift(current.name.to_s)
188
+ current = current.parent
189
+ end
190
+ case current
191
+ when nil
192
+ "::#{parts.join('::')}"
193
+ when Prism::ConstantReadNode
194
+ "#{current.name}::#{parts.join('::')}"
195
+ end
196
+ end
197
+
198
+ def call_argument_count(node)
199
+ return 0 if node.arguments.nil?
200
+
201
+ node.arguments.arguments.size
202
+ end
203
+
204
+ # Returns the symbol-keyed pairs of any KeywordHashNode
205
+ # argument as `{ key:, value: }` rows. Plain hash-literal
206
+ # arguments (`Model.where({a: 1})`) are NOT walked —
207
+ # Rails accepts both, but the keyword form is the
208
+ # idiomatic one. The `value` Prism node is preserved so
209
+ # the enum check downstream can recognise Symbol
210
+ # literals without re-walking the call.
211
+ def keyword_argument_pairs(node)
212
+ return [] if node.arguments.nil?
213
+
214
+ pairs = []
215
+ node.arguments.arguments.each do |arg|
216
+ next unless arg.is_a?(Prism::KeywordHashNode)
217
+
218
+ arg.elements.each do |pair|
219
+ next unless pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode)
220
+
221
+ pairs << { key: pair.key.unescaped, value: pair.value }
222
+ end
223
+ end
224
+ pairs
225
+ end
226
+
227
+ def closest_column(name, candidates)
228
+ best = nil
229
+ best_distance = DID_YOU_MEAN_DISTANCE + 1
230
+ candidates.each do |candidate|
231
+ distance = levenshtein(name, candidate)
232
+ if distance < best_distance
233
+ best = candidate
234
+ best_distance = distance
235
+ end
236
+ end
237
+ best
238
+ end
239
+
240
+ def levenshtein(a, b) # rubocop:disable Naming/MethodParameterName
241
+ return b.length if a.empty?
242
+ return a.length if b.empty?
243
+
244
+ rows = Array.new(a.length + 1) { |_i| Array.new(b.length + 1, 0) }
245
+ (0..a.length).each { |i| rows[i][0] = i }
246
+ (0..b.length).each { |j| rows[0][j] = j }
247
+
248
+ (1..a.length).each do |i|
249
+ (1..b.length).each do |j|
250
+ cost = a[i - 1] == b[j - 1] ? 0 : 1
251
+ rows[i][j] = [
252
+ rows[i - 1][j] + 1,
253
+ rows[i][j - 1] + 1,
254
+ rows[i - 1][j - 1] + cost
255
+ ].min
256
+ end
257
+ end
258
+ rows[a.length][b.length]
259
+ end
260
+
261
+ def push_info(node, rule, message)
262
+ push_diagnostic(node, severity: :info, rule: rule, message: message)
263
+ end
264
+
265
+ def push_error(node, rule, message)
266
+ push_diagnostic(node, severity: :error, rule: rule, message: message)
267
+ end
268
+
269
+ def push_diagnostic(node, severity:, rule:, message:)
270
+ location = node.location
271
+ @diagnostics << Rigor::Analysis::Diagnostic.new(
272
+ path: @path,
273
+ line: location.start_line,
274
+ column: location.start_column + 1,
275
+ message: message,
276
+ severity: severity,
277
+ rule: rule
278
+ )
279
+ end
280
+ end
281
+ end
282
+ end
283
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ class Activerecord < Rigor::Plugin::Base
6
+ # Tiny inflection helper for the common `ClassName → snake_case_plural`
7
+ # mapping Rails uses to derive table names. Deliberately
8
+ # narrow — handles the regular cases (`User → users`,
9
+ # `BlogPost → blog_posts`, `Category → categories`,
10
+ # `Bus → buses`, `Wolf → wolves`). Irregular plurals
11
+ # (`Person → people`, `Mouse → mice`, `Datum → data`) are
12
+ # NOT handled; the user is expected to declare
13
+ # `self.table_name = "people"` for those.
14
+ #
15
+ # Avoids an `activesupport` runtime dependency. Rails apps
16
+ # that need richer inflection should set explicit table
17
+ # names on the affected models.
18
+ module Inflector
19
+ IRREGULAR_PLURALS = {
20
+ # Common ones we still want to handle without bringing
21
+ # in a full inflection table. Users get the configured
22
+ # explicit table_name route for anything else.
23
+ "person" => "people",
24
+ "child" => "children",
25
+ "datum" => "data"
26
+ }.freeze
27
+
28
+ module_function
29
+
30
+ # `BlogPost` → `blog_posts`. `User::Profile` → `user_profiles`
31
+ # (Rails-style namespacing flattens with underscore).
32
+ def tableize(class_name)
33
+ underscore = underscore(class_name.to_s.gsub("::", "/"))
34
+ # `user/profiles` → `user_profiles`
35
+ underscore = underscore.tr("/", "_")
36
+ pluralize(underscore)
37
+ end
38
+
39
+ # `BlogPost` → `blog_post`. Standard Rails-style underscore.
40
+ def underscore(camel_case_word)
41
+ word = camel_case_word.to_s.dup
42
+ word.gsub!(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
43
+ word.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
44
+ word.tr!("-", "_")
45
+ word.downcase!
46
+ word
47
+ end
48
+
49
+ # `users` → `User`, `blog_posts` → `BlogPost`.
50
+ # Used by the association detector to map an
51
+ # association NAME (`has_many :posts` → `Post`) to the
52
+ # target class without requiring an explicit
53
+ # `class_name:` option. Singularises the word first,
54
+ # then camel-cases. Recognises the same irregular
55
+ # plurals as {.pluralize}.
56
+ def classify(word)
57
+ camelize(singularize(word.to_s))
58
+ end
59
+
60
+ # `posts` → `post`, `categories` → `category`,
61
+ # `wolves` → `wolf`, `buses` → `bus`. The inverse of
62
+ # {.pluralize} for the regular cases this module
63
+ # recognises. Irregular forms (`people` → `person`)
64
+ # round-trip via {IRREGULAR_PLURALS}.
65
+ def singularize(word)
66
+ IRREGULAR_PLURALS.each { |singular, plural| return singular if word == plural }
67
+
68
+ case word
69
+ when /(.*[bcdfghjklmnpqrstvwxz])ies\z/
70
+ "#{Regexp.last_match(1)}y"
71
+ when /(.*[sxz]|.*[cs]h)es\z/
72
+ Regexp.last_match(0)[0..-3]
73
+ when /(.*)ves\z/
74
+ "#{Regexp.last_match(1)}f"
75
+ when /(.+)s\z/
76
+ Regexp.last_match(1)
77
+ else
78
+ word
79
+ end
80
+ end
81
+
82
+ # `blog_post` → `BlogPost`. Camelizes around `_` and
83
+ # `/` separators; the latter promotes namespace boundaries
84
+ # to `::` (Rails-style).
85
+ def camelize(snake)
86
+ snake.to_s.split("/").map do |segment|
87
+ segment.split("_").map { |part| part.empty? ? part : part[0].upcase + part[1..] }.join
88
+ end.join("::")
89
+ end
90
+
91
+ # `user` → `users`, `category` → `categories`,
92
+ # `bus` → `buses`, `wolf` → `wolves`. Falls back to a
93
+ # plain `+ "s"` for unrecognised endings.
94
+ def pluralize(word)
95
+ return IRREGULAR_PLURALS[word] if IRREGULAR_PLURALS.key?(word)
96
+
97
+ case word
98
+ when /(.*[bcdfghjklmnpqrstvwxz])y\z/
99
+ # `category` → `categories`, `cherry` → `cherries`
100
+ "#{Regexp.last_match(1)}ies"
101
+ when /(.*[sxz]|.*[cs]h)\z/
102
+ # `bus` → `buses`, `box` → `boxes`, `dish` → `dishes`
103
+ "#{Regexp.last_match(0)}es"
104
+ when /(.*)fe?\z/
105
+ # `wolf` → `wolves`, `knife` → `knives`
106
+ "#{Regexp.last_match(1)}ves"
107
+ else
108
+ "#{word}s"
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end