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.
Files changed (158) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/rigor/analysis/baseline.rb +51 -15
  4. data/lib/rigor/analysis/runner.rb +67 -9
  5. data/lib/rigor/analysis/worker_session.rb +13 -4
  6. data/lib/rigor/cache/rbs_descriptor.rb +21 -2
  7. data/lib/rigor/cache/rbs_environment.rb +2 -1
  8. data/lib/rigor/cli/annotate_command.rb +57 -7
  9. data/lib/rigor/cli/baseline_command.rb +4 -3
  10. data/lib/rigor/cli/coverage_command.rb +126 -0
  11. data/lib/rigor/cli/coverage_renderer.rb +162 -0
  12. data/lib/rigor/cli/coverage_report.rb +75 -0
  13. data/lib/rigor/cli/mcp_command.rb +70 -0
  14. data/lib/rigor/cli.rb +88 -5
  15. data/lib/rigor/environment/rbs_loader.rb +46 -5
  16. data/lib/rigor/environment/reporters.rb +3 -2
  17. data/lib/rigor/environment.rb +159 -4
  18. data/lib/rigor/inference/def_return_typer.rb +98 -0
  19. data/lib/rigor/inference/expression_typer.rb +143 -12
  20. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +5 -0
  21. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
  22. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +115 -7
  23. data/lib/rigor/inference/precision_scanner.rb +131 -0
  24. data/lib/rigor/inference/statement_evaluator.rb +26 -2
  25. data/lib/rigor/mcp/loop.rb +43 -0
  26. data/lib/rigor/mcp/server.rb +263 -0
  27. data/lib/rigor/mcp.rb +16 -0
  28. data/lib/rigor/plugin/base.rb +28 -5
  29. data/lib/rigor/plugin/manifest.rb +33 -5
  30. data/lib/rigor/plugin/registry.rb +21 -0
  31. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
  32. data/lib/rigor/sig_gen/generator.rb +150 -75
  33. data/lib/rigor/type/combinator.rb +57 -0
  34. data/lib/rigor/version.rb +1 -1
  35. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
  36. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
  37. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
  38. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
  39. data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
  40. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +178 -0
  41. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +310 -0
  42. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +76 -0
  43. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +177 -0
  44. data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
  45. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +589 -0
  46. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +150 -0
  47. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +123 -0
  48. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +247 -0
  49. data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
  50. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
  51. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
  52. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
  53. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
  54. data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
  55. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +273 -0
  56. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
  57. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
  58. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
  59. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +240 -0
  60. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +94 -0
  61. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +514 -0
  62. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
  63. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
  64. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
  65. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
  66. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
  67. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
  68. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
  69. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +34 -0
  70. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
  71. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +463 -0
  72. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
  73. data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
  74. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
  75. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
  76. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
  77. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
  78. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
  79. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
  80. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
  81. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
  82. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
  83. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
  84. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
  85. data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
  86. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
  87. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
  88. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
  89. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
  90. data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
  91. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
  92. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
  93. data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
  94. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
  95. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
  96. data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
  97. data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
  98. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
  99. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
  100. data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
  101. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
  102. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
  103. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
  104. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
  105. data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
  106. data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
  107. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +277 -0
  108. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
  109. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
  110. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +167 -0
  111. data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
  112. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +161 -0
  113. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +103 -0
  114. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +490 -0
  115. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +158 -0
  116. data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
  117. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
  118. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
  119. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
  120. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
  121. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
  122. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
  123. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
  124. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
  125. data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
  126. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
  127. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
  128. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
  129. data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
  130. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
  131. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
  132. data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
  133. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
  134. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
  135. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
  136. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
  137. data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
  138. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
  139. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
  140. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
  141. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
  142. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
  143. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
  144. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
  145. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
  146. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
  147. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
  148. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
  149. data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
  150. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
  151. data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
  152. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
  153. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
  154. data/sig/rigor/analysis/baseline.rbs +39 -0
  155. data/sig/rigor/environment.rbs +3 -2
  156. data/sig/rigor/type.rbs +4 -0
  157. data/sig/rigor.rbs +2 -0
  158. 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