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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/data/core_overlay/csv.rbs +28 -0
- data/data/core_overlay/psych.rbs +22 -0
- data/docs/handbook/01-getting-started.md +9 -1
- data/docs/handbook/02-everyday-types.md +4 -1
- data/docs/handbook/08-understanding-errors.md +3 -3
- data/docs/manual/01-installation.md +1 -0
- data/docs/manual/02-cli-reference.md +16 -7
- data/docs/manual/07-plugins.md +1 -1
- data/docs/manual/14-rails-quickstart.md +4 -2
- data/docs/manual/15-type-protection-coverage.md +21 -0
- data/docs/manual/plugins/rigor-actionpack.md +1 -1
- data/docs/manual/plugins/rigor-activerecord.md +12 -5
- data/docs/manual/plugins/rigor-rails-routes.md +11 -0
- data/lib/rigor/cache/store.rb +174 -57
- data/lib/rigor/cli/coverage_command.rb +50 -29
- data/lib/rigor/cli/diagnostic_formats.rb +4 -1
- data/lib/rigor/cli/protection_fork_scan.rb +55 -0
- data/lib/rigor/cli/protection_report.rb +7 -1
- data/lib/rigor/environment/missing_gem_constant_index.rb +128 -0
- data/lib/rigor/environment.rb +54 -18
- data/lib/rigor/inference/expression_typer.rb +20 -2
- data/lib/rigor/inference/fork_map.rb +87 -0
- data/lib/rigor/inference/narrowing.rb +118 -0
- data/lib/rigor/inference/parameter_inference_collector.rb +55 -10
- data/lib/rigor/inference/scope_indexer.rb +9 -13
- data/lib/rigor/inference/statement_evaluator.rb +48 -3
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +23 -8
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +21 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +10 -1
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +61 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +20 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/structure_sql_parser.rb +172 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +22 -8
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +32 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +189 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +19 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +41 -10
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +42 -12
- data/sig/rigor/environment.rbs +1 -0
- 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
|
-
#
|
|
1993
|
-
#
|
|
1994
|
-
#
|
|
1995
|
-
#
|
|
1996
|
-
#
|
|
1997
|
-
#
|
|
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
|
-
#
|
|
2140
|
-
#
|
|
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
|
-
|
|
1829
|
-
return base_scope if
|
|
1828
|
+
mutated = callee_content_mutated_parameters(def_node)
|
|
1829
|
+
return base_scope if mutated.empty?
|
|
1830
1830
|
|
|
1831
|
-
floor_arguments_at_positions(node,
|
|
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
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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:
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
)
|
|
94
|
-
|
|
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}`
|
|
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}"
|