rigortype 0.2.8 → 0.3.0

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 (207) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/data/core_overlay/csv.rbs +28 -0
  4. data/data/core_overlay/psych.rbs +22 -0
  5. data/docs/handbook/01-getting-started.md +9 -1
  6. data/docs/handbook/02-everyday-types.md +4 -1
  7. data/docs/handbook/03-narrowing.md +2 -1
  8. data/docs/handbook/04-tuples-and-shapes.md +7 -3
  9. data/docs/handbook/06-classes.md +18 -9
  10. data/docs/handbook/08-understanding-errors.md +4 -3
  11. data/docs/handbook/11-sig-gen.md +11 -1
  12. data/docs/handbook/appendix-mypy.md +4 -3
  13. data/docs/install.md +14 -6
  14. data/docs/manual/01-installation.md +99 -4
  15. data/docs/manual/02-cli-reference.md +65 -19
  16. data/docs/manual/03-configuration.md +34 -2
  17. data/docs/manual/04-diagnostics.md +23 -0
  18. data/docs/manual/07-plugins.md +5 -5
  19. data/docs/manual/09-editor-integration.md +4 -4
  20. data/docs/manual/11-ci.md +51 -0
  21. data/docs/manual/12-caching.md +65 -0
  22. data/docs/manual/14-rails-quickstart.md +4 -2
  23. data/docs/manual/15-type-protection-coverage.md +21 -0
  24. data/docs/manual/plugins/rigor-actionpack.md +1 -1
  25. data/docs/manual/plugins/rigor-activerecord.md +12 -5
  26. data/docs/manual/plugins/rigor-rails-routes.md +11 -0
  27. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -1
  28. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +5 -3
  29. data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +128 -0
  30. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +60 -0
  31. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
  32. data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +117 -0
  33. data/lib/rigor/analysis/check_rules/rule_ids.rb +125 -0
  34. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  35. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -1
  36. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +283 -0
  37. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +10 -1
  38. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +93 -0
  39. data/lib/rigor/analysis/check_rules.rb +557 -95
  40. data/lib/rigor/analysis/dependency_recorder.rb +93 -9
  41. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  42. data/lib/rigor/analysis/incremental_session.rb +456 -51
  43. data/lib/rigor/analysis/path_expansion.rb +42 -0
  44. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +188 -0
  45. data/lib/rigor/analysis/rule_catalog.rb +226 -1
  46. data/lib/rigor/analysis/run_cache_key.rb +68 -0
  47. data/lib/rigor/analysis/run_cache_probe.rb +72 -0
  48. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +181 -28
  49. data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -15
  50. data/lib/rigor/analysis/runner/project_pre_passes.rb +95 -49
  51. data/lib/rigor/analysis/runner/run_snapshots.rb +9 -2
  52. data/lib/rigor/analysis/runner.rb +343 -68
  53. data/lib/rigor/analysis/severity_stamp.rb +43 -0
  54. data/lib/rigor/analysis/worker_session.rb +30 -2
  55. data/lib/rigor/bleeding_edge.rb +37 -8
  56. data/lib/rigor/builtins/regex_refinement.rb +36 -1
  57. data/lib/rigor/cache/descriptor.rb +88 -28
  58. data/lib/rigor/cache/file_digest.rb +158 -0
  59. data/lib/rigor/cache/incremental_snapshot.rb +41 -5
  60. data/lib/rigor/cache/rbs_descriptor.rb +56 -10
  61. data/lib/rigor/cache/store.rb +207 -60
  62. data/lib/rigor/ci_detector.rb +90 -0
  63. data/lib/rigor/cli/annotate_command.rb +15 -9
  64. data/lib/rigor/cli/baseline_command.rb +4 -1
  65. data/lib/rigor/cli/check_command.rb +212 -27
  66. data/lib/rigor/cli/coverage_command.rb +56 -30
  67. data/lib/rigor/cli/diagnostic_formats.rb +4 -1
  68. data/lib/rigor/cli/docs_command.rb +0 -30
  69. data/lib/rigor/cli/doctor_command.rb +163 -23
  70. data/lib/rigor/cli/lsp_command.rb +5 -0
  71. data/lib/rigor/cli/mcp_command.rb +5 -0
  72. data/lib/rigor/cli/plugins_command.rb +36 -6
  73. data/lib/rigor/cli/plugins_renderer.rb +44 -12
  74. data/lib/rigor/cli/probe_environment.rb +85 -0
  75. data/lib/rigor/cli/protection_fork_scan.rb +55 -0
  76. data/lib/rigor/cli/protection_report.rb +7 -1
  77. data/lib/rigor/cli/sig_gen_command.rb +36 -9
  78. data/lib/rigor/cli/skill_command.rb +1 -32
  79. data/lib/rigor/cli/trace_command.rb +5 -6
  80. data/lib/rigor/cli/type_of_command.rb +9 -8
  81. data/lib/rigor/cli/type_scan_command.rb +8 -8
  82. data/lib/rigor/cli.rb +15 -4
  83. data/lib/rigor/config_audit.rb +48 -2
  84. data/lib/rigor/configuration/severity_profile.rb +31 -0
  85. data/lib/rigor/configuration.rb +156 -8
  86. data/lib/rigor/environment/default_libraries.rb +35 -0
  87. data/lib/rigor/environment/missing_gem_constant_index.rb +128 -0
  88. data/lib/rigor/environment/rbs_loader.rb +164 -5
  89. data/lib/rigor/environment.rb +71 -43
  90. data/lib/rigor/inference/budget_trace.rb +77 -2
  91. data/lib/rigor/inference/closure_escape_analyzer.rb +14 -1
  92. data/lib/rigor/inference/def_handle.rb +23 -0
  93. data/lib/rigor/inference/def_node_resolver.rb +90 -0
  94. data/lib/rigor/inference/def_return_typer.rb +2 -1
  95. data/lib/rigor/inference/expression_typer.rb +379 -113
  96. data/lib/rigor/inference/fork_map.rb +87 -0
  97. data/lib/rigor/inference/method_dispatcher/data_folding.rb +30 -5
  98. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +200 -0
  99. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +22 -16
  100. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +33 -0
  101. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +11 -0
  102. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +42 -9
  103. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +5 -0
  104. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +28 -35
  105. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +40 -8
  106. data/lib/rigor/inference/method_dispatcher.rb +57 -1
  107. data/lib/rigor/inference/mutation_widening.rb +42 -8
  108. data/lib/rigor/inference/narrowing.rb +245 -17
  109. data/lib/rigor/inference/parameter_inference_collector.rb +70 -10
  110. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  111. data/lib/rigor/inference/rbs_type_translator.rb +8 -1
  112. data/lib/rigor/inference/scope_indexer.rb +460 -49
  113. data/lib/rigor/inference/statement_evaluator.rb +111 -17
  114. data/lib/rigor/inference/struct_fold_safety.rb +48 -11
  115. data/lib/rigor/inference/synthetic_method_scanner.rb +15 -14
  116. data/lib/rigor/inference/void_origin.rb +25 -0
  117. data/lib/rigor/inference/void_tail_summary.rb +220 -0
  118. data/lib/rigor/language_server/completion_provider.rb +2 -1
  119. data/lib/rigor/language_server/document_symbol_provider.rb +2 -1
  120. data/lib/rigor/language_server/folding_range_provider.rb +2 -1
  121. data/lib/rigor/language_server/selection_range_provider.rb +2 -1
  122. data/lib/rigor/language_server/signature_help_provider.rb +2 -1
  123. data/lib/rigor/plugin/base.rb +9 -22
  124. data/lib/rigor/plugin/inflector.rb +12 -3
  125. data/lib/rigor/plugin/io_boundary.rb +5 -1
  126. data/lib/rigor/plugin/isolation.rb +81 -11
  127. data/lib/rigor/plugin/load_error.rb +10 -1
  128. data/lib/rigor/plugin/loader.rb +96 -14
  129. data/lib/rigor/plugin/node_rule_walk.rb +5 -3
  130. data/lib/rigor/plugin/registry.rb +32 -23
  131. data/lib/rigor/plugin.rb +26 -0
  132. data/lib/rigor/protection/mutator.rb +3 -2
  133. data/lib/rigor/reflection.rb +64 -0
  134. data/lib/rigor/runtime/jit.rb +128 -0
  135. data/lib/rigor/scope/discovery_index.rb +12 -2
  136. data/lib/rigor/scope.rb +122 -28
  137. data/lib/rigor/sig_gen/classification.rb +5 -1
  138. data/lib/rigor/sig_gen/generator.rb +38 -6
  139. data/lib/rigor/sig_gen/observation_collector.rb +4 -3
  140. data/lib/rigor/sig_gen/rbs_validity.rb +44 -0
  141. data/lib/rigor/sig_gen/renderer.rb +10 -0
  142. data/lib/rigor/sig_gen/write_result.rb +9 -4
  143. data/lib/rigor/sig_gen/writer.rb +24 -2
  144. data/lib/rigor/source/node_children.rb +116 -0
  145. data/lib/rigor/source/node_locator.rb +3 -1
  146. data/lib/rigor/source/node_walker.rb +4 -2
  147. data/lib/rigor/source.rb +1 -0
  148. data/lib/rigor/type/difference.rb +28 -24
  149. data/lib/rigor/type/hash_shape.rb +34 -10
  150. data/lib/rigor/version.rb +1 -1
  151. data/lib/rigortype.rb +24 -0
  152. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +4 -2
  153. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +6 -4
  154. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +23 -8
  155. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +5 -3
  156. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +21 -0
  157. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +4 -2
  158. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +13 -2
  159. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +65 -2
  160. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +20 -2
  161. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +4 -2
  162. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/structure_sql_parser.rb +172 -0
  163. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +22 -8
  164. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -1
  165. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +4 -2
  166. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +32 -0
  167. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +38 -6
  168. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -1
  169. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +3 -1
  170. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +10 -0
  171. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +11 -0
  172. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +3 -1
  173. data/plugins/rigor-rails/lib/rigor-rails.rb +11 -12
  174. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +191 -0
  175. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +3 -1
  176. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +19 -1
  177. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +48 -15
  178. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +42 -12
  179. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +83 -9
  180. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +9 -5
  181. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -1
  182. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +3 -1
  183. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +11 -0
  184. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +4 -1
  185. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +4 -2
  186. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +3 -1
  187. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +74 -14
  188. data/sig/prism_node_children.rbs +9 -0
  189. data/sig/rigor/environment.rbs +1 -0
  190. data/sig/rigor/inference.rbs +7 -0
  191. data/sig/rigor/plugin/base.rbs +2 -3
  192. data/sig/rigor/reflection.rbs +1 -0
  193. data/sig/rigor/scope.rbs +12 -1
  194. data/skills/rigor-ci-setup/SKILL.md +10 -0
  195. data/skills/rigor-editor-setup/SKILL.md +11 -0
  196. data/skills/rigor-next-steps/SKILL.md +23 -2
  197. data/skills/rigor-plugin-author/SKILL.md +3 -3
  198. data/skills/rigor-plugin-author/references/02-walker-and-types.md +2 -2
  199. data/skills/rigor-plugin-review/SKILL.md +2 -2
  200. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +5 -4
  201. data/skills/rigor-project-init/SKILL.md +29 -3
  202. data/skills/rigor-project-init/references/01-detect.md +1 -1
  203. data/skills/rigor-project-init/references/02-configure.md +39 -5
  204. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +20 -0
  205. data/skills/rigor-project-init/references/05-jit-performance.md +125 -0
  206. metadata +49 -24
  207. data/lib/rigor/cli/ci_detector.rb +0 -89
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rigor/source/node_children"
4
+
3
5
  require "prism"
4
6
  require "rigor/source/literals"
5
7
 
@@ -33,12 +35,25 @@ module Rigor
33
35
  # @param io_boundary [Rigor::Plugin::IoBoundary]
34
36
  # @param search_paths [Array<String>] absolute or project-relative paths.
35
37
  # @param base_classes [Array<String>] superclass names that identify a class as an AR model.
38
+ # Declaration macros whose column's runtime value is a rich object, not the SQL scalar. Their column
39
+ # must NOT be narrowed to the schema type (see {ModelIndex.build}'s type-override remap).
40
+ TYPE_OVERRIDE_METHODS = %i[serialize mount_uploader mount_uploaders].freeze
41
+
36
42
  def initialize(io_boundary:, search_paths:, base_classes:)
37
43
  @io_boundary = io_boundary
38
44
  @search_paths = search_paths
39
45
  @base_classes = base_classes.to_set
46
+ # Global set of column names whose declared runtime type overrides the schema scalar
47
+ # (`serialize` / `mount_uploader` / `attribute :x, CustomType`). Collected across every class AND
48
+ # concern module walked — a serialize inside a concern's `included do … end` is invisible to the
49
+ # per-model view (the discoverer doesn't follow `include`), so overrides are tracked globally by
50
+ # column name and applied wherever that column appears. Over-suppresses a same-named scalar
51
+ # column elsewhere (a precision cost, never a false positive).
52
+ @type_override_columns = Set.new
40
53
  end
41
54
 
55
+ attr_reader :type_override_columns
56
+
42
57
  # @return [Array<Hash>] rows of { class_name:, table_name_override:, sti_parent:, ... }
43
58
  def discover
44
59
  candidates = []
@@ -127,7 +142,7 @@ module Rigor
127
142
  when Prism::ModuleNode
128
143
  visit_module(node, lexical_path, &)
129
144
  else
130
- node.compact_child_nodes.each { |child| walk_for_classes(child, lexical_path, &) }
145
+ node.rigor_each_child { |child| walk_for_classes(child, lexical_path, &) }
131
146
  end
132
147
  end
133
148
 
@@ -141,6 +156,8 @@ module Rigor
141
156
  full_name = (lexical_path + [class_local_name]).join("::")
142
157
  superclass = constant_path_name(node.superclass) if node.superclass
143
158
 
159
+ collect_type_overrides(node.body)
160
+
144
161
  yield({
145
162
  class_name: full_name,
146
163
  superclass_name: superclass,
@@ -162,10 +179,56 @@ module Rigor
162
179
  module_local_name = constant_path_name(node.constant_path)
163
180
  return if module_local_name.nil?
164
181
 
182
+ # Concerns (`module DiffPositionableNote`) carry `serialize` / `mount_uploader` inside an
183
+ # `included do … end` block; collect their overrides even though the module itself is not a model.
184
+ collect_type_overrides(node.body)
185
+
165
186
  inner_path = lexical_path + [module_local_name]
166
187
  walk_for_classes(node.body, inner_path, &) if node.body
167
188
  end
168
189
 
190
+ # Records the column name of every `serialize :col` / `mount_uploader(s) :col` / `attribute :col,
191
+ # CustomType` in `body` (descending into `with_options` and concern `included do` blocks) into the
192
+ # global {#type_override_columns} set. `attribute :col, :symbol_type` (a built-in scalar type) is
193
+ # NOT an override — only a custom type CONSTANT is.
194
+ def collect_type_overrides(body)
195
+ type_override_declaration_calls(body).each do |node|
196
+ next if node.receiver
197
+
198
+ column = Rigor::Source::Literals.symbol_name(node.arguments&.arguments&.first)
199
+ next if column.nil?
200
+
201
+ if TYPE_OVERRIDE_METHODS.include?(node.name)
202
+ @type_override_columns << column
203
+ elsif node.name == :attribute && custom_type_attribute?(node)
204
+ @type_override_columns << column
205
+ end
206
+ end
207
+ end
208
+
209
+ # `declaration_calls` variant that also descends into a concern's `included do … end` block (the
210
+ # ActiveSupport::Concern idiom where models' shared `serialize` declarations live).
211
+ def type_override_declaration_calls(body)
212
+ return [] if body.nil?
213
+
214
+ body.compact_child_nodes.flat_map do |node|
215
+ next [] unless node.is_a?(Prism::CallNode)
216
+
217
+ if %i[with_options included].include?(node.name) && node.block.is_a?(Prism::BlockNode)
218
+ type_override_declaration_calls(node.block.body)
219
+ else
220
+ [node]
221
+ end
222
+ end
223
+ end
224
+
225
+ # True when an `attribute :col, <type>` call's type argument is a custom type CLASS (a constant),
226
+ # whose runtime value is a rich object — not a built-in `:symbol` type, which stays a scalar.
227
+ def custom_type_attribute?(node)
228
+ type_arg = node.arguments&.arguments&.[](1)
229
+ type_arg.is_a?(Prism::ConstantReadNode) || type_arg.is_a?(Prism::ConstantPathNode)
230
+ end
231
+
169
232
  # Renders a constant-path node (`Admin::User`, `::ApplicationRecord`) as a String. Returns nil for
170
233
  # shapes the discoverer chooses not to handle.
171
234
  def constant_path_name(node)
@@ -195,7 +258,7 @@ module Rigor
195
258
  def lookup_table_name_override(body)
196
259
  return nil if body.nil?
197
260
 
198
- body.compact_child_nodes.each do |node|
261
+ body.rigor_each_child do |node|
199
262
  next unless node.is_a?(Prism::CallNode) && node.name == :table_name=
200
263
  next unless node.receiver.is_a?(Prism::SelfNode)
201
264
 
@@ -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 = [])
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rigor/source/node_children"
4
+
3
5
  require "prism"
4
6
 
5
7
  module Rigor
@@ -66,7 +68,7 @@ module Rigor
66
68
 
67
69
  yield node if node.is_a?(Prism::CallNode) && node.name == :create_table && node.receiver.nil?
68
70
 
69
- node.compact_child_nodes.each { |child| collect_create_table_calls(child, &) }
71
+ node.rigor_each_child { |child| collect_create_table_calls(child, &) }
70
72
  end
71
73
 
72
74
  def parse_create_table(call_node)
@@ -118,7 +120,7 @@ module Rigor
118
120
  return
119
121
  end
120
122
 
121
- node.compact_child_nodes.each { |child| collect_column_calls(child, &) }
123
+ node.rigor_each_child { |child| collect_column_calls(child, &) }
122
124
  end
123
125
 
124
126
  def parse_column(call_node)
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "schema_table"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ class Activerecord < Rigor::Plugin::Base
8
+ # Parses a PostgreSQL `db/structure.sql` (the `schema_format = :sql` schema dump) into the same
9
+ # {SchemaTable} the Ruby-DSL {SchemaParser} produces, so a project that commits `structure.sql`
10
+ # instead of `schema.rb` is no longer inert (GitLab-class apps use `structure.sql`; without this the
11
+ # plugin skips every AR call check and — worse — degrades ordinary relation chains to `Array`,
12
+ # cascading false diagnostics).
13
+ #
14
+ # The dump is regular enough to read line-by-line without a SQL parser. `pg_dump` emits, per table:
15
+ #
16
+ # CREATE TABLE namespaces (
17
+ # id bigint NOT NULL,
18
+ # name character varying NOT NULL,
19
+ # visibility_level integer DEFAULT 20 NOT NULL,
20
+ # tag_ids bigint[],
21
+ # created_at timestamp without time zone
22
+ # );
23
+ #
24
+ # Load-bearing regularities this relies on: column names / types are lowercase, column modifiers
25
+ # (`NOT NULL`, `DEFAULT …`, `GENERATED …`, `COLLATE …`) are UPPERCASE, one column per line, the body
26
+ # closes on a line starting with `)`. So the type is the run of tokens before the first
27
+ # uppercase-initial token, and a table-level constraint line (`CONSTRAINT …`, `PRIMARY KEY …`) is
28
+ # recognised by its uppercase leading keyword and skipped. Anything it cannot map (custom enum,
29
+ # `tsvector`, `ltree`) degrades to `Object` via {SchemaTable.ruby_type_for} — never a dropped column,
30
+ # which would turn every query on it into a false `unknown-column`.
31
+ class StructureSqlParser
32
+ # Normalised PostgreSQL type (lowercased, length/precision + `[]` stripped) → the Rails column-type
33
+ # symbol {SchemaTable.ruby_type_for} already understands. Unmapped types fall through to their own
34
+ # symbol, which `ruby_type_for` maps to `Object` (the safe "do not narrow" default).
35
+ PG_TYPE_TO_RAILS = {
36
+ "bigint" => :bigint, "int8" => :bigint, "bigserial" => :bigint,
37
+ "integer" => :integer, "int" => :integer, "int4" => :integer, "serial" => :integer,
38
+ "smallint" => :integer, "int2" => :integer, "smallserial" => :integer,
39
+ "boolean" => :boolean, "bool" => :boolean,
40
+ "text" => :text,
41
+ "character varying" => :string, "varchar" => :string,
42
+ "character" => :string, "char" => :string, "name" => :string, "citext" => :citext,
43
+ "timestamp without time zone" => :datetime, "timestamp with time zone" => :datetime,
44
+ "timestamp" => :datetime, "timestamptz" => :datetime,
45
+ "date" => :date,
46
+ "time without time zone" => :time, "time with time zone" => :time, "time" => :time,
47
+ "numeric" => :decimal, "decimal" => :decimal, "money" => :decimal,
48
+ "double precision" => :float, "real" => :float, "float8" => :float, "float4" => :float,
49
+ "bytea" => :binary,
50
+ "json" => :json, "jsonb" => :jsonb,
51
+ "uuid" => :uuid, "inet" => :inet, "cidr" => :string, "macaddr" => :string
52
+ }.freeze
53
+
54
+ # Uppercase leading keywords that mark a table-level constraint line rather than a column.
55
+ CONSTRAINT_KEYWORDS = %w[CONSTRAINT PRIMARY UNIQUE CHECK FOREIGN EXCLUDE LIKE PARTITION].freeze
56
+
57
+ # @param source [String] contents of `db/structure.sql`
58
+ # @return [SchemaTable]
59
+ def self.parse(source)
60
+ new.parse(source)
61
+ end
62
+
63
+ def parse(source)
64
+ tables = {}
65
+ each_create_table(source.to_s) do |table_name, body_lines|
66
+ columns = parse_columns(body_lines)
67
+ tables[table_name] = columns unless columns.empty?
68
+ end
69
+ SchemaTable.new(tables.freeze)
70
+ end
71
+
72
+ private
73
+
74
+ # Yields `[table_name, body_lines]` for every `CREATE TABLE … (` … `)` block. Body lines are the raw
75
+ # lines between the opening `(` line and the closing `)` line.
76
+ def each_create_table(source)
77
+ lines = source.lines
78
+ index = 0
79
+ while index < lines.length
80
+ match = lines[index].match(/\ACREATE (?:UNLOGGED |TEMPORARY )?TABLE (?:IF NOT EXISTS )?(\S+)\s*\(/)
81
+ unless match
82
+ index += 1
83
+ next
84
+ end
85
+
86
+ table_name = normalize_table_name(match[1])
87
+ index += 1
88
+ body = []
89
+ while index < lines.length && lines[index] !~ /\A\s*\)/
90
+ body << lines[index]
91
+ index += 1
92
+ end
93
+ yield(table_name, body) if table_name
94
+ index += 1 # step past the closing `)` line
95
+ end
96
+ end
97
+
98
+ # Strips quotes and a leading schema qualifier. Only the default `public` schema (or an unqualified
99
+ # name) is accepted; other schemas (`gitlab_partitions_dynamic.*`, etc.) are partitions of a base
100
+ # table already emitted unqualified, so returning nil skips them without losing a real table.
101
+ def normalize_table_name(raw)
102
+ name = raw.delete('"')
103
+ if name.include?(".")
104
+ schema, table = name.split(".", 2)
105
+ return nil unless schema == "public"
106
+
107
+ name = table
108
+ end
109
+ name.empty? ? nil : name
110
+ end
111
+
112
+ def parse_columns(body_lines)
113
+ columns = {}
114
+ # A column's `DEFAULT` may carry a multi-line single-quoted string (a serialized YAML default,
115
+ # `DEFAULT '--- []\n'::character varying`). The column name + type sit before the `DEFAULT`, so
116
+ # the first physical line still parses correctly; the continuation lines must be skipped or they
117
+ # read as bogus columns (`'::character varying`). A line with an ODD count of single quotes opens
118
+ # (or closes) such a string — PG doubles a literal quote (`''`), so escapes stay even and only a
119
+ # genuinely unterminated string toggles the state.
120
+ in_string = false
121
+ body_lines.each do |raw|
122
+ if in_string
123
+ in_string = false if raw.count("'").odd?
124
+ next
125
+ end
126
+ in_string = true if raw.count("'").odd?
127
+
128
+ line = raw.strip.chomp(",")
129
+ next if line.empty?
130
+
131
+ name, rest = line.split(/\s+/, 2)
132
+ next if name.nil? || rest.nil?
133
+ next if CONSTRAINT_KEYWORDS.include?(name) # a table-level constraint, not a column
134
+
135
+ column = build_column(name.delete('"'), rest)
136
+ columns[column.name] = column if column
137
+ end
138
+ columns.freeze
139
+ end
140
+
141
+ def build_column(name, rest)
142
+ type_str, array = extract_type(rest)
143
+ return nil if type_str.empty?
144
+
145
+ rails_type = PG_TYPE_TO_RAILS.fetch(type_str, type_str.to_sym)
146
+ SchemaTable::Column.new(
147
+ name: name,
148
+ type: rails_type,
149
+ ruby_type: SchemaTable.ruby_type_for(rails_type),
150
+ array: array
151
+ )
152
+ end
153
+
154
+ # The type is every token before the first uppercase-initial token (a column modifier such as
155
+ # `NOT`, `NULL`, `DEFAULT`, `GENERATED`, `COLLATE`). A trailing `[]` marks a Postgres array column;
156
+ # a `(length[,scale])` specifier is dropped. Returns `[normalized_type, array?]`.
157
+ def extract_type(rest)
158
+ type_tokens = []
159
+ rest.split(/\s+/).each do |token|
160
+ break if token.match?(/\A[A-Z]/)
161
+
162
+ type_tokens << token
163
+ end
164
+ type_str = type_tokens.join(" ")
165
+ array = type_str.include?("[]")
166
+ type_str = type_str.gsub("[]", "").gsub(/\([0-9,\s]*\)/, "").strip.downcase
167
+ [type_str, array]
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
@@ -4,6 +4,7 @@ require "rigor/plugin"
4
4
 
5
5
  require_relative "activerecord/schema_table"
6
6
  require_relative "activerecord/schema_parser"
7
+ require_relative "activerecord/structure_sql_parser"
7
8
  require_relative "activerecord/model_index"
8
9
  require_relative "activerecord/model_discoverer"
9
10
  require_relative "activerecord/analyzer"
@@ -18,7 +19,8 @@ module Rigor
18
19
  # Two cached producers per plugin run:
19
20
  #
20
21
  # 1. `:schema_table` reads `db/schema.rb` via the `IoBoundary` and parses it through {SchemaParser} into
21
- # a {SchemaTable} mapping `table_name → { column_name → Column }`.
22
+ # a {SchemaTable} mapping `table_name → { column_name → Column }`. When `db/schema.rb` is absent it
23
+ # falls back to a PostgreSQL `db/structure.sql` (`schema_format = :sql` apps) via {StructureSqlParser}.
22
24
  # 2. `:model_index` walks every `.rb` file under the configured `model_search_paths`, finds class
23
25
  # declarations whose direct superclass is in `model_base_classes`, and composes them with the schema
24
26
  # table into a {ModelIndex}.
@@ -57,6 +59,9 @@ module Rigor
57
59
  description: "Types ActiveRecord finders against the project's db/schema.rb and AR models.",
58
60
  config_schema: {
59
61
  "schema_file" => { kind: :string, default: "db/schema.rb" },
62
+ # `schema_format = :sql` projects (GitLab-class apps) commit a PostgreSQL `db/structure.sql`
63
+ # instead of `db/schema.rb`. Used as the fallback schema source when `schema_file` is absent.
64
+ "structure_sql_file" => { kind: :string, default: "db/structure.sql" },
60
65
  "model_search_paths" => { kind: :array, default: ["app/models"] },
61
66
  "model_base_classes" => { kind: :array, default: %w[ApplicationRecord ActiveRecord::Base] }
62
67
  },
@@ -76,26 +81,34 @@ module Rigor
76
81
  RELATION_CLASS_NAME = "ActiveRecord::Relation"
77
82
 
78
83
  # Cached: parsed schema table. The producer reads `@schema_file` via `io_boundary.read_file` so the
79
- # descriptor picks up the digest, then parses through {SchemaParser}.
84
+ # descriptor picks up the digest, then parses through {SchemaParser}. When `db/schema.rb` is absent
85
+ # the producer falls back to a PostgreSQL `db/structure.sql` (`schema_format = :sql` apps) parsed
86
+ # through {StructureSqlParser} — both reads are captured into the record-and-validate descriptor.
80
87
  producer :schema_table do |_params|
81
- contents = io_boundary.read_file(@schema_file)
82
- SchemaParser.parse(contents)
88
+ SchemaParser.parse(io_boundary.read_file(@schema_file))
89
+ rescue Errno::ENOENT
90
+ StructureSqlParser.parse(io_boundary.read_file(@structure_sql_file))
83
91
  end
84
92
 
85
93
  # Cached: model index. Walks every model file, then composes the rows with the cached schema table.
86
94
  # `watch:` (ADR-60 WD3) covers model-file additions; the discoverer's in-block reads are captured
87
95
  # into the record-and-validate dependency descriptor after the block runs.
88
96
  producer :model_index, watch: -> { [[@model_search_paths, "**/*.rb"]] } do |_params|
89
- rows = ModelDiscoverer.new(
97
+ discoverer = ModelDiscoverer.new(
90
98
  io_boundary: io_boundary,
91
99
  search_paths: @model_search_paths,
92
100
  base_classes: @model_base_classes
93
- ).discover
94
- ModelIndex.build(model_rows: rows, schema_table: schema_table_or_nil)
101
+ )
102
+ rows = discoverer.discover
103
+ ModelIndex.build(
104
+ model_rows: rows, schema_table: schema_table_or_nil,
105
+ type_override_columns: discoverer.type_override_columns
106
+ )
95
107
  end
96
108
 
97
109
  def init(_services)
98
110
  @schema_file = config.fetch("schema_file")
111
+ @structure_sql_file = config.fetch("structure_sql_file")
99
112
  @model_search_paths = Array(config.fetch("model_search_paths")).map(&:to_s)
100
113
  @model_base_classes = Array(config.fetch("model_base_classes")).map(&:to_s)
101
114
  @schema_table = nil
@@ -518,7 +531,8 @@ module Rigor
518
531
  @load_errors << "rigor-activerecord: #{e.message}"
519
532
  nil
520
533
  rescue Errno::ENOENT
521
- @load_errors << "rigor-activerecord: schema file `#{@schema_file}` not found; AR call checks skipped"
534
+ @load_errors << "rigor-activerecord: schema file `#{@schema_file}` (or `#{@structure_sql_file}`) " \
535
+ "not found; AR call checks skipped"
522
536
  nil
523
537
  rescue StandardError => e
524
538
  @load_errors << "rigor-activerecord: failed to parse `#{@schema_file}`: #{e.class}: #{e.message}"
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rigor/source/node_children"
4
+
3
5
  require "prism"
4
6
 
5
7
  module Rigor
@@ -32,7 +34,7 @@ module Rigor
32
34
  return if node.nil?
33
35
 
34
36
  yield node
35
- node.compact_child_nodes.each { |child| walk(child, &) }
37
+ node.rigor_each_child { |child| walk(child, &) }
36
38
  end
37
39
 
38
40
  def visit_call(node)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rigor/source/node_children"
4
+
3
5
  require "prism"
4
6
  require "rigor/source/literals"
5
7
 
@@ -77,7 +79,7 @@ module Rigor
77
79
  when Prism::ModuleNode
78
80
  visit_module(node, lexical_path, &)
79
81
  else
80
- node.compact_child_nodes.each { |child| walk(child, lexical_path, &) }
82
+ node.rigor_each_child { |child| walk(child, lexical_path, &) }
81
83
  end
82
84
  end
83
85
 
@@ -105,7 +107,7 @@ module Rigor
105
107
  return [] if body.nil?
106
108
 
107
109
  rows = []
108
- body.compact_child_nodes.each do |node|
110
+ body.rigor_each_child do |node|
109
111
  next unless node.is_a?(Prism::CallNode)
110
112
 
111
113
  kind = ATTACHMENT_METHODS[node.name]
@@ -62,6 +62,9 @@ class Object
62
62
  # `acts_like?(:string)` / `acts_like?(:date)` / `acts_like?(:time)`
63
63
  # is ActiveSupport's "duck-typing helper" predicate.
64
64
  def acts_like?: (Symbol | String) -> bool
65
+
66
+ # `core_ext/object/inclusion` — `x.in?([a, b])` / `x.in?(1..10)`.
67
+ def in?: (untyped) -> bool
65
68
  end
66
69
 
67
70
  # `nil.blank?` / `nil.present?` / `nil.try` are the most frequent
@@ -99,6 +102,9 @@ class String
99
102
  def demodulize: () -> String
100
103
  def deconstantize: () -> String
101
104
  def titleize: () -> String
105
+ def titlecase: () -> String
106
+ def dasherize: () -> String
107
+ def upcase_first: () -> String
102
108
  def parameterize: (?separator: String, ?preserve_case: bool, ?locale: Symbol?) -> String
103
109
  def tableize: () -> String
104
110
  def foreign_key: (?bool separate_class_name_and_id_with_underscore) -> String
@@ -112,6 +118,9 @@ class String
112
118
  def truncate: (Integer truncate_at, ?omission: String, ?separator: String | Regexp | nil) -> String
113
119
  def truncate_bytes: (Integer truncate_at, ?omission: String) -> String
114
120
  def truncate_words: (Integer words_count, ?omission: String, ?separator: String | Regexp | nil) -> String
121
+ # `remove` / `remove!` delete every occurrence of the given patterns.
122
+ def remove: (*Regexp | String patterns) -> String
123
+ def remove!: (*Regexp | String patterns) -> String
115
124
 
116
125
  # `core_ext/string/output_safety` — `html_safe` returns an
117
126
  # `ActiveSupport::SafeBuffer` (a String subclass). The closest
@@ -274,6 +283,9 @@ class Time
274
283
  def noon: () -> Time
275
284
  def utc?: () -> bool
276
285
  def acts_like_time?: () -> true
286
+ # `advance(days: 1, months: -2)` and `all_day` (a `beginning_of_day..end_of_day` Range).
287
+ def advance: (untyped options) -> Time
288
+ def all_day: () -> Range[Time]
277
289
  end
278
290
 
279
291
  # ---------------------------------------------------------------
@@ -314,6 +326,11 @@ class Date
314
326
  def at_beginning_of_day: () -> Time
315
327
  def end_of_day: () -> Time
316
328
  def at_end_of_day: () -> Time
329
+ # `advance(days: 1)`, `all_day` (a Time Range), and `to_time(form)` — the
330
+ # `:local` / `:utc` form arg core RBS' `Date#to_time` (arity 0) omits.
331
+ def advance: (untyped options) -> Date
332
+ def all_day: () -> Range[Time]
333
+ def to_time: (?Symbol form) -> Time
317
334
  end
318
335
 
319
336
  # ---------------------------------------------------------------
@@ -476,3 +493,18 @@ end
476
493
  class String
477
494
  def exclude?: (String) -> bool
478
495
  end
496
+
497
+ # ---------------------------------------------------------------
498
+ # ERB::Util — ActionView extends it with `html_escape_once`
499
+ # ---------------------------------------------------------------
500
+
501
+ # `ERB` is a CLASS in Ruby / RBS (not a module), so reopen it as a class —
502
+ # a `module ERB` wrapper collides with upstream `class ERB` once the `erb`
503
+ # stdlib is in scope. `ERB::Util` itself is a module.
504
+ class ERB
505
+ module Util
506
+ # `ERB::Util.html_escape_once(s)` escapes HTML without double-escaping
507
+ # an already-escaped entity. ActionView adds it on top of stdlib ERB.
508
+ def self.html_escape_once: (untyped) -> String
509
+ end
510
+ end
@@ -63,12 +63,28 @@ module Rigor
63
63
  produces: [:dry_type_aliases]
64
64
  )
65
65
 
66
- # Walks every project file once during `prepare(services)` to build the alias table, then publishes
67
- # via the ADR-9 fact store. The walk is bounded by the configured `paths:` surface; each file's
68
- # parse error degrades to "no contribution" without polluting the user-visible diagnostic stream.
66
+ # Cached alias-table producer (ADR-60 WD3 record-and-validate). The scan Prism-parses every `.rb` file
67
+ # under the project's `paths:` tree to find `include Dry.Types()` declarations an expensive pass that
68
+ # ran on EVERY invocation (cold and warm) because `#prepare` called {AliasScanner.scan} directly, with
69
+ # no cache. It now rides `cache_for`: a warm run re-globs + re-digests the watched tree (cheap SHA over
70
+ # file bytes, no AST build) and reuses the cached table instead of re-parsing. This is the dominant win
71
+ # even for a project that ships no dry-types module — the parse still has to run to prove that, and now
72
+ # the empty result is cached too.
73
+ #
74
+ # `watch:` covers exactly the files the scan reads: one glob per project `paths:` entry, co-extensive
75
+ # with {#scannable_paths}. A {Cache::Descriptor::GlobEntry} digests every file matching its glob, so a
76
+ # content edit, a file addition, and a file removal anywhere under those paths all move the digest and
77
+ # invalidate the entry. The scan reads no file individually through the `IoBoundary`, so the evaluated
78
+ # `watch:` globs are the entire dependency descriptor — and they fully cover the scan's input.
79
+ producer :dry_type_aliases, watch: -> { alias_watch_globs } do |_params|
80
+ AliasScanner.scan(paths: scannable_paths)
81
+ end
82
+
83
+ # Builds the (cached) alias table and publishes it via the ADR-9 fact store. `producer_value` runs the
84
+ # producer through `cache_for` and memoises the result — including a rescued nil — for the run.
69
85
  def prepare(services)
70
- aliases = AliasScanner.scan(paths: scannable_paths(services))
71
- return if aliases.empty?
86
+ aliases = producer_value(:dry_type_aliases)
87
+ return if aliases.nil? || aliases.empty?
72
88
 
73
89
  services.fact_store.publish(
74
90
  plugin_id: manifest.id,
@@ -86,7 +102,7 @@ module Rigor
86
102
  # Resolves the project's `paths:` to a flat list of `.rb` files the scanner walks. Mirrors
87
103
  # `Analysis::Runner`'s `expand_paths` floor; we don't need the runner's full exclude/sort surface
88
104
  # because the alias table is a union — any duplicate scan is a no-op.
89
- def scannable_paths(services)
105
+ def scannable_paths
90
106
  @scannable_paths ||= services.configuration.paths.flat_map do |entry|
91
107
  if File.directory?(entry)
92
108
  Dir.glob(File.join(entry, "**", "*.rb"), sort: true)
@@ -97,6 +113,22 @@ module Rigor
97
113
  end
98
114
  end.uniq.freeze
99
115
  end
116
+
117
+ # The `watch:` glob tuples covering the scan's entire input — one `[root, pattern]` per project `paths:`
118
+ # entry. A directory entry watches its whole `**/*.rb` subtree; a single-file `.rb` entry watches
119
+ # exactly that file (`[dir, basename]`). Roots are expanded to absolute paths by
120
+ # `Plugin::Base#watch_glob_entries`, so a relative CLI path (`app/models`) re-validates against the same
121
+ # tree from the project root on the next run. Co-extensive with {#scannable_paths}: every `.rb` the scan
122
+ # would read is watched, so any edit / addition / removal under those paths invalidates the cache.
123
+ def alias_watch_globs
124
+ services.configuration.paths.filter_map do |entry|
125
+ if File.directory?(entry)
126
+ [entry, "**/*.rb"]
127
+ elsif File.file?(entry) && entry.end_with?(".rb")
128
+ [File.dirname(entry), File.basename(entry)]
129
+ end
130
+ end
131
+ end
100
132
  end
101
133
 
102
134
  Rigor::Plugin.register(DryTypes)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rigor/source/node_children"
4
+
3
5
  require "prism"
4
6
  require "rigor/source/literals"
5
7
 
@@ -86,7 +88,7 @@ module Rigor
86
88
  visit_factory(node, &)
87
89
  return
88
90
  end
89
- node.compact_child_nodes.each { |child| walk_for_factories(child, &) }
91
+ node.rigor_each_child { |child| walk_for_factories(child, &) }
90
92
  end
91
93
 
92
94
  def factory_call?(node)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rigor/source/node_children"
4
+
3
5
  require "prism"
4
6
 
5
7
  module Rigor
@@ -65,7 +67,7 @@ module Rigor
65
67
  case node
66
68
  when Prism::DefNode then defs << node
67
69
  when Prism::ClassNode, Prism::ModuleNode then nil # nested scopes own their own defs
68
- else node.compact_child_nodes.each { |child| collect_direct_defs(child, defs) }
70
+ else node.rigor_each_child { |child| collect_direct_defs(child, defs) }
69
71
  end
70
72
  end
71
73