rigortype 0.1.9 → 0.1.11
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/lib/rigor/analysis/baseline.rb +51 -15
- data/lib/rigor/analysis/runner.rb +67 -9
- data/lib/rigor/analysis/worker_session.rb +13 -4
- data/lib/rigor/cache/rbs_descriptor.rb +21 -2
- data/lib/rigor/cache/rbs_environment.rb +2 -1
- data/lib/rigor/cli/annotate_command.rb +57 -7
- data/lib/rigor/cli/baseline_command.rb +4 -3
- data/lib/rigor/cli/coverage_command.rb +126 -0
- data/lib/rigor/cli/coverage_renderer.rb +162 -0
- data/lib/rigor/cli/coverage_report.rb +75 -0
- data/lib/rigor/cli/mcp_command.rb +70 -0
- data/lib/rigor/cli.rb +88 -5
- data/lib/rigor/environment/rbs_loader.rb +46 -5
- data/lib/rigor/environment/reporters.rb +3 -2
- data/lib/rigor/environment.rb +159 -4
- data/lib/rigor/inference/def_return_typer.rb +98 -0
- data/lib/rigor/inference/expression_typer.rb +143 -12
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +5 -0
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +115 -7
- data/lib/rigor/inference/precision_scanner.rb +131 -0
- data/lib/rigor/inference/statement_evaluator.rb +26 -2
- data/lib/rigor/mcp/loop.rb +43 -0
- data/lib/rigor/mcp/server.rb +263 -0
- data/lib/rigor/mcp.rb +16 -0
- data/lib/rigor/plugin/base.rb +28 -5
- data/lib/rigor/plugin/manifest.rb +33 -5
- data/lib/rigor/plugin/registry.rb +21 -0
- data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
- data/lib/rigor/sig_gen/generator.rb +150 -75
- data/lib/rigor/type/combinator.rb +57 -0
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
- data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +178 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +310 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +76 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +177 -0
- data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +589 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +150 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +123 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +247 -0
- data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
- data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +273 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +240 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +94 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +514 -0
- data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
- data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
- data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +34 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +463 -0
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
- data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
- data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
- data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
- data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
- data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
- data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
- data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
- data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
- data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
- data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
- data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
- data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
- data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
- data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +277 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +167 -0
- data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +161 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +103 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +490 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +158 -0
- data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
- data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
- data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
- data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
- data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
- data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
- data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
- data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
- data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
- data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
- data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
- data/sig/rigor/analysis/baseline.rbs +39 -0
- data/sig/rigor/environment.rbs +3 -2
- data/sig/rigor/type.rbs +4 -0
- data/sig/rigor.rbs +2 -0
- metadata +180 -1
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Plugin
|
|
5
|
+
class Activerecord < Rigor::Plugin::Base
|
|
6
|
+
# Maps a discovered ActiveRecord model class name to its
|
|
7
|
+
# resolved table name and the column set the schema attaches
|
|
8
|
+
# to that table. Marshal-clean; the cache producer round-
|
|
9
|
+
# trips it through the standard pair.
|
|
10
|
+
#
|
|
11
|
+
# Construction is two-phase by design:
|
|
12
|
+
# 1. {ModelDiscoverer} walks the project source for model
|
|
13
|
+
# class declarations (direct base-class children plus
|
|
14
|
+
# transitive STI subclasses) and yields a row per model.
|
|
15
|
+
# 2. The plugin combines those rows with the parsed
|
|
16
|
+
# {SchemaTable} to produce this index.
|
|
17
|
+
#
|
|
18
|
+
# `table_name_override` is non-nil when the source contained
|
|
19
|
+
# `self.table_name = "..."`. When nil, the table name
|
|
20
|
+
# derives from {Inflector.tableize}.
|
|
21
|
+
#
|
|
22
|
+
# Single-table-inheritance subclasses (`class Admin < User`)
|
|
23
|
+
# carry an `sti_parent:` pointer; their {Entry} resolves its
|
|
24
|
+
# table from the root model and inherits the chain's
|
|
25
|
+
# declared associations / enums / aliases / scopes /
|
|
26
|
+
# validations / callbacks.
|
|
27
|
+
class ModelIndex
|
|
28
|
+
# `associations` is a frozen `Array<Hash>` where each
|
|
29
|
+
# row carries `{ name:, kind:, target:, nullable: }`:
|
|
30
|
+
#
|
|
31
|
+
# - `name` — String, the association method name as
|
|
32
|
+
# the user invokes it (`"posts"`).
|
|
33
|
+
# - `kind` — `:singular` (`belongs_to` / `has_one`)
|
|
34
|
+
# or `:collection` (`has_many`).
|
|
35
|
+
# - `target` — String, the target class name resolved
|
|
36
|
+
# either from an explicit `class_name:`
|
|
37
|
+
# option or via {Inflector.classify}.
|
|
38
|
+
# - `nullable` — Boolean; whether a `:singular` accessor
|
|
39
|
+
# can return `nil`. `has_one` → `true`;
|
|
40
|
+
# `belongs_to` → `false` (required by default
|
|
41
|
+
# since Rails 5) unless `optional: true` /
|
|
42
|
+
# `required: false`. Meaningless for
|
|
43
|
+
# `:collection` rows.
|
|
44
|
+
Entry = Struct.new(:class_name, :table_name, :columns, :associations,
|
|
45
|
+
:enums, :scopes, :validations, :callbacks, :aliases,
|
|
46
|
+
keyword_init: true) do
|
|
47
|
+
def column(name)
|
|
48
|
+
columns.find { |c| c.name == name.to_s }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def column?(name)
|
|
52
|
+
!column(name).nil?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def column_names = columns.map(&:name)
|
|
56
|
+
|
|
57
|
+
def association(name)
|
|
58
|
+
associations.find { |a| a[:name] == name.to_s }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def association?(name)
|
|
62
|
+
!association(name).nil?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def association_names = associations.map { |a| a[:name] }
|
|
66
|
+
|
|
67
|
+
# `enums` is `Hash<column_name => Array<value_name>>`.
|
|
68
|
+
def enum?(name) = enums.key?(name.to_s)
|
|
69
|
+
def enum_values(name) = enums.fetch(name.to_s, [])
|
|
70
|
+
|
|
71
|
+
# `scopes` is `Array<scope_name>`.
|
|
72
|
+
def scope?(name) = scopes.include?(name.to_s)
|
|
73
|
+
|
|
74
|
+
# `validations` is `Array<attribute_name>` covering
|
|
75
|
+
# both `validates :name, ...` and the
|
|
76
|
+
# `validates_*_of :name, ...` shorthand families.
|
|
77
|
+
def validation?(name) = validations.include?(name.to_s)
|
|
78
|
+
def validated_attributes = validations
|
|
79
|
+
|
|
80
|
+
# `callbacks` is `Array<{ name:, callback: }>`.
|
|
81
|
+
def callback_targets = callbacks.map { |c| c[:name] }
|
|
82
|
+
|
|
83
|
+
# `aliases` is `Hash<alias_name => target_attribute>`
|
|
84
|
+
# populated from `alias_attribute` declarations.
|
|
85
|
+
def alias?(name) = aliases.key?(name.to_s)
|
|
86
|
+
def resolve_alias(name) = aliases[name.to_s]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
attr_reader :entries
|
|
90
|
+
|
|
91
|
+
def initialize(entries)
|
|
92
|
+
@entries = entries.freeze
|
|
93
|
+
freeze
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def find(class_name)
|
|
97
|
+
entries[class_name.to_s]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def model?(class_name) = entries.key?(class_name.to_s)
|
|
101
|
+
def class_names = entries.keys
|
|
102
|
+
def empty? = entries.empty?
|
|
103
|
+
|
|
104
|
+
def self.build(model_rows:, schema_table:)
|
|
105
|
+
rows_by_name = model_rows.to_h { |row| [row.fetch(:class_name), row] }
|
|
106
|
+
|
|
107
|
+
entries = model_rows.each_with_object({}) do |row, acc|
|
|
108
|
+
class_name = row.fetch(:class_name)
|
|
109
|
+
# The STI ancestry chain, root → self. For a plain
|
|
110
|
+
# (non-STI) model this is just `[row]`.
|
|
111
|
+
chain = sti_chain(row, rows_by_name)
|
|
112
|
+
table_name = sti_table_name(chain)
|
|
113
|
+
columns = schema_table.columns_for(table_name) || []
|
|
114
|
+
|
|
115
|
+
# STI children inherit their ancestors' declared
|
|
116
|
+
# associations / enums / aliases / scopes /
|
|
117
|
+
# validations / callbacks. Without the merge a
|
|
118
|
+
# `where(<parent-association>: ...)` on the child
|
|
119
|
+
# would surface as a false `unknown-column`.
|
|
120
|
+
acc[class_name] = Entry.new(
|
|
121
|
+
class_name: class_name,
|
|
122
|
+
table_name: table_name,
|
|
123
|
+
columns: columns.freeze,
|
|
124
|
+
associations: merge_named_rows(chain.flat_map { |r| Array(r[:associations]) }),
|
|
125
|
+
enums: merge_enums(chain),
|
|
126
|
+
scopes: chain.flat_map { |r| Array(r[:scopes]) }.uniq.freeze,
|
|
127
|
+
validations: chain.flat_map { |r| Array(r[:validations]) }.uniq.freeze,
|
|
128
|
+
callbacks: chain.flat_map { |r| Array(r[:callbacks]) }.map(&:freeze).freeze,
|
|
129
|
+
aliases: merge_aliases(chain)
|
|
130
|
+
).freeze
|
|
131
|
+
end
|
|
132
|
+
new(entries.freeze)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The STI ancestry chain for a row, ordered root → self.
|
|
136
|
+
# Walks `sti_parent` pointers, guarding against a cycle.
|
|
137
|
+
def self.sti_chain(row, rows_by_name, seen = [])
|
|
138
|
+
class_name = row.fetch(:class_name)
|
|
139
|
+
parent_name = row[:sti_parent]
|
|
140
|
+
return [row] if parent_name.nil? || seen.include?(class_name)
|
|
141
|
+
|
|
142
|
+
parent = rows_by_name[parent_name]
|
|
143
|
+
return [row] if parent.nil?
|
|
144
|
+
|
|
145
|
+
sti_chain(parent, rows_by_name, seen + [class_name]) + [row]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The effective table name for an STI chain: the nearest
|
|
149
|
+
# explicit `self.table_name =` override walking leaf →
|
|
150
|
+
# root, else the name inflected from the root class.
|
|
151
|
+
def self.sti_table_name(chain)
|
|
152
|
+
chain.reverse_each do |row|
|
|
153
|
+
override = row[:table_name_override]
|
|
154
|
+
return override if override
|
|
155
|
+
end
|
|
156
|
+
Inflector.tableize(strip_leading_namespace(chain.first.fetch(:class_name)))
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Dedups association-style rows by `:name`, keeping the
|
|
160
|
+
# LAST occurrence so a child redeclaration overrides the
|
|
161
|
+
# inherited ancestor row.
|
|
162
|
+
def self.merge_named_rows(rows)
|
|
163
|
+
seen = {}
|
|
164
|
+
rows.each { |row| seen[row[:name]] = row }
|
|
165
|
+
seen.values.map(&:freeze).freeze
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# `Hash<column => Array<value>>` merged across the STI
|
|
169
|
+
# chain; a child redeclaration of the same enum column
|
|
170
|
+
# overrides the ancestor's value list.
|
|
171
|
+
def self.merge_enums(chain)
|
|
172
|
+
chain.each_with_object({}) do |row, acc|
|
|
173
|
+
(row[:enums] || {}).each { |col, values| acc[col] = Array(values).map(&:freeze).freeze }
|
|
174
|
+
end.freeze
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# `Hash<alias => target>` merged across the STI chain.
|
|
178
|
+
def self.merge_aliases(chain)
|
|
179
|
+
chain.each_with_object({}) do |row, acc|
|
|
180
|
+
(row[:aliases] || {}).each { |name, target| acc[name.to_s] = target.to_s }
|
|
181
|
+
end.freeze
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# `::User` → `User`. The discoverer might prefix with
|
|
185
|
+
# `::` for top-level constants depending on how it
|
|
186
|
+
# resolved the path; the table-name derivation uses the
|
|
187
|
+
# short form regardless.
|
|
188
|
+
def self.strip_leading_namespace(name)
|
|
189
|
+
name.start_with?("::") ? name[2..] : name
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Plugin
|
|
7
|
+
class Activerecord < Rigor::Plugin::Base
|
|
8
|
+
# Walks a parsed `db/schema.rb` and produces a {SchemaTable}.
|
|
9
|
+
# Recognises the `create_table` DSL Rails generates:
|
|
10
|
+
#
|
|
11
|
+
# ActiveRecord::Schema[8.0].define(version: ...) do
|
|
12
|
+
# create_table "users", force: :cascade do |t|
|
|
13
|
+
# t.string "name", null: false
|
|
14
|
+
# t.integer "age"
|
|
15
|
+
# t.datetime "created_at"
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# create_table "posts" do |t|
|
|
19
|
+
# t.text "body"
|
|
20
|
+
# t.references "user", foreign_key: true # adds user_id integer
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# `t.references "x"` becomes an `x_id` integer column
|
|
25
|
+
# (foreign-key indices and constraints are ignored — only the
|
|
26
|
+
# column shape matters for type inference); add a `polymorphic:
|
|
27
|
+
# true` option and an `x_type` string column is emitted too.
|
|
28
|
+
# `t.timestamps` adds `created_at` and `updated_at` datetime
|
|
29
|
+
# columns. `t.column "x", :type` is the generic column form.
|
|
30
|
+
# Any other `t.<method> "name"` call is treated as a column
|
|
31
|
+
# whose type symbol is the method name — unknown types degrade
|
|
32
|
+
# to `Object` per `SchemaTable.ruby_type_for` rather than being
|
|
33
|
+
# dropped. Only the structural calls in {NON_COLUMN_METHODS}
|
|
34
|
+
# (indexes, constraints, foreign keys) are skipped.
|
|
35
|
+
#
|
|
36
|
+
# Designed for the Prism interpretation pattern from
|
|
37
|
+
# rigor-lisp-eval — recursive descent on the AST, no eval.
|
|
38
|
+
class SchemaParser
|
|
39
|
+
TIMESTAMPS_COLUMNS = %w[created_at updated_at].freeze
|
|
40
|
+
|
|
41
|
+
# Structural `t.<method>` calls inside a `create_table`
|
|
42
|
+
# block that declare indexes / constraints rather than
|
|
43
|
+
# columns. Everything NOT in this set that carries a
|
|
44
|
+
# literal column name is treated as a column declaration
|
|
45
|
+
# so a real column is never silently dropped — a dropped
|
|
46
|
+
# column turns every query against it into a false
|
|
47
|
+
# `unknown-column` diagnostic.
|
|
48
|
+
NON_COLUMN_METHODS = %i[
|
|
49
|
+
index check_constraint exclusion_constraint
|
|
50
|
+
unique_constraint foreign_key primary_keys
|
|
51
|
+
].freeze
|
|
52
|
+
|
|
53
|
+
# @param source [String] contents of `db/schema.rb`
|
|
54
|
+
# @return [SchemaTable]
|
|
55
|
+
def self.parse(source)
|
|
56
|
+
tree = Prism.parse(source).value
|
|
57
|
+
new.parse(tree)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def parse(node)
|
|
61
|
+
tables = {}
|
|
62
|
+
collect_create_table_calls(node) do |call_node|
|
|
63
|
+
table_name, columns = parse_create_table(call_node)
|
|
64
|
+
tables[table_name] = columns if table_name
|
|
65
|
+
end
|
|
66
|
+
SchemaTable.new(tables.freeze)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def collect_create_table_calls(node, &)
|
|
72
|
+
return if node.nil?
|
|
73
|
+
|
|
74
|
+
yield node if node.is_a?(Prism::CallNode) && node.name == :create_table && node.receiver.nil?
|
|
75
|
+
|
|
76
|
+
node.compact_child_nodes.each { |child| collect_create_table_calls(child, &) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def parse_create_table(call_node)
|
|
80
|
+
table_name = string_argument(call_node, 0)
|
|
81
|
+
return [nil, nil] if table_name.nil?
|
|
82
|
+
|
|
83
|
+
block_node = call_node.block
|
|
84
|
+
columns = { "id" => SchemaTable::Column.new(name: "id", type: :integer, ruby_type: "Integer") }
|
|
85
|
+
columns.delete("id") if id_disabled?(call_node)
|
|
86
|
+
|
|
87
|
+
if block_node.is_a?(Prism::BlockNode) && block_node.body
|
|
88
|
+
collect_column_calls(block_node.body) do |column_call|
|
|
89
|
+
column = parse_column(column_call)
|
|
90
|
+
if column.is_a?(Array)
|
|
91
|
+
column.each { |c| columns[c.name] = c }
|
|
92
|
+
elsif column
|
|
93
|
+
columns[column.name] = column
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
[table_name, columns.freeze]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def id_disabled?(call_node)
|
|
102
|
+
return false if call_node.arguments.nil?
|
|
103
|
+
|
|
104
|
+
call_node.arguments.arguments.each do |arg|
|
|
105
|
+
next unless arg.is_a?(Prism::KeywordHashNode)
|
|
106
|
+
|
|
107
|
+
arg.elements.each do |pair|
|
|
108
|
+
next unless pair.is_a?(Prism::AssocNode)
|
|
109
|
+
|
|
110
|
+
key = symbol_key(pair.key)
|
|
111
|
+
return true if key == :id && pair.value.is_a?(Prism::FalseNode)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
false
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Walks the block body collecting `t.<method>(...)` calls.
|
|
118
|
+
# Skips nested blocks (e.g. inside `if`-conditioned columns)
|
|
119
|
+
# only at the top level — for richer schema constructs the
|
|
120
|
+
# parser falls back silently.
|
|
121
|
+
def collect_column_calls(node, &)
|
|
122
|
+
return if node.nil?
|
|
123
|
+
|
|
124
|
+
if node.is_a?(Prism::CallNode) && node.receiver.is_a?(Prism::LocalVariableReadNode)
|
|
125
|
+
yield node
|
|
126
|
+
return
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
node.compact_child_nodes.each { |child| collect_column_calls(child, &) }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def parse_column(call_node)
|
|
133
|
+
method = call_node.name
|
|
134
|
+
case method
|
|
135
|
+
when :references, :belongs_to
|
|
136
|
+
parse_references_column(call_node)
|
|
137
|
+
when :timestamps
|
|
138
|
+
parse_timestamps
|
|
139
|
+
when :column
|
|
140
|
+
parse_generic_column(call_node)
|
|
141
|
+
else
|
|
142
|
+
# Structural DSL call (index / constraint / FK) —
|
|
143
|
+
# not a column.
|
|
144
|
+
return nil if NON_COLUMN_METHODS.include?(method)
|
|
145
|
+
|
|
146
|
+
# Any other `t.<method> "name"` is a column. The
|
|
147
|
+
# method name is the type symbol; unknown types
|
|
148
|
+
# degrade to `Object` rather than dropping the column.
|
|
149
|
+
parse_typed_column(method, call_node)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def parse_typed_column(type, call_node)
|
|
154
|
+
name = string_argument(call_node, 0)
|
|
155
|
+
return nil if name.nil?
|
|
156
|
+
|
|
157
|
+
SchemaTable::Column.new(
|
|
158
|
+
name: name,
|
|
159
|
+
type: type,
|
|
160
|
+
ruby_type: SchemaTable.ruby_type_for(type)
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# `t.references "x"` adds an `x_id` integer column.
|
|
165
|
+
# `t.references "x", polymorphic: true` additionally adds
|
|
166
|
+
# an `x_type` string column — without it every
|
|
167
|
+
# `where(x_type: ...)` on the polymorphic owner surfaces
|
|
168
|
+
# as a false `unknown-column`.
|
|
169
|
+
def parse_references_column(call_node)
|
|
170
|
+
name = string_argument(call_node, 0)
|
|
171
|
+
return nil if name.nil?
|
|
172
|
+
|
|
173
|
+
columns = [
|
|
174
|
+
SchemaTable::Column.new(name: "#{name}_id", type: :integer, ruby_type: "Integer")
|
|
175
|
+
]
|
|
176
|
+
if references_polymorphic?(call_node)
|
|
177
|
+
columns << SchemaTable::Column.new(name: "#{name}_type", type: :string, ruby_type: "String")
|
|
178
|
+
end
|
|
179
|
+
columns
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def references_polymorphic?(call_node)
|
|
183
|
+
return false if call_node.arguments.nil?
|
|
184
|
+
|
|
185
|
+
call_node.arguments.arguments.each do |arg|
|
|
186
|
+
next unless arg.is_a?(Prism::KeywordHashNode)
|
|
187
|
+
|
|
188
|
+
arg.elements.each do |pair|
|
|
189
|
+
next unless pair.is_a?(Prism::AssocNode)
|
|
190
|
+
next unless symbol_key(pair.key) == :polymorphic
|
|
191
|
+
|
|
192
|
+
return pair.value.is_a?(Prism::TrueNode)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
false
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# `t.column "name", "type"` / `t.column "name", :type` —
|
|
199
|
+
# the explicit generic column form. The type lives in the
|
|
200
|
+
# second argument; an absent type degrades to `:string`.
|
|
201
|
+
def parse_generic_column(call_node)
|
|
202
|
+
name = string_argument(call_node, 0)
|
|
203
|
+
return nil if name.nil?
|
|
204
|
+
|
|
205
|
+
type = string_argument(call_node, 1)
|
|
206
|
+
type_sym = type ? type.to_sym : :string
|
|
207
|
+
SchemaTable::Column.new(
|
|
208
|
+
name: name,
|
|
209
|
+
type: type_sym,
|
|
210
|
+
ruby_type: SchemaTable.ruby_type_for(type_sym)
|
|
211
|
+
)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def parse_timestamps
|
|
215
|
+
TIMESTAMPS_COLUMNS.map do |name|
|
|
216
|
+
SchemaTable::Column.new(name: name, type: :datetime, ruby_type: "Time")
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def string_argument(call_node, index)
|
|
221
|
+
return nil if call_node.arguments.nil?
|
|
222
|
+
|
|
223
|
+
arg = call_node.arguments.arguments[index]
|
|
224
|
+
return nil if arg.nil?
|
|
225
|
+
|
|
226
|
+
case arg
|
|
227
|
+
when Prism::StringNode then arg.unescaped
|
|
228
|
+
when Prism::SymbolNode then arg.unescaped
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def symbol_key(node)
|
|
233
|
+
case node
|
|
234
|
+
when Prism::SymbolNode then node.unescaped.to_sym
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Plugin
|
|
5
|
+
class Activerecord < Rigor::Plugin::Base
|
|
6
|
+
# Parsed `db/schema.rb`. Maps each table name to its column
|
|
7
|
+
# set; each column carries its declared type. Marshal-clean
|
|
8
|
+
# by construction so the cache producer can round-trip it
|
|
9
|
+
# without a custom serialize / deserialize pair.
|
|
10
|
+
#
|
|
11
|
+
# The mapping from Rails column types to Ruby class names is
|
|
12
|
+
# deliberately conservative — `:string`/`:text` → `String`,
|
|
13
|
+
# `:integer`/`:bigint` → `Integer`, `:boolean` → `bool`,
|
|
14
|
+
# `:datetime`/`:timestamp` → `Time`, `:date` → `Date`,
|
|
15
|
+
# `:decimal`/`:float` → `Float`. Exotic types (json, jsonb,
|
|
16
|
+
# ltree, hstore, custom) fall back to `Object` so the
|
|
17
|
+
# plugin stays silent rather than guessing.
|
|
18
|
+
class SchemaTable
|
|
19
|
+
Column = Struct.new(:name, :type, :ruby_type, keyword_init: true) do
|
|
20
|
+
def to_h = { name: name, type: type, ruby_type: ruby_type }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Map ActiveRecord column types → Ruby class names.
|
|
24
|
+
RUBY_TYPE_MAPPING = {
|
|
25
|
+
string: "String",
|
|
26
|
+
text: "String",
|
|
27
|
+
integer: "Integer",
|
|
28
|
+
bigint: "Integer",
|
|
29
|
+
float: "Float",
|
|
30
|
+
decimal: "Float",
|
|
31
|
+
boolean: "bool",
|
|
32
|
+
datetime: "Time",
|
|
33
|
+
timestamp: "Time",
|
|
34
|
+
date: "Date",
|
|
35
|
+
time: "Time",
|
|
36
|
+
binary: "String",
|
|
37
|
+
json: "Object",
|
|
38
|
+
jsonb: "Object",
|
|
39
|
+
# PostgreSQL-flavoured string-ish column types Rails
|
|
40
|
+
# schemas commonly carry. `uuid` / `citext` / `inet`
|
|
41
|
+
# all behave as `String` for query purposes; mapping
|
|
42
|
+
# them here keeps the column out of the "unknown type
|
|
43
|
+
# → dropped column → false unknown-column" path.
|
|
44
|
+
uuid: "String",
|
|
45
|
+
citext: "String",
|
|
46
|
+
inet: "String"
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
# Implicit columns that every Rails table has unless the
|
|
50
|
+
# schema explicitly opts out. The plugin assumes these
|
|
51
|
+
# exist; users who run `create_table id: false` get no
|
|
52
|
+
# implicit `id` column from the parser, but most apps
|
|
53
|
+
# never disable it.
|
|
54
|
+
IMPLICIT_COLUMNS = [
|
|
55
|
+
Column.new(name: "id", type: :integer, ruby_type: "Integer").freeze
|
|
56
|
+
].freeze
|
|
57
|
+
|
|
58
|
+
attr_reader :tables
|
|
59
|
+
|
|
60
|
+
def initialize(tables)
|
|
61
|
+
@tables = tables.freeze
|
|
62
|
+
freeze
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def column(table_name, column_name)
|
|
66
|
+
table = tables[table_name.to_s]
|
|
67
|
+
return nil if table.nil?
|
|
68
|
+
|
|
69
|
+
table[column_name.to_s]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def columns_for(table_name)
|
|
73
|
+
table = tables[table_name.to_s]
|
|
74
|
+
return nil if table.nil?
|
|
75
|
+
|
|
76
|
+
table.values
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def table?(table_name)
|
|
80
|
+
tables.key?(table_name.to_s)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def table_names = tables.keys
|
|
84
|
+
|
|
85
|
+
# Maps a Rails column type symbol to its Ruby class name.
|
|
86
|
+
# Returns "Object" for unknown types — the analyzer treats
|
|
87
|
+
# that as "do not narrow" (silent on unknowns).
|
|
88
|
+
def self.ruby_type_for(column_type)
|
|
89
|
+
RUBY_TYPE_MAPPING.fetch(column_type.to_sym, "Object")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|