rigortype 0.1.15 → 0.1.16

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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/exe/rigor +19 -0
  4. data/lib/rigor/analysis/check_rules.rb +25 -1
  5. data/lib/rigor/analysis/diagnostic.rb +40 -0
  6. data/lib/rigor/analysis/runner.rb +61 -2
  7. data/lib/rigor/analysis/worker_session.rb +3 -2
  8. data/lib/rigor/cache/descriptor.rb +6 -2
  9. data/lib/rigor/cli/plugins_command.rb +51 -4
  10. data/lib/rigor/cli/plugins_renderer.rb +86 -1
  11. data/lib/rigor/cli.rb +135 -5
  12. data/lib/rigor/environment/rbs_loader.rb +259 -1
  13. data/lib/rigor/environment.rb +8 -2
  14. data/lib/rigor/inference/budget_trace.rb +137 -0
  15. data/lib/rigor/inference/expression_typer.rb +9 -2
  16. data/lib/rigor/inference/hkt_reducer.rb +2 -0
  17. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -6
  18. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +81 -14
  19. data/lib/rigor/inference/method_dispatcher.rb +57 -10
  20. data/lib/rigor/inference/precision_scanner.rb +60 -1
  21. data/lib/rigor/inference/scope_indexer.rb +127 -8
  22. data/lib/rigor/inference/statement_evaluator.rb +13 -8
  23. data/lib/rigor/inference/synthetic_method_index.rb +23 -4
  24. data/lib/rigor/inference/synthetic_method_scanner.rb +148 -14
  25. data/lib/rigor/plugin/additional_initializer.rb +108 -0
  26. data/lib/rigor/plugin/base.rb +321 -2
  27. data/lib/rigor/plugin/box.rb +64 -0
  28. data/lib/rigor/plugin/inflector.rb +121 -0
  29. data/lib/rigor/plugin/isolation.rb +191 -0
  30. data/lib/rigor/plugin/macro/nested_class_template.rb +140 -0
  31. data/lib/rigor/plugin/macro.rb +1 -0
  32. data/lib/rigor/plugin/manifest.rb +120 -23
  33. data/lib/rigor/plugin/node_context.rb +62 -0
  34. data/lib/rigor/plugin/registry.rb +10 -0
  35. data/lib/rigor/plugin.rb +3 -0
  36. data/lib/rigor/sig_gen/generator.rb +2 -3
  37. data/lib/rigor/sig_gen/observation_collector.rb +2 -2
  38. data/lib/rigor/source/literals.rb +118 -0
  39. data/lib/rigor/source/node_walker.rb +26 -0
  40. data/lib/rigor/source.rb +1 -0
  41. data/lib/rigor/type/combinator.rb +6 -1
  42. data/lib/rigor/type/union.rb +65 -1
  43. data/lib/rigor/version.rb +1 -1
  44. data/lib/rigor.rb +1 -0
  45. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +31 -53
  46. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +21 -23
  47. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +38 -59
  48. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +7 -13
  49. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +22 -33
  50. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +298 -413
  51. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +69 -71
  52. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +24 -34
  53. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +18 -16
  54. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +4 -46
  55. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  56. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +1 -1
  57. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +17 -12
  58. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -8
  59. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +2 -7
  60. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +2 -6
  61. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +4 -3
  62. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  63. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +40 -45
  64. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +7 -17
  65. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +20 -42
  66. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +7 -4
  67. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +4 -8
  68. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +188 -0
  69. data/plugins/rigor-mangrove/lib/rigor-mangrove.rb +3 -0
  70. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +4 -0
  71. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +24 -8
  72. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +31 -48
  73. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +21 -23
  74. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +54 -82
  75. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +25 -25
  76. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +63 -147
  77. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -17
  78. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +23 -114
  79. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +36 -31
  80. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  81. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +6 -3
  82. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +4 -2
  83. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +13 -12
  84. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +28 -40
  85. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +44 -47
  86. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +11 -10
  87. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +45 -87
  88. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +11 -12
  89. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +29 -42
  90. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +20 -19
  91. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +73 -0
  92. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +43 -1
  93. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +21 -29
  94. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +36 -96
  95. data/sig/rigor/plugin/access_denied_error.rbs +3 -1
  96. data/sig/rigor/plugin/base.rbs +58 -3
  97. data/sig/rigor/plugin/io_boundary.rbs +3 -0
  98. data/sig/rigor/plugin/manifest.rbs +31 -1
  99. data/sig/rigor/source.rbs +12 -0
  100. data/sig/rigor.rbs +5 -0
  101. data/skills/rigor-plugin-author/SKILL.md +13 -9
  102. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +6 -5
  103. data/skills/rigor-plugin-author/references/02-walker-and-types.md +159 -75
  104. data/skills/rigor-plugin-author/references/03-test-and-ship.md +3 -3
  105. metadata +52 -2
  106. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +0 -114
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "did_you_mean"
4
4
  require "prism"
5
+ require "rigor/source/literals"
5
6
 
6
7
  module Rigor
7
8
  module Plugin
@@ -20,35 +21,34 @@ module Rigor
20
21
  module Analyzer
21
22
  ENTRY_METHODS = %i[create build build_stubbed attributes_for create_list build_list build_stubbed_list].freeze
22
23
 
23
- Diagnostic = Data.define(:path, :line, :column, :message, :severity, :rule)
24
+ # One violation. Carries its own `location` because a single
25
+ # entry call yields diagnostics at different positions — the
26
+ # matcher/method name (`message_loc`) for factory-call /
27
+ # unknown-factory, and the attribute key node for
28
+ # unknown-attribute. The caller positions each via
29
+ # `Plugin::Base#diagnostic(node, location:)`.
30
+ Violation = Data.define(:location, :message, :severity, :rule)
24
31
 
25
32
  module_function
26
33
 
27
- def diagnose(path:, root:, factory_index:, model_index: nil)
28
- diagnostics = []
29
- spell_checker = DidYouMean::SpellChecker.new(dictionary: factory_index.names)
30
-
31
- walk_entry_calls(root) do |call_node|
32
- factory_name = first_positional_symbol_or_string(call_node)
33
- next if factory_name.nil?
34
-
35
- entry = factory_index.find(factory_name)
36
- diagnostics.concat(
37
- diagnostics_for_call(path, call_node, factory_name, entry, spell_checker, model_index)
38
- )
39
- end
40
-
41
- diagnostics
42
- end
34
+ # The violations for a single entry call (`FactoryBot.create(...)`
35
+ # etc.), or `[]` when the node is not an entry call or its first
36
+ # argument is not a literal factory name. ADR-37: the engine owns
37
+ # the walk.
38
+ #
39
+ # @param call_node [Prism::Node]
40
+ # @param factory_index [FactoryIndex]
41
+ # @param model_index [Hash, nil]
42
+ # @return [Array<Violation>]
43
+ def violations_for(call_node:, factory_index:, model_index: nil)
44
+ return [] unless entry_call?(call_node)
43
45
 
44
- # Walk the AST yielding only call nodes whose receiver
45
- # is `FactoryBot` (or the `FactoryGirl` legacy alias)
46
- # and whose method name is in {ENTRY_METHODS}.
47
- def walk_entry_calls(node, &)
48
- return unless node.is_a?(Prism::Node)
46
+ factory_name = first_positional_symbol_or_string(call_node)
47
+ return [] if factory_name.nil?
49
48
 
50
- yield node if entry_call?(node)
51
- node.compact_child_nodes.each { |child| walk_entry_calls(child, &) }
49
+ spell_checker = DidYouMean::SpellChecker.new(dictionary: factory_index.names)
50
+ entry = factory_index.find(factory_name)
51
+ violations_for_call(call_node, factory_name, entry, spell_checker, model_index)
52
52
  end
53
53
 
54
54
  def entry_call?(node)
@@ -70,18 +70,14 @@ module Rigor
70
70
  end
71
71
 
72
72
  def first_positional_symbol_or_string(call_node)
73
- first_arg = call_node.arguments&.arguments&.first
74
- case first_arg
75
- when Prism::SymbolNode then first_arg.value
76
- when Prism::StringNode then first_arg.unescaped
77
- end
73
+ Rigor::Source::Literals.symbol_or_string_name(call_node.arguments&.arguments&.first)
78
74
  end
79
75
 
80
- def diagnostics_for_call(path, call_node, factory_name, entry, spell_checker, model_index)
81
- return [unknown_factory_diagnostic(path, call_node, factory_name, spell_checker)] if entry.nil?
76
+ def violations_for_call(call_node, factory_name, entry, spell_checker, model_index)
77
+ return [unknown_factory_violation(call_node, factory_name, spell_checker)] if entry.nil?
82
78
 
83
- unknown_attribute_diagnostics(path, call_node, entry, model_index) +
84
- [factory_call_diagnostic(path, call_node, factory_name, entry)]
79
+ unknown_attribute_violations(call_node, entry, model_index) +
80
+ [factory_call_violation(call_node, factory_name, entry)]
85
81
  end
86
82
 
87
83
  # The keyword-argument attribute keys come from the
@@ -98,7 +94,7 @@ module Rigor
98
94
  # accepts any AR attribute regardless of whether the
99
95
  # factory declared it, so the cross-check broadens the
100
96
  # acceptance accordingly.
101
- def unknown_attribute_diagnostics(path, call_node, entry, model_index)
97
+ def unknown_attribute_violations(call_node, entry, model_index)
102
98
  accepted_keys, suggestion_dictionary = effective_keys(entry, model_index)
103
99
  attr_spell_checker = DidYouMean::SpellChecker.new(dictionary: suggestion_dictionary)
104
100
  attribute_assoc_nodes(call_node).filter_map do |assoc|
@@ -107,7 +103,7 @@ module Rigor
107
103
  attr_name = assoc.key.value
108
104
  next if accepted_keys.include?(attr_name)
109
105
 
110
- unknown_attribute_diagnostic(path, assoc, entry, attr_name, attr_spell_checker)
106
+ unknown_attribute_violation(assoc, entry, attr_name, attr_spell_checker)
111
107
  end
112
108
  end
113
109
 
@@ -139,35 +135,34 @@ module Rigor
139
135
  last.elements.grep(Prism::AssocNode)
140
136
  end
141
137
 
142
- def factory_call_diagnostic(path, call_node, factory_name, entry)
138
+ def factory_call_violation(call_node, factory_name, entry)
143
139
  loc = call_node.message_loc || call_node.location
144
140
  attrs = entry.attribute_names.empty? ? "(no attributes)" : entry.attribute_names.join(", ")
145
- Diagnostic.new(
146
- path: path, line: loc.start_line, column: loc.start_column + 1,
141
+ Violation.new(
142
+ location: loc,
147
143
  message: "FactoryBot.#{call_node.name}(:#{factory_name}) — declared attributes: #{attrs}.",
148
144
  severity: :info, rule: "factory-call"
149
145
  )
150
146
  end
151
147
 
152
- def unknown_factory_diagnostic(path, call_node, factory_name, spell_checker)
148
+ def unknown_factory_violation(call_node, factory_name, spell_checker)
153
149
  loc = call_node.message_loc || call_node.location
154
150
  base = "FactoryBot.#{call_node.name}(:#{factory_name}) — factory not declared in any " \
155
151
  "factory_search_paths file."
156
152
  suggestion = spell_checker.correct(factory_name).first
157
153
  message = suggestion ? "#{base} Did you mean `:#{suggestion}`?" : base
158
- Diagnostic.new(
159
- path: path, line: loc.start_line, column: loc.start_column + 1,
154
+ Violation.new(
155
+ location: loc,
160
156
  message: message, severity: :error, rule: "unknown-factory"
161
157
  )
162
158
  end
163
159
 
164
- def unknown_attribute_diagnostic(path, assoc, entry, attr_name, spell_checker)
165
- loc = assoc.key.location
160
+ def unknown_attribute_violation(assoc, entry, attr_name, spell_checker)
166
161
  base = "FactoryBot factory `:#{entry.name}` has no declared attribute `:#{attr_name}`."
167
162
  suggestion = spell_checker.correct(attr_name).first
168
163
  message = suggestion ? "#{base} Did you mean `:#{suggestion}`?" : base
169
- Diagnostic.new(
170
- path: path, line: loc.start_line, column: loc.start_column + 1,
164
+ Violation.new(
165
+ location: assoc.key.location,
171
166
  message: message, severity: :error, rule: "unknown-attribute"
172
167
  )
173
168
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "prism"
4
+ require "rigor/source/literals"
4
5
 
5
6
  require_relative "factory_index"
6
7
 
@@ -112,11 +113,7 @@ module Rigor
112
113
  end
113
114
 
114
115
  def literal_name_arg(call_node)
115
- first_arg = call_node.arguments&.arguments&.first
116
- case first_arg
117
- when Prism::SymbolNode then first_arg.value
118
- when Prism::StringNode then first_arg.unescaped
119
- end
116
+ Rigor::Source::Literals.symbol_or_string_name(call_node.arguments&.arguments&.first)
120
117
  end
121
118
 
122
119
  # Pillar 2 Slice 3 — resolve the model class name for
@@ -136,7 +133,11 @@ module Rigor
136
133
  explicit = explicit_class_option(call_node)
137
134
  return explicit if explicit
138
135
 
139
- camelize(factory_name)
136
+ # ADR-39: the real ActiveSupport::Inflector camelizes the factory
137
+ # name to its class (`admin_user` → `AdminUser`,
138
+ # `admin/user` → `Admin::User`), so the model-class fallback
139
+ # matches Rails' real convention rather than an approximation.
140
+ Rigor::Plugin::Inflector.camelize(factory_name)
140
141
  end
141
142
 
142
143
  def explicit_class_option(call_node)
@@ -180,17 +181,6 @@ module Rigor
180
181
  end
181
182
  end
182
183
 
183
- # Pure-Ruby camelize for the factory-name fallback.
184
- # `user` → `User`, `blog_post` → `BlogPost`, `admin_user`
185
- # → `AdminUser`. Factory names with `/` separators
186
- # (`admin/user`) camelize per-segment and join with `::`
187
- # (`Admin::User`), mirroring Rails inflection.
188
- def camelize(snake)
189
- snake.to_s.split("/").map do |segment|
190
- segment.split("_").map { |part| part.empty? ? part : part[0].upcase + part[1..] }.join
191
- end.join("::")
192
- end
193
-
194
184
  def collect_attribute_names(block_node)
195
185
  return [] unless block_node.is_a?(Prism::BlockNode)
196
186
 
@@ -80,18 +80,13 @@ module Rigor
80
80
  "publishes per-factory attribute set + inferred model class as the " \
81
81
  ":factory_index ADR-9 fact (Pillar 2 Slice 3).",
82
82
  config_schema: {
83
- "factory_search_paths" => :array
83
+ "factory_search_paths" => { kind: :array, default: ["spec/factories", "spec/factories.rb"] }
84
84
  },
85
85
  consumes: [
86
86
  { plugin_id: "activerecord", name: :model_index, optional: true }
87
87
  ]
88
88
  )
89
89
 
90
- DEFAULT_FACTORY_SEARCH_PATHS = [
91
- "spec/factories",
92
- "spec/factories.rb"
93
- ].freeze
94
-
95
90
  producer :factory_index do |_params|
96
91
  FactoryDiscoverer.new(
97
92
  io_boundary: io_boundary,
@@ -101,22 +96,29 @@ module Rigor
101
96
 
102
97
  def init(services)
103
98
  @services = services
104
- @factory_search_paths = Array(
105
- config.fetch("factory_search_paths", DEFAULT_FACTORY_SEARCH_PATHS)
106
- ).map(&:to_s)
99
+ @factory_search_paths = Array(config.fetch("factory_search_paths")).map(&:to_s)
107
100
  @factory_index = nil
108
101
  @model_index = nil
109
102
  @model_index_resolved = false
110
103
  end
111
104
 
112
- def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
105
+ # ADR-37 per-call factory/attribute validation over the
106
+ # engine-owned walk. Each violation carries its own location
107
+ # (the call's message_loc, or the offending attribute key), so it
108
+ # is positioned via `diagnostic(node, location:)`. No file-level
109
+ # diagnostic remains, so there is no `diagnostics_for_file`.
110
+ node_rule Prism::CallNode do |node, _scope, path|
113
111
  index = factory_index_or_nil
114
- return [] if index.nil? || index.empty?
115
-
116
- Analyzer.diagnose(
117
- path: path, root: root,
118
- factory_index: index, model_index: model_index_or_nil
119
- ).map { |diag| build_diagnostic(diag) }
112
+ next [] if index.nil? || index.empty?
113
+
114
+ Analyzer.violations_for(
115
+ call_node: node, factory_index: index, model_index: model_index_or_nil
116
+ ).map do |violation|
117
+ diagnostic(
118
+ node, path: path, location: violation.location,
119
+ message: violation.message, severity: violation.severity, rule: violation.rule
120
+ )
121
+ end
120
122
  end
121
123
 
122
124
  private
@@ -138,35 +140,11 @@ module Rigor
138
140
  def factory_index_or_nil
139
141
  return @factory_index if @factory_index
140
142
 
141
- prime_io_boundary_for_index
142
- @factory_index = cache_for(:factory_index, params: {}).call
143
+ descriptor = glob_descriptor(@factory_search_paths, "**/*.rb")
144
+ @factory_index = cache_for(:factory_index, params: {}, descriptor: descriptor).call
143
145
  rescue StandardError
144
146
  nil
145
147
  end
146
-
147
- def prime_io_boundary_for_index
148
- @factory_search_paths.each do |root|
149
- absolute = File.expand_path(root)
150
- if File.file?(absolute)
151
- safely_read(absolute)
152
- elsif File.directory?(absolute)
153
- Dir.glob(File.join(absolute, "**", "*.rb")).each { |p| safely_read(p) }
154
- end
155
- end
156
- end
157
-
158
- def safely_read(path)
159
- io_boundary.read_file(path)
160
- rescue Plugin::AccessDeniedError, Errno::ENOENT
161
- nil
162
- end
163
-
164
- def build_diagnostic(diag)
165
- Rigor::Analysis::Diagnostic.new(
166
- path: diag.path, line: diag.line, column: diag.column,
167
- message: diag.message, severity: diag.severity, rule: diag.rule
168
- )
169
- end
170
148
  end
171
149
 
172
150
  Rigor::Plugin.register(Factorybot)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "prism"
4
+ require "rigor/source/literals"
4
5
 
5
6
  module Rigor
6
7
  module Plugin
@@ -255,13 +256,14 @@ module Rigor
255
256
 
256
257
  name_node = args[0]
257
258
  type_node = args[1]
258
- return nil unless name_node.is_a?(Prism::SymbolNode)
259
+ name = Rigor::Source::Literals.symbol_name(name_node)
260
+ return nil if name.nil?
259
261
 
260
262
  type_info = resolve_field_type(type_node)
261
263
  return nil if type_info.nil?
262
264
 
263
265
  {
264
- name: name_node.unescaped,
266
+ name: name,
265
267
  type: type_info[:type],
266
268
  list: type_info[:list],
267
269
  required: extract_required_flag(args)
@@ -304,13 +306,14 @@ module Rigor
304
306
 
305
307
  name_node = args[0]
306
308
  type_node = args[1]
307
- return nil unless name_node.is_a?(Prism::SymbolNode)
309
+ name = Rigor::Source::Literals.symbol_name(name_node)
310
+ return nil if name.nil?
308
311
 
309
312
  type_info = resolve_field_type(type_node)
310
313
  return nil if type_info.nil?
311
314
 
312
315
  {
313
- name: name_node.unescaped,
316
+ name: name,
314
317
  type: type_info[:type],
315
318
  list: type_info[:list],
316
319
  nullable: extract_nullability(args)
@@ -82,11 +82,9 @@ module Rigor
82
82
  req_count = def_node.parameters ? def_node.parameters.requireds.size : 0
83
83
  return nil if req_count == 2
84
84
 
85
- location = def_node.location
86
- Rigor::Analysis::Diagnostic.new(
85
+ Rigor::Analysis::Diagnostic.from_location(
86
+ def_node.location,
87
87
  path: path,
88
- line: location.start_line,
89
- column: location.start_column + 1,
90
88
  message: "`#{class_name(class_node)}#handle` must accept exactly 2 parameters " \
91
89
  "(request, response), got #{req_count}",
92
90
  severity: contract.severity,
@@ -95,11 +93,9 @@ module Rigor
95
93
  end
96
94
 
97
95
  def missing_handle_diagnostic(contract, path, class_node)
98
- location = (class_node.constant_path || class_node).location
99
- Rigor::Analysis::Diagnostic.new(
96
+ Rigor::Analysis::Diagnostic.from_location(
97
+ (class_node.constant_path || class_node).location,
100
98
  path: path,
101
- line: location.start_line,
102
- column: location.start_column + 1,
103
99
  message: "`#{class_name(class_node)}` must define `#handle(request, response)` — " \
104
100
  "required of every Hanami action under `#{contract.path_glob}`",
105
101
  severity: contract.severity,
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require "rigor/plugin"
5
+
6
+ module Rigor
7
+ module Plugin
8
+ # rigor-mangrove — precision plugin for the
9
+ # [Mangrove](https://github.com/kazzix14/mangrove) functional
10
+ # toolkit (Result / Option carriers).
11
+ #
12
+ # ## Why this plugin exists (and what it deliberately is NOT)
13
+ #
14
+ # Mangrove is a Sorbet-first library: every carrier method is
15
+ # annotated with an inline `sig { ... }`, and the Enum DSL's
16
+ # dynamic variants are materialised by a bundled Tapioca DSL
17
+ # compiler. So the *type source* for Mangrove is already
18
+ # covered by `rigor-sorbet` (sig ingestion + RBI walking) —
19
+ # this plugin is NOT a parallel type source. See
20
+ # `docs/notes/20260530-mangrove-library-survey.md`.
21
+ #
22
+ # What sig-ingestion (Sorbet-level precision) structurally
23
+ # cannot do is **instantiate the carrier's generic
24
+ # type-member at the unwrap call site**. Mangrove's `Result`
25
+ # interface declares:
26
+ #
27
+ # sig { abstract.returns(OkType) }
28
+ # def unwrap!; end
29
+ #
30
+ # `OkType` is a `type_member(:out)`. When the receiver's
31
+ # static type is `Mangrove::Result[String, StandardError]`,
32
+ # `unwrap!` *should* yield `String` — but resolving the
33
+ # abstract `OkType` to the receiver's first type argument is
34
+ # generic instantiation, which neither `rigor-sorbet` (it
35
+ # contributes the cataloged sig's return type verbatim) nor a
36
+ # bare-`def unwrap!: () -> untyped` RBS stub performs. The
37
+ # call degrades to `Dynamic[top]`.
38
+ #
39
+ # This plugin closes exactly that gap. At every recognised
40
+ # unwrap-family call on a known Mangrove carrier, it reads the
41
+ # receiver's `Rigor::Type::Nominal#type_args` and contributes
42
+ # the first argument (the `OkType` / `InnerType`) as the
43
+ # call's return type. The receiver type is already known, so
44
+ # this only ever *sharpens* an existing `Dynamic[top]` into a
45
+ # concrete type — it never invents a receiver type and never
46
+ # emits a diagnostic of its own, so it cannot frighten working
47
+ # code (Rigor's false-positive discipline).
48
+ #
49
+ # ## Scope (slice 1)
50
+ #
51
+ # - Result unwrap family → `OkType` (`type_args[0]`):
52
+ # `unwrap!`, `unwrap_in`, `expect!`, `expect_with!`,
53
+ # `unwrap_or_raise!`, `unwrap_or_raise_with!`,
54
+ # `unwrap_or_raise_inner!`.
55
+ # - Option unwrap family → `InnerType` (`type_args[0]`):
56
+ # `unwrap`, `unwrap!`, `unwrap_or`, `expect!`,
57
+ # `expect_with!`.
58
+ #
59
+ # The contribution fires only when the receiver resolves to a
60
+ # carrier Nominal carrying a non-empty `type_args` — i.e. when
61
+ # the Result/Option came from a method whose declared return
62
+ # type is an applied generic (`-> Result[String, E]`), the
63
+ # realistic Mangrove shape. The bare-constructor shape
64
+ # (`Result::Ok.new("x")`) currently yields a raw Nominal with
65
+ # no `type_args` (the engine does not infer generics from
66
+ # constructor arguments), so the plugin no-ops there rather
67
+ # than guessing — a conservative floor, not a regression.
68
+ #
69
+ # ## Out of scope (tracked elsewhere)
70
+ #
71
+ # - `is_a?(Result::Ok)` / `Some` / `None` exhaustive
72
+ # narrowing — core control-flow analysis over a sealed
73
+ # hierarchy, not a plugin surface.
74
+ # - The `variants do variant Const, Type end` Enum DSL — needs
75
+ # an ADR-16 nested-class emission tier (ADR-36). Today's
76
+ # contract has no `const_set`-emitting macro substrate.
77
+ class Mangrove < Rigor::Plugin::Base
78
+ manifest(
79
+ id: "mangrove",
80
+ version: "0.2.0",
81
+ description: "Instantiates Mangrove Result/Option carrier generics at unwrap call sites, " \
82
+ "sharpening `unwrap!` / `unwrap_in` / `unwrap_or` from `untyped` to the carried type; " \
83
+ "synthesises `Enum` variant subclasses from the `variants do … end` DSL (ADR-36).",
84
+ # ADR-36 nested-class emission — Mangrove's `Enum`:
85
+ #
86
+ # class Shape
87
+ # extend Mangrove::Enum
88
+ # variants do
89
+ # variant Circle, Float
90
+ # end
91
+ # end
92
+ #
93
+ # mints `Shape::Circle < Shape` with `#inner : Float`. The
94
+ # substrate resolves the variant constant + its `#inner`
95
+ # reader statically so `Shape::Circle.new(1.0).inner`
96
+ # types as `Float` without running Mangrove's `const_missing`.
97
+ nested_class_templates: [
98
+ Rigor::Plugin::Macro::NestedClassTemplate.new(
99
+ receiver_constraint: "Mangrove::Enum",
100
+ block_method: :variants,
101
+ variant_method: :variant,
102
+ name_arg_position: 0,
103
+ inner_arg_position: 1,
104
+ inner_reader: :inner
105
+ )
106
+ ]
107
+ )
108
+
109
+ # Carrier class names whose FIRST type argument is the
110
+ # value the unwrap family yields (`OkType` for Result,
111
+ # `InnerType` for Option). Matched with and without a
112
+ # leading `::` because `scope.type_of` reports the lexical
113
+ # form while user code may root the constant.
114
+ RESULT_CARRIERS = [
115
+ "Mangrove::Result",
116
+ "Mangrove::Result::Ok",
117
+ "Mangrove::Result::Err"
118
+ ].freeze
119
+
120
+ OPTION_CARRIERS = [
121
+ "Mangrove::Option",
122
+ "Mangrove::Option::Some",
123
+ "Mangrove::Option::None"
124
+ ].freeze
125
+
126
+ # Methods on the Result carriers that return `OkType`
127
+ # (`type_args[0]`). All of these are documented as yielding
128
+ # the success value, raising / short-circuiting on `Err`.
129
+ RESULT_UNWRAP_METHODS = %i[
130
+ unwrap! unwrap_in expect! expect_with!
131
+ unwrap_or_raise! unwrap_or_raise_with! unwrap_or_raise_inner!
132
+ ].freeze
133
+
134
+ # Methods on the Option carriers that return `InnerType`
135
+ # (`type_args[0]`).
136
+ OPTION_UNWRAP_METHODS = %i[unwrap unwrap! unwrap_or expect! expect_with!].freeze
137
+
138
+ # ADR-37 slice 2 — a pure return-type contributor. The engine
139
+ # gates on the receiver being a known carrier class (so the block
140
+ # only runs on Result/Option receivers); the block then confirms
141
+ # the call is an unwrap method on an instantiated carrier and
142
+ # yields the carried `type_args[0]`.
143
+ dynamic_return receivers: (RESULT_CARRIERS + OPTION_CARRIERS) do |call_node, scope|
144
+ receiver = call_node.receiver
145
+ next nil if receiver.nil?
146
+
147
+ receiver_type = receiver_type_of(receiver, scope)
148
+ next nil unless receiver_type.is_a?(Rigor::Type::Nominal)
149
+
150
+ carried_type(receiver_type, call_node.name)
151
+ end
152
+
153
+ private
154
+
155
+ # @return [Rigor::Type, nil] the receiver's inferred type, or
156
+ # nil when the engine raises on a synthetic / unrecognised
157
+ # node (mirrors rigor-sorbet's defensive degrade).
158
+ def receiver_type_of(receiver, scope)
159
+ scope.type_of(receiver)
160
+ rescue StandardError
161
+ nil
162
+ end
163
+
164
+ # The value the unwrap family yields for this receiver, or
165
+ # nil when the call is not an unwrap on a known carrier, or
166
+ # the carrier is raw (no `type_args` to instantiate).
167
+ def carried_type(receiver_type, method_name)
168
+ class_name = normalize(receiver_type.class_name)
169
+ type_args = receiver_type.type_args
170
+ return nil if type_args.empty?
171
+
172
+ if RESULT_CARRIERS.include?(class_name)
173
+ return type_args.first if RESULT_UNWRAP_METHODS.include?(method_name)
174
+ elsif OPTION_CARRIERS.include?(class_name)
175
+ return type_args.first if OPTION_UNWRAP_METHODS.include?(method_name)
176
+ end
177
+
178
+ nil
179
+ end
180
+
181
+ def normalize(class_name)
182
+ class_name.start_with?("::") ? class_name.delete_prefix("::") : class_name
183
+ end
184
+ end
185
+
186
+ Rigor::Plugin.register(Mangrove)
187
+ end
188
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rigor/plugin/mangrove"
@@ -144,6 +144,10 @@ module Rigor
144
144
  }.freeze
145
145
  private_constant :SPEC_MATCHER_FORM
146
146
 
147
+ # ADR-37 slice 2 — the method names this analyzer narrows on,
148
+ # for the plugin's `type_specifier methods:` gate.
149
+ SUPPORTED_METHODS = (ASSERT_FORM.keys + SPEC_MATCHER_FORM.keys).freeze
150
+
147
151
  def spec_form_fact(call_node, environment:)
148
152
  shape_negative = SPEC_MATCHER_FORM[call_node.name]
149
153
  return nil if shape_negative.nil?
@@ -54,16 +54,32 @@ module Rigor
54
54
  id: "minitest",
55
55
  version: "0.1.0",
56
56
  description: "Narrows locals through Minitest / Test::Unit `assert_*` / `refute_*` " \
57
- "and Minitest/spec `_(x).must_*` / `.wont_*` matchers."
57
+ "and Minitest/spec `_(x).must_*` / `.wont_*` matchers.",
58
+ # ADR-38 — `def setup` runs before every `def test_*`, so
59
+ # ivars it assigns are initialised by the time a test body
60
+ # reads them. Declaring it an additional initializer stops
61
+ # the read-before-write nil widening that would otherwise
62
+ # type `@conn` (set in `setup`, read in a test) as
63
+ # `T | nil` and surface a false nil-receiver diagnostic.
64
+ additional_initializers: [
65
+ Rigor::Plugin::AdditionalInitializer.new(
66
+ receiver_constraint: "Minitest::Test", methods: [:setup]
67
+ ),
68
+ Rigor::Plugin::AdditionalInitializer.new(
69
+ receiver_constraint: "ActiveSupport::TestCase", methods: [:setup]
70
+ ),
71
+ Rigor::Plugin::AdditionalInitializer.new(
72
+ receiver_constraint: "Test::Unit::TestCase", methods: [:setup]
73
+ )
74
+ ]
58
75
  )
59
76
 
60
- # Pillar 2 Slice 1 (rigor-minitest sibling) emits
61
- # `post_return_facts` for every recognised assertion. The
62
- # engine routes `:local`-kind facts through
63
- # `StatementEvaluator#apply_local_post_return_fact` (added
64
- # in v0.1.8 for the rigor-rspec slice).
65
- def flow_contribution_for(call_node:, scope:)
66
- AssertionAnalyzer.contribution_for(call_node, environment: scope&.environment)
77
+ # ADR-37 slice 2 emits `post_return_facts` for every recognised
78
+ # assertion, method-gated by the engine. The engine routes
79
+ # `:local`-kind facts through
80
+ # `StatementEvaluator#apply_local_post_return_fact`.
81
+ type_specifier methods: AssertionAnalyzer::SUPPORTED_METHODS do |call_node, scope|
82
+ AssertionAnalyzer.contribution_for(call_node, environment: scope&.environment)&.post_return_facts
67
83
  end
68
84
  end
69
85