rigortype 0.2.8 → 0.2.9

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/core_overlay/csv.rbs +28 -0
  4. data/data/core_overlay/psych.rbs +22 -0
  5. data/docs/handbook/01-getting-started.md +9 -1
  6. data/docs/handbook/02-everyday-types.md +4 -1
  7. data/docs/handbook/08-understanding-errors.md +3 -3
  8. data/docs/manual/01-installation.md +1 -0
  9. data/docs/manual/02-cli-reference.md +16 -7
  10. data/docs/manual/07-plugins.md +1 -1
  11. data/docs/manual/14-rails-quickstart.md +4 -2
  12. data/docs/manual/15-type-protection-coverage.md +21 -0
  13. data/docs/manual/plugins/rigor-actionpack.md +1 -1
  14. data/docs/manual/plugins/rigor-activerecord.md +12 -5
  15. data/docs/manual/plugins/rigor-rails-routes.md +11 -0
  16. data/lib/rigor/cache/store.rb +174 -57
  17. data/lib/rigor/cli/coverage_command.rb +50 -29
  18. data/lib/rigor/cli/diagnostic_formats.rb +4 -1
  19. data/lib/rigor/cli/protection_fork_scan.rb +55 -0
  20. data/lib/rigor/cli/protection_report.rb +7 -1
  21. data/lib/rigor/environment/missing_gem_constant_index.rb +128 -0
  22. data/lib/rigor/environment.rb +54 -18
  23. data/lib/rigor/inference/expression_typer.rb +20 -2
  24. data/lib/rigor/inference/fork_map.rb +87 -0
  25. data/lib/rigor/inference/narrowing.rb +118 -0
  26. data/lib/rigor/inference/parameter_inference_collector.rb +55 -10
  27. data/lib/rigor/inference/scope_indexer.rb +9 -13
  28. data/lib/rigor/inference/statement_evaluator.rb +48 -3
  29. data/lib/rigor/version.rb +1 -1
  30. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +23 -8
  31. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +21 -0
  32. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +10 -1
  33. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +61 -0
  34. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +20 -2
  35. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/structure_sql_parser.rb +172 -0
  36. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +22 -8
  37. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +32 -0
  38. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +189 -0
  39. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +19 -1
  40. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +41 -10
  41. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +42 -12
  42. data/sig/rigor/environment.rbs +1 -0
  43. metadata +8 -1
@@ -1989,13 +1989,12 @@ module Rigor
1989
1989
  # reads for the bound logical path to the buffer's physical path so editor-mode pre-passes see the in-flight
1990
1990
  # bytes.
1991
1991
  #
1992
- # **Modules are intentionally excluded** from the project-wide seed: a `module M; module_function; def x; end;
1993
- # end` body, when surfaced as `singleton(M)` to the dispatcher, falls through to `Kernel#x` (or any Module
1994
- # ancestor method) when the project's per-file `discovered_methods` doesn't know `M.x` — leading to surprising
1995
- # types like `Kernel.select Array[String]`. Until cross-file `discovered_methods` follows the same project-wide
1996
- # seed, registering modules here would introduce regressions in modules-with-module_function idioms that
1997
- # previously resolved to `Dynamic[Top]`. Class declarations are safe because per-file `discovered_methods` already
1998
- # tracks `def self.x` / `def x` instance and singleton methods consistently.
1992
+ # Modules are registered on the same terms as classes, matching `record_declarations`' per-file behaviour (ADR-57
1993
+ # WD3). An earlier revision excluded them, fearing an undiscovered `M.x` would fall through to `Kernel#x`; the
1994
+ # exclusion was retired once measurement showed Kernel's private instance methods (`select`, `puts`, `load`, …)
1995
+ # resolve to nothing on a `Singleton[M]` receiver, and that a project-side `def self.x` body wins over the lenient
1996
+ # `Singleton[Object]` fallback anyway. The residual leak is `Class`-only (`M.new`, `M.superclass`), which mistypes
1997
+ # only code that raises `NoMethodError` at runtime.
1999
1998
  #
2000
1999
  # @param paths [Array<String>] project file paths.
2001
2000
  # @param buffer [Rigor::Analysis::BufferBinding, nil]
@@ -2136,22 +2135,19 @@ module Rigor
2136
2135
  end
2137
2136
  end
2138
2137
 
2139
- # Class-only variant of `record_declarations` — descends into nested module bodies (so `module Foo; class Bar`
2140
- # registers `Foo::Bar`) but never registers the module itself in `accumulator`.
2138
+ # Cross-file counterpart of `record_declarations` — registers every `class` / `module` declaration under its
2139
+ # qualified name and descends into the body (so `module Foo; class Bar` registers both `Foo` and `Foo::Bar`).
2141
2140
  def collect_class_decls(node, qualified_prefix, accumulator)
2142
2141
  return unless node.is_a?(Prism::Node)
2143
2142
 
2144
2143
  case node
2145
- when Prism::ClassNode
2144
+ when Prism::ClassNode, Prism::ModuleNode
2146
2145
  name = Source::ConstantPath.qualified_name(node.constant_path)
2147
2146
  if name
2148
2147
  full = (qualified_prefix + [name]).join("::")
2149
2148
  accumulator[full] = Type::Combinator.singleton_of(full)
2150
2149
  return collect_class_decls(node.body, qualified_prefix + [name], accumulator) if node.body
2151
2150
  end
2152
- when Prism::ModuleNode
2153
- name = Source::ConstantPath.qualified_name(node.constant_path)
2154
- return collect_class_decls(node.body, qualified_prefix + [name], accumulator) if name && node.body
2155
2151
  when Prism::ConstantWriteNode
2156
2152
  record_class_new_constant_decl(node, qualified_prefix, accumulator)
2157
2153
  end
@@ -1825,10 +1825,19 @@ module Rigor
1825
1825
  def_node = resolve_self_callee_def(node)
1826
1826
  return base_scope if def_node.nil?
1827
1827
 
1828
- escaped = escaped_content_parameters(def_node)
1829
- return base_scope if escaped.empty?
1828
+ mutated = callee_content_mutated_parameters(def_node)
1829
+ return base_scope if mutated.empty?
1830
1830
 
1831
- floor_arguments_at_positions(node, escaped, base_scope)
1831
+ floor_arguments_at_positions(node, mutated, base_scope)
1832
+ end
1833
+
1834
+ # The `{ name => position }` positional parameters whose content the callee mutates, from either channel: those
1835
+ # escape-mutated inside a block (which may run later / repeatedly) AND those mutated directly in the method body
1836
+ # during the call itself (`declaration[:prefix] = v`). Both leave the caller's argument binding stale after the
1837
+ # call, so both floor it. Memoised per def node (the merge is otherwise recomputed at every call site).
1838
+ def callee_content_mutated_parameters(def_node)
1839
+ cache = (@callee_mutated_param_cache ||= {}.compare_by_identity)
1840
+ cache[def_node] ||= escaped_content_parameters(def_node).merge(direct_content_parameters(def_node))
1832
1841
  end
1833
1842
 
1834
1843
  # The user def a self-dispatch `node` resolves to in the enclosing class, or nil. Reuses the discovery index
@@ -1872,6 +1881,42 @@ module Rigor
1872
1881
  positions.slice(*mutated)
1873
1882
  end
1874
1883
 
1884
+ # The `{ name => position }` positional parameters whose CONTENT the callee mutates directly in its method body —
1885
+ # a top-level `param[k] = v` / `param << x`, outside a nested block. Memoised per def node. The block-nested case
1886
+ # is `escaped_content_parameters`'s job; walking only outside nested blocks / defs / lambdas here means a matching
1887
+ # parameter-name read is genuinely at method scope (depth 0) rather than a block-local of the same name that
1888
+ # merely shadows the parameter — so we never floor a caller argument the callee did not actually mutate.
1889
+ def direct_content_parameters(def_node)
1890
+ cache = (@direct_param_cache ||= {}.compare_by_identity)
1891
+ cache[def_node] ||= compute_direct_content_parameters(def_node)
1892
+ end
1893
+
1894
+ def compute_direct_content_parameters(def_node)
1895
+ positions = positional_parameter_positions(def_node)
1896
+ return {} if positions.empty?
1897
+
1898
+ mutated = Set.new
1899
+ each_node_outside_nested_scopes(def_node.body) do |descendant|
1900
+ name, = content_mutation_target(descendant) { |r| r.is_a?(Prism::LocalVariableReadNode) && r.depth.zero? }
1901
+ mutated << name if !name.nil? && positions.key?(name)
1902
+ end
1903
+ positions.slice(*mutated)
1904
+ end
1905
+
1906
+ # Yields every node reachable from `body` without crossing into a nested block / def / lambda — i.e. the nodes
1907
+ # that execute in the method's own scope. A local-variable read found here has depth 0 relative to the method, so
1908
+ # a content mutation whose receiver is a positional-parameter name is a genuine mutation of that parameter.
1909
+ def each_node_outside_nested_scopes(node, &)
1910
+ return if node.nil?
1911
+
1912
+ yield node
1913
+ node.compact_child_nodes.each do |child|
1914
+ next if child.is_a?(Prism::BlockNode) || child.is_a?(Prism::DefNode) || child.is_a?(Prism::LambdaNode)
1915
+
1916
+ each_node_outside_nested_scopes(child, &)
1917
+ end
1918
+ end
1919
+
1875
1920
  # A receiver-independent over-approximation of `ClosureEscapeAnalyzer`'s non-escaping verdict, used when scanning
1876
1921
  # a callee body where the block- owning call's receiver TYPE is not available. A call whose method name is a known
1877
1922
  # structural iterator (`each` / `map` / `tap` / …) runs its block synchronously and does not retain it, so its
data/lib/rigor/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rigor
4
- VERSION = "0.2.8"
4
+ VERSION = "0.2.9"
5
5
  end
@@ -58,6 +58,12 @@ module Rigor
58
58
  # receiver is `params.require(:symbol)`.
59
59
  STRONG_PARAMS_RECEIVER_NAMES = %i[require permit_params strong_params].freeze
60
60
 
61
+ # Max edit distance between a non-column permit key and a real column for the key to count as a
62
+ # typo (and fire `unknown-permit-key`) rather than a legitimate virtual attribute. A tight bound
63
+ # keeps genuine typos (`emial`→`email`, distance 2) while never firing on a virtual attribute,
64
+ # whose name is nothing like any column.
65
+ PERMIT_KEY_TYPO_MAX_DISTANCE = 2
66
+
61
67
  # One Action Pack observation. Carries no path — the caller (the `node_rule` block) positions
62
68
  # it via `Plugin::Base#diagnostic`. `location` is the Prism location the diagnostic should point
63
69
  # at (a call's `message_loc` for the call itself, or a sub-argument's location for
@@ -150,12 +156,11 @@ module Rigor
150
156
  return [] if entry.nil? # unknown model — skip; the model lookup is best-effort.
151
157
 
152
158
  columns = entry[:columns]
153
- spell_checker = DidYouMean::SpellChecker.new(dictionary: columns)
154
- literal_permit_keys(call_node).map do |key_node, key_name|
159
+ literal_permit_keys(call_node).filter_map do |key_node, key_name|
155
160
  if columns.include?(key_name)
156
161
  permit_call_violation(call_node, model_class, key_name)
157
162
  else
158
- unknown_permit_key_violation(key_node, model_class, key_name, spell_checker)
163
+ unknown_permit_key_violation(key_node, model_class, key_name, columns)
159
164
  end
160
165
  end
161
166
  end
@@ -347,13 +352,23 @@ module Rigor
347
352
  )
348
353
  end
349
354
 
350
- def unknown_permit_key_violation(key_node, model_class, key_name, spell_checker)
351
- base = "Action Pack permit `#{key_name}` is not a column on `#{model_class}`."
352
- suggestion = spell_checker.correct(key_name).first
353
- message = suggestion ? "#{base} Did you mean `:#{suggestion}`?" : base
355
+ # A permit key that is not a column is only a violation when it is a near-miss TYPO of a real
356
+ # column. Permitting a non-column is ordinary Rails: a Devise virtual attribute (`password`,
357
+ # `remember_me`, `otp_attempt`), a state-machine `*_event`, an `attr_accessor` setter, or a nested
358
+ # `*_attributes` key are all mass-assignable but absent from the schema. So fire only when a column
359
+ # sits within a tight edit distance of the key (a typo like `emial`→`email`); a key with no close
360
+ # column is presumed a deliberate virtual attribute and passes silently. Firing on every non-column
361
+ # key false-positives across the whole strong-params surface of a Devise/state-machine app.
362
+ def unknown_permit_key_violation(key_node, model_class, key_name, columns)
363
+ nearest = columns.min_by { |col| DidYouMean::Levenshtein.distance(key_name, col) }
364
+ return nil if nearest.nil?
365
+ return nil if DidYouMean::Levenshtein.distance(key_name, nearest) > PERMIT_KEY_TYPO_MAX_DISTANCE
366
+
354
367
  Violation.new(
355
368
  location: key_node.location,
356
- message: message, severity: :error, rule: "unknown-permit-key"
369
+ message: "Action Pack permit `#{key_name}` is not a column on `#{model_class}`. " \
370
+ "Did you mean `:#{nearest}`?",
371
+ severity: :error, rule: "unknown-permit-key"
357
372
  )
358
373
  end
359
374
 
@@ -182,6 +182,27 @@ module Rigor
182
182
  Rigor::Type::Combinator.nominal_of(class_name)
183
183
  end
184
184
 
185
+ # Phase 5b (2026-07-10) — keep the strong-parameters fluent chain typed. `params` types to
186
+ # `ActionController::Parameters` (above), but the chained `params.require(:user).permit(:name)` /
187
+ # `params.permit(...)` calls returned `Dynamic` at the first hop (Parameters ships no bundled RBS,
188
+ # so a call on it resolves lenient-to-Dynamic), leaking every downstream site (`.permit`, `.to_h`,
189
+ # `.each`) to unprotected. These three methods return another `Parameters`, so gate on a
190
+ # `Parameters` receiver and re-type the result as the same lenient nominal — the chain stays a
191
+ # concrete receiver end-to-end and `coverage --protection` counts the sites as protected.
192
+ #
193
+ # `require` may return a scalar for a flat key at runtime (`params.require(:id) → String`), but
194
+ # typing it as the RBS-less `Parameters` is FP-safe by the same argument as the readers: every
195
+ # method on a Parameters value stays engine-lenient (no `undefined-method`), and this types the
196
+ # container only, never a caller's argument (ADR-5). The GitLab strong-params survey (108 leaked
197
+ # `.permit` sites) is the demand.
198
+ STRONG_PARAMS_CHAIN_METHODS = %i[require permit permit!].freeze
199
+
200
+ dynamic_return receivers: [REQUEST_CONTEXT_READER_TYPES[:params]], methods: STRONG_PARAMS_CHAIN_METHODS do |call_node, _scope|
201
+ next nil unless call_node.is_a?(Prism::CallNode)
202
+
203
+ Rigor::Type::Combinator.nominal_of(REQUEST_CONTEXT_READER_TYPES[:params])
204
+ end
205
+
185
206
  private
186
207
 
187
208
  # True when the current `self` is a controller — the enclosing class is one the discoverer
@@ -90,7 +90,7 @@ module Rigor
90
90
  # than firing a false `unknown-column` against every key.
91
91
  return push_recognised(node, entry, keyword_pairs.map { |p| p[:key] }) if entry.column_names.empty?
92
92
 
93
- unknown = keyword_pairs.reject { |pair| valid_query_key?(entry, pair[:key]) }
93
+ unknown = keyword_pairs.reject { |pair| nested_condition?(pair[:value]) || valid_query_key?(entry, pair[:key]) }
94
94
  if unknown.empty?
95
95
  keyword_pairs.each { |pair| validate_enum_value(node, entry, pair) }
96
96
  push_recognised(node, entry, keyword_pairs.map { |p| p[:key] })
@@ -129,6 +129,15 @@ module Rigor
129
129
  assoc && assoc[:kind] == :singular
130
130
  end
131
131
 
132
+ # A keyword pair with a Hash value is a nested / joined-table condition (`Approval.where(approvals:
133
+ # { user_id: 1 })`, `where(users: { name: 'x' })`), where the key names an association or a joined
134
+ # table, not a column on the receiver. Rails resolves it through the join, so it must not be checked
135
+ # against the receiver's own columns. (An Array value — `where(status: [:a, :b])` — is an IN query on
136
+ # a real column, not nested, so only a Hash is skipped.)
137
+ def nested_condition?(value_node)
138
+ value_node.is_a?(Prism::HashNode) || value_node.is_a?(Prism::KeywordHashNode)
139
+ end
140
+
132
141
  # When the column is an enum-bearing column AND the passed value is a Symbol literal, the value
133
142
  # MUST be one of the declared enum values. Non-Symbol values (variables, expressions) decline so
134
143
  # dynamic call sites stay silent. Sequences (`status: [:active, :archived]`) are intentionally NOT
@@ -33,12 +33,25 @@ module Rigor
33
33
  # @param io_boundary [Rigor::Plugin::IoBoundary]
34
34
  # @param search_paths [Array<String>] absolute or project-relative paths.
35
35
  # @param base_classes [Array<String>] superclass names that identify a class as an AR model.
36
+ # Declaration macros whose column's runtime value is a rich object, not the SQL scalar. Their column
37
+ # must NOT be narrowed to the schema type (see {ModelIndex.build}'s type-override remap).
38
+ TYPE_OVERRIDE_METHODS = %i[serialize mount_uploader mount_uploaders].freeze
39
+
36
40
  def initialize(io_boundary:, search_paths:, base_classes:)
37
41
  @io_boundary = io_boundary
38
42
  @search_paths = search_paths
39
43
  @base_classes = base_classes.to_set
44
+ # Global set of column names whose declared runtime type overrides the schema scalar
45
+ # (`serialize` / `mount_uploader` / `attribute :x, CustomType`). Collected across every class AND
46
+ # concern module walked — a serialize inside a concern's `included do … end` is invisible to the
47
+ # per-model view (the discoverer doesn't follow `include`), so overrides are tracked globally by
48
+ # column name and applied wherever that column appears. Over-suppresses a same-named scalar
49
+ # column elsewhere (a precision cost, never a false positive).
50
+ @type_override_columns = Set.new
40
51
  end
41
52
 
53
+ attr_reader :type_override_columns
54
+
42
55
  # @return [Array<Hash>] rows of { class_name:, table_name_override:, sti_parent:, ... }
43
56
  def discover
44
57
  candidates = []
@@ -141,6 +154,8 @@ module Rigor
141
154
  full_name = (lexical_path + [class_local_name]).join("::")
142
155
  superclass = constant_path_name(node.superclass) if node.superclass
143
156
 
157
+ collect_type_overrides(node.body)
158
+
144
159
  yield({
145
160
  class_name: full_name,
146
161
  superclass_name: superclass,
@@ -162,10 +177,56 @@ module Rigor
162
177
  module_local_name = constant_path_name(node.constant_path)
163
178
  return if module_local_name.nil?
164
179
 
180
+ # Concerns (`module DiffPositionableNote`) carry `serialize` / `mount_uploader` inside an
181
+ # `included do … end` block; collect their overrides even though the module itself is not a model.
182
+ collect_type_overrides(node.body)
183
+
165
184
  inner_path = lexical_path + [module_local_name]
166
185
  walk_for_classes(node.body, inner_path, &) if node.body
167
186
  end
168
187
 
188
+ # Records the column name of every `serialize :col` / `mount_uploader(s) :col` / `attribute :col,
189
+ # CustomType` in `body` (descending into `with_options` and concern `included do` blocks) into the
190
+ # global {#type_override_columns} set. `attribute :col, :symbol_type` (a built-in scalar type) is
191
+ # NOT an override — only a custom type CONSTANT is.
192
+ def collect_type_overrides(body)
193
+ type_override_declaration_calls(body).each do |node|
194
+ next if node.receiver
195
+
196
+ column = Rigor::Source::Literals.symbol_name(node.arguments&.arguments&.first)
197
+ next if column.nil?
198
+
199
+ if TYPE_OVERRIDE_METHODS.include?(node.name)
200
+ @type_override_columns << column
201
+ elsif node.name == :attribute && custom_type_attribute?(node)
202
+ @type_override_columns << column
203
+ end
204
+ end
205
+ end
206
+
207
+ # `declaration_calls` variant that also descends into a concern's `included do … end` block (the
208
+ # ActiveSupport::Concern idiom where models' shared `serialize` declarations live).
209
+ def type_override_declaration_calls(body)
210
+ return [] if body.nil?
211
+
212
+ body.compact_child_nodes.flat_map do |node|
213
+ next [] unless node.is_a?(Prism::CallNode)
214
+
215
+ if %i[with_options included].include?(node.name) && node.block.is_a?(Prism::BlockNode)
216
+ type_override_declaration_calls(node.block.body)
217
+ else
218
+ [node]
219
+ end
220
+ end
221
+ end
222
+
223
+ # True when an `attribute :col, <type>` call's type argument is a custom type CLASS (a constant),
224
+ # whose runtime value is a rich object — not a built-in `:symbol` type, which stays a scalar.
225
+ def custom_type_attribute?(node)
226
+ type_arg = node.arguments&.arguments&.[](1)
227
+ type_arg.is_a?(Prism::ConstantReadNode) || type_arg.is_a?(Prism::ConstantPathNode)
228
+ end
229
+
169
230
  # Renders a constant-path node (`Admin::User`, `::ApplicationRecord`) as a String. Returns nil for
170
231
  # shapes the discoverer chooses not to handle.
171
232
  def constant_path_name(node)
@@ -88,15 +88,16 @@ module Rigor
88
88
  def class_names = entries.keys
89
89
  def empty? = entries.empty?
90
90
 
91
- def self.build(model_rows:, schema_table:)
91
+ def self.build(model_rows:, schema_table:, type_override_columns: nil)
92
92
  rows_by_name = model_rows.to_h { |row| [row.fetch(:class_name), row] }
93
+ overrides = type_override_columns || []
93
94
 
94
95
  entries = model_rows.each_with_object({}) do |row, acc|
95
96
  class_name = row.fetch(:class_name)
96
97
  # The STI ancestry chain, root → self. For a plain (non-STI) model this is just `[row]`.
97
98
  chain = sti_chain(row, rows_by_name)
98
99
  table_name = sti_table_name(chain)
99
- columns = schema_table.columns_for(table_name) || []
100
+ columns = apply_type_overrides(schema_table.columns_for(table_name) || [], overrides)
100
101
 
101
102
  # STI children inherit their ancestors' declared associations / enums / aliases / scopes /
102
103
  # validations / callbacks. Without the merge a `where(<parent-association>: ...)` on the
@@ -116,6 +117,23 @@ module Rigor
116
117
  new(entries.freeze)
117
118
  end
118
119
 
120
+ # Remaps every type-overridden column's `ruby_type` to `"Object"` so instance-side column narrowing
121
+ # declines to narrow it (the `"Object"` case in `ruby_type_to_type`). A `serialize` / `mount_uploader`
122
+ # / custom-`attribute` column reads as a rich object at runtime, not its SQL scalar, so narrowing it
123
+ # to e.g. `String` false-positives on the object's methods (`note.position.diff_refs`). The column
124
+ # stays in the set, so `where(col: ...)` existence validation is unaffected — only its value type.
125
+ def self.apply_type_overrides(columns, overrides)
126
+ return columns if overrides.empty?
127
+
128
+ columns.map do |column|
129
+ next column unless overrides.include?(column.name)
130
+
131
+ SchemaTable::Column.new(
132
+ name: column.name, type: column.type, ruby_type: "Object", array: column.array
133
+ )
134
+ end
135
+ end
136
+
119
137
  # The STI ancestry chain for a row, ordered root → self. Walks `sti_parent` pointers, guarding
120
138
  # against a cycle.
121
139
  def self.sti_chain(row, rows_by_name, seen = [])
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "schema_table"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ class Activerecord < Rigor::Plugin::Base
8
+ # Parses a PostgreSQL `db/structure.sql` (the `schema_format = :sql` schema dump) into the same
9
+ # {SchemaTable} the Ruby-DSL {SchemaParser} produces, so a project that commits `structure.sql`
10
+ # instead of `schema.rb` is no longer inert (GitLab-class apps use `structure.sql`; without this the
11
+ # plugin skips every AR call check and — worse — degrades ordinary relation chains to `Array`,
12
+ # cascading false diagnostics).
13
+ #
14
+ # The dump is regular enough to read line-by-line without a SQL parser. `pg_dump` emits, per table:
15
+ #
16
+ # CREATE TABLE namespaces (
17
+ # id bigint NOT NULL,
18
+ # name character varying NOT NULL,
19
+ # visibility_level integer DEFAULT 20 NOT NULL,
20
+ # tag_ids bigint[],
21
+ # created_at timestamp without time zone
22
+ # );
23
+ #
24
+ # Load-bearing regularities this relies on: column names / types are lowercase, column modifiers
25
+ # (`NOT NULL`, `DEFAULT …`, `GENERATED …`, `COLLATE …`) are UPPERCASE, one column per line, the body
26
+ # closes on a line starting with `)`. So the type is the run of tokens before the first
27
+ # uppercase-initial token, and a table-level constraint line (`CONSTRAINT …`, `PRIMARY KEY …`) is
28
+ # recognised by its uppercase leading keyword and skipped. Anything it cannot map (custom enum,
29
+ # `tsvector`, `ltree`) degrades to `Object` via {SchemaTable.ruby_type_for} — never a dropped column,
30
+ # which would turn every query on it into a false `unknown-column`.
31
+ class StructureSqlParser
32
+ # Normalised PostgreSQL type (lowercased, length/precision + `[]` stripped) → the Rails column-type
33
+ # symbol {SchemaTable.ruby_type_for} already understands. Unmapped types fall through to their own
34
+ # symbol, which `ruby_type_for` maps to `Object` (the safe "do not narrow" default).
35
+ PG_TYPE_TO_RAILS = {
36
+ "bigint" => :bigint, "int8" => :bigint, "bigserial" => :bigint,
37
+ "integer" => :integer, "int" => :integer, "int4" => :integer, "serial" => :integer,
38
+ "smallint" => :integer, "int2" => :integer, "smallserial" => :integer,
39
+ "boolean" => :boolean, "bool" => :boolean,
40
+ "text" => :text,
41
+ "character varying" => :string, "varchar" => :string,
42
+ "character" => :string, "char" => :string, "name" => :string, "citext" => :citext,
43
+ "timestamp without time zone" => :datetime, "timestamp with time zone" => :datetime,
44
+ "timestamp" => :datetime, "timestamptz" => :datetime,
45
+ "date" => :date,
46
+ "time without time zone" => :time, "time with time zone" => :time, "time" => :time,
47
+ "numeric" => :decimal, "decimal" => :decimal, "money" => :decimal,
48
+ "double precision" => :float, "real" => :float, "float8" => :float, "float4" => :float,
49
+ "bytea" => :binary,
50
+ "json" => :json, "jsonb" => :jsonb,
51
+ "uuid" => :uuid, "inet" => :inet, "cidr" => :string, "macaddr" => :string
52
+ }.freeze
53
+
54
+ # Uppercase leading keywords that mark a table-level constraint line rather than a column.
55
+ CONSTRAINT_KEYWORDS = %w[CONSTRAINT PRIMARY UNIQUE CHECK FOREIGN EXCLUDE LIKE PARTITION].freeze
56
+
57
+ # @param source [String] contents of `db/structure.sql`
58
+ # @return [SchemaTable]
59
+ def self.parse(source)
60
+ new.parse(source)
61
+ end
62
+
63
+ def parse(source)
64
+ tables = {}
65
+ each_create_table(source.to_s) do |table_name, body_lines|
66
+ columns = parse_columns(body_lines)
67
+ tables[table_name] = columns unless columns.empty?
68
+ end
69
+ SchemaTable.new(tables.freeze)
70
+ end
71
+
72
+ private
73
+
74
+ # Yields `[table_name, body_lines]` for every `CREATE TABLE … (` … `)` block. Body lines are the raw
75
+ # lines between the opening `(` line and the closing `)` line.
76
+ def each_create_table(source)
77
+ lines = source.lines
78
+ index = 0
79
+ while index < lines.length
80
+ match = lines[index].match(/\ACREATE (?:UNLOGGED |TEMPORARY )?TABLE (?:IF NOT EXISTS )?(\S+)\s*\(/)
81
+ unless match
82
+ index += 1
83
+ next
84
+ end
85
+
86
+ table_name = normalize_table_name(match[1])
87
+ index += 1
88
+ body = []
89
+ while index < lines.length && lines[index] !~ /\A\s*\)/
90
+ body << lines[index]
91
+ index += 1
92
+ end
93
+ yield(table_name, body) if table_name
94
+ index += 1 # step past the closing `)` line
95
+ end
96
+ end
97
+
98
+ # Strips quotes and a leading schema qualifier. Only the default `public` schema (or an unqualified
99
+ # name) is accepted; other schemas (`gitlab_partitions_dynamic.*`, etc.) are partitions of a base
100
+ # table already emitted unqualified, so returning nil skips them without losing a real table.
101
+ def normalize_table_name(raw)
102
+ name = raw.delete('"')
103
+ if name.include?(".")
104
+ schema, table = name.split(".", 2)
105
+ return nil unless schema == "public"
106
+
107
+ name = table
108
+ end
109
+ name.empty? ? nil : name
110
+ end
111
+
112
+ def parse_columns(body_lines)
113
+ columns = {}
114
+ # A column's `DEFAULT` may carry a multi-line single-quoted string (a serialized YAML default,
115
+ # `DEFAULT '--- []\n'::character varying`). The column name + type sit before the `DEFAULT`, so
116
+ # the first physical line still parses correctly; the continuation lines must be skipped or they
117
+ # read as bogus columns (`'::character varying`). A line with an ODD count of single quotes opens
118
+ # (or closes) such a string — PG doubles a literal quote (`''`), so escapes stay even and only a
119
+ # genuinely unterminated string toggles the state.
120
+ in_string = false
121
+ body_lines.each do |raw|
122
+ if in_string
123
+ in_string = false if raw.count("'").odd?
124
+ next
125
+ end
126
+ in_string = true if raw.count("'").odd?
127
+
128
+ line = raw.strip.chomp(",")
129
+ next if line.empty?
130
+
131
+ name, rest = line.split(/\s+/, 2)
132
+ next if name.nil? || rest.nil?
133
+ next if CONSTRAINT_KEYWORDS.include?(name) # a table-level constraint, not a column
134
+
135
+ column = build_column(name.delete('"'), rest)
136
+ columns[column.name] = column if column
137
+ end
138
+ columns.freeze
139
+ end
140
+
141
+ def build_column(name, rest)
142
+ type_str, array = extract_type(rest)
143
+ return nil if type_str.empty?
144
+
145
+ rails_type = PG_TYPE_TO_RAILS.fetch(type_str, type_str.to_sym)
146
+ SchemaTable::Column.new(
147
+ name: name,
148
+ type: rails_type,
149
+ ruby_type: SchemaTable.ruby_type_for(rails_type),
150
+ array: array
151
+ )
152
+ end
153
+
154
+ # The type is every token before the first uppercase-initial token (a column modifier such as
155
+ # `NOT`, `NULL`, `DEFAULT`, `GENERATED`, `COLLATE`). A trailing `[]` marks a Postgres array column;
156
+ # a `(length[,scale])` specifier is dropped. Returns `[normalized_type, array?]`.
157
+ def extract_type(rest)
158
+ type_tokens = []
159
+ rest.split(/\s+/).each do |token|
160
+ break if token.match?(/\A[A-Z]/)
161
+
162
+ type_tokens << token
163
+ end
164
+ type_str = type_tokens.join(" ")
165
+ array = type_str.include?("[]")
166
+ type_str = type_str.gsub("[]", "").gsub(/\([0-9,\s]*\)/, "").strip.downcase
167
+ [type_str, array]
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
@@ -4,6 +4,7 @@ require "rigor/plugin"
4
4
 
5
5
  require_relative "activerecord/schema_table"
6
6
  require_relative "activerecord/schema_parser"
7
+ require_relative "activerecord/structure_sql_parser"
7
8
  require_relative "activerecord/model_index"
8
9
  require_relative "activerecord/model_discoverer"
9
10
  require_relative "activerecord/analyzer"
@@ -18,7 +19,8 @@ module Rigor
18
19
  # Two cached producers per plugin run:
19
20
  #
20
21
  # 1. `:schema_table` reads `db/schema.rb` via the `IoBoundary` and parses it through {SchemaParser} into
21
- # a {SchemaTable} mapping `table_name → { column_name → Column }`.
22
+ # a {SchemaTable} mapping `table_name → { column_name → Column }`. When `db/schema.rb` is absent it
23
+ # falls back to a PostgreSQL `db/structure.sql` (`schema_format = :sql` apps) via {StructureSqlParser}.
22
24
  # 2. `:model_index` walks every `.rb` file under the configured `model_search_paths`, finds class
23
25
  # declarations whose direct superclass is in `model_base_classes`, and composes them with the schema
24
26
  # table into a {ModelIndex}.
@@ -57,6 +59,9 @@ module Rigor
57
59
  description: "Types ActiveRecord finders against the project's db/schema.rb and AR models.",
58
60
  config_schema: {
59
61
  "schema_file" => { kind: :string, default: "db/schema.rb" },
62
+ # `schema_format = :sql` projects (GitLab-class apps) commit a PostgreSQL `db/structure.sql`
63
+ # instead of `db/schema.rb`. Used as the fallback schema source when `schema_file` is absent.
64
+ "structure_sql_file" => { kind: :string, default: "db/structure.sql" },
60
65
  "model_search_paths" => { kind: :array, default: ["app/models"] },
61
66
  "model_base_classes" => { kind: :array, default: %w[ApplicationRecord ActiveRecord::Base] }
62
67
  },
@@ -76,26 +81,34 @@ module Rigor
76
81
  RELATION_CLASS_NAME = "ActiveRecord::Relation"
77
82
 
78
83
  # Cached: parsed schema table. The producer reads `@schema_file` via `io_boundary.read_file` so the
79
- # descriptor picks up the digest, then parses through {SchemaParser}.
84
+ # descriptor picks up the digest, then parses through {SchemaParser}. When `db/schema.rb` is absent
85
+ # the producer falls back to a PostgreSQL `db/structure.sql` (`schema_format = :sql` apps) parsed
86
+ # through {StructureSqlParser} — both reads are captured into the record-and-validate descriptor.
80
87
  producer :schema_table do |_params|
81
- contents = io_boundary.read_file(@schema_file)
82
- SchemaParser.parse(contents)
88
+ SchemaParser.parse(io_boundary.read_file(@schema_file))
89
+ rescue Errno::ENOENT
90
+ StructureSqlParser.parse(io_boundary.read_file(@structure_sql_file))
83
91
  end
84
92
 
85
93
  # Cached: model index. Walks every model file, then composes the rows with the cached schema table.
86
94
  # `watch:` (ADR-60 WD3) covers model-file additions; the discoverer's in-block reads are captured
87
95
  # into the record-and-validate dependency descriptor after the block runs.
88
96
  producer :model_index, watch: -> { [[@model_search_paths, "**/*.rb"]] } do |_params|
89
- rows = ModelDiscoverer.new(
97
+ discoverer = ModelDiscoverer.new(
90
98
  io_boundary: io_boundary,
91
99
  search_paths: @model_search_paths,
92
100
  base_classes: @model_base_classes
93
- ).discover
94
- ModelIndex.build(model_rows: rows, schema_table: schema_table_or_nil)
101
+ )
102
+ rows = discoverer.discover
103
+ ModelIndex.build(
104
+ model_rows: rows, schema_table: schema_table_or_nil,
105
+ type_override_columns: discoverer.type_override_columns
106
+ )
95
107
  end
96
108
 
97
109
  def init(_services)
98
110
  @schema_file = config.fetch("schema_file")
111
+ @structure_sql_file = config.fetch("structure_sql_file")
99
112
  @model_search_paths = Array(config.fetch("model_search_paths")).map(&:to_s)
100
113
  @model_base_classes = Array(config.fetch("model_base_classes")).map(&:to_s)
101
114
  @schema_table = nil
@@ -518,7 +531,8 @@ module Rigor
518
531
  @load_errors << "rigor-activerecord: #{e.message}"
519
532
  nil
520
533
  rescue Errno::ENOENT
521
- @load_errors << "rigor-activerecord: schema file `#{@schema_file}` not found; AR call checks skipped"
534
+ @load_errors << "rigor-activerecord: schema file `#{@schema_file}` (or `#{@structure_sql_file}`) " \
535
+ "not found; AR call checks skipped"
522
536
  nil
523
537
  rescue StandardError => e
524
538
  @load_errors << "rigor-activerecord: failed to parse `#{@schema_file}`: #{e.class}: #{e.message}"