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.
- checksums.yaml +4 -4
- data/README.md +4 -2
- data/exe/rigor +19 -0
- data/lib/rigor/analysis/check_rules.rb +25 -1
- data/lib/rigor/analysis/diagnostic.rb +40 -0
- data/lib/rigor/analysis/runner.rb +61 -2
- data/lib/rigor/analysis/worker_session.rb +3 -2
- data/lib/rigor/cache/descriptor.rb +6 -2
- data/lib/rigor/cli/plugins_command.rb +51 -4
- data/lib/rigor/cli/plugins_renderer.rb +86 -1
- data/lib/rigor/cli.rb +135 -5
- data/lib/rigor/environment/rbs_loader.rb +259 -1
- data/lib/rigor/environment.rb +8 -2
- data/lib/rigor/inference/budget_trace.rb +137 -0
- data/lib/rigor/inference/expression_typer.rb +9 -2
- data/lib/rigor/inference/hkt_reducer.rb +2 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -6
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +81 -14
- data/lib/rigor/inference/method_dispatcher.rb +57 -10
- data/lib/rigor/inference/precision_scanner.rb +60 -1
- data/lib/rigor/inference/scope_indexer.rb +127 -8
- data/lib/rigor/inference/statement_evaluator.rb +13 -8
- data/lib/rigor/inference/synthetic_method_index.rb +23 -4
- data/lib/rigor/inference/synthetic_method_scanner.rb +148 -14
- data/lib/rigor/plugin/additional_initializer.rb +108 -0
- data/lib/rigor/plugin/base.rb +321 -2
- data/lib/rigor/plugin/box.rb +64 -0
- data/lib/rigor/plugin/inflector.rb +121 -0
- data/lib/rigor/plugin/isolation.rb +191 -0
- data/lib/rigor/plugin/macro/nested_class_template.rb +140 -0
- data/lib/rigor/plugin/macro.rb +1 -0
- data/lib/rigor/plugin/manifest.rb +120 -23
- data/lib/rigor/plugin/node_context.rb +62 -0
- data/lib/rigor/plugin/registry.rb +10 -0
- data/lib/rigor/plugin.rb +3 -0
- data/lib/rigor/sig_gen/generator.rb +2 -3
- data/lib/rigor/sig_gen/observation_collector.rb +2 -2
- data/lib/rigor/source/literals.rb +118 -0
- data/lib/rigor/source/node_walker.rb +26 -0
- data/lib/rigor/source.rb +1 -0
- data/lib/rigor/type/combinator.rb +6 -1
- data/lib/rigor/type/union.rb +65 -1
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +1 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +31 -53
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +21 -23
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +38 -59
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +7 -13
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +22 -33
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +298 -413
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +69 -71
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +24 -34
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +18 -16
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +4 -46
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +1 -1
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +17 -12
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -8
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +2 -7
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +2 -6
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +4 -3
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +40 -45
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +7 -17
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +20 -42
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +7 -4
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +4 -8
- data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +188 -0
- data/plugins/rigor-mangrove/lib/rigor-mangrove.rb +3 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +4 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +24 -8
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +31 -48
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +21 -23
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +54 -82
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +25 -25
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +63 -147
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -17
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +23 -114
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +36 -31
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +6 -3
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +4 -2
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +13 -12
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +28 -40
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +44 -47
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +11 -10
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +45 -87
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +11 -12
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +29 -42
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +20 -19
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +73 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +43 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +21 -29
- data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +36 -96
- data/sig/rigor/plugin/access_denied_error.rbs +3 -1
- data/sig/rigor/plugin/base.rbs +58 -3
- data/sig/rigor/plugin/io_boundary.rbs +3 -0
- data/sig/rigor/plugin/manifest.rbs +31 -1
- data/sig/rigor/source.rbs +12 -0
- data/sig/rigor.rbs +5 -0
- data/skills/rigor-plugin-author/SKILL.md +13 -9
- data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +6 -5
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +159 -75
- data/skills/rigor-plugin-author/references/03-test-and-ship.md +3 -3
- metadata +52 -2
- 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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
|
81
|
-
return [
|
|
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
|
-
|
|
84
|
-
[
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
146
|
-
|
|
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
|
|
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
|
-
|
|
159
|
-
|
|
154
|
+
Violation.new(
|
|
155
|
+
location: loc,
|
|
160
156
|
message: message, severity: :error, rule: "unknown-factory"
|
|
161
157
|
)
|
|
162
158
|
end
|
|
163
159
|
|
|
164
|
-
def
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
Analyzer.
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
|
@@ -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
|
-
#
|
|
61
|
-
#
|
|
62
|
-
#
|
|
63
|
-
# `StatementEvaluator#apply_local_post_return_fact
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|