rigortype 0.1.18 → 0.2.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.
- checksums.yaml +4 -4
- data/README.md +159 -224
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -3
- data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +32 -23
- data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
- data/lib/rigor/analysis/check_rules/rule_walk.rb +151 -23
- data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +24 -15
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +9 -3
- data/lib/rigor/analysis/check_rules.rb +756 -132
- data/lib/rigor/analysis/dependency_source_inference/index.rb +4 -7
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -18
- data/lib/rigor/analysis/dependency_source_inference.rb +3 -12
- data/lib/rigor/analysis/diagnostic.rb +8 -0
- data/lib/rigor/analysis/fact_store.rb +5 -4
- data/lib/rigor/analysis/rule_catalog.rb +153 -6
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +19 -18
- data/lib/rigor/analysis/runner/project_pre_passes.rb +13 -9
- data/lib/rigor/analysis/runner.rb +75 -27
- data/lib/rigor/analysis/self_call_resolution_recorder.rb +3 -4
- data/lib/rigor/analysis/worker_session.rb +31 -25
- data/lib/rigor/bleeding_edge.rb +123 -0
- data/lib/rigor/builtins/predefined_constant_refinements.rb +151 -0
- data/lib/rigor/cache/descriptor.rb +86 -8
- data/lib/rigor/cache/rbs_descriptor.rb +2 -1
- data/lib/rigor/cache/store.rb +5 -3
- data/lib/rigor/cli/annotate_command.rb +122 -16
- data/lib/rigor/cli/baseline_command.rb +4 -3
- data/lib/rigor/cli/check_command.rb +118 -16
- data/lib/rigor/cli/coverage_command.rb +148 -16
- data/lib/rigor/cli/coverage_scan.rb +57 -0
- data/lib/rigor/cli/explain_command.rb +2 -0
- data/lib/rigor/cli/lsp_command.rb +3 -7
- data/lib/rigor/cli/mutation_protection_renderer.rb +63 -0
- data/lib/rigor/cli/mutation_protection_report.rb +73 -0
- data/lib/rigor/cli/options.rb +9 -0
- data/lib/rigor/cli/plugins_command.rb +4 -5
- data/lib/rigor/cli/plugins_renderer.rb +0 -2
- data/lib/rigor/cli/protection_renderer.rb +63 -0
- data/lib/rigor/cli/protection_report.rb +68 -0
- data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
- data/lib/rigor/cli/sig_gen_command.rb +2 -1
- data/lib/rigor/cli/trace_command.rb +2 -1
- data/lib/rigor/cli/triage_command.rb +8 -4
- data/lib/rigor/cli/triage_renderer.rb +15 -1
- data/lib/rigor/cli/type_of_command.rb +1 -1
- data/lib/rigor/cli/type_scan_command.rb +2 -1
- data/lib/rigor/cli.rb +12 -3
- data/lib/rigor/configuration/dependencies.rb +2 -4
- data/lib/rigor/configuration/severity_profile.rb +13 -1
- data/lib/rigor/configuration.rb +100 -6
- data/lib/rigor/environment/bundle_sig_discovery.rb +61 -13
- data/lib/rigor/environment/class_registry.rb +4 -3
- data/lib/rigor/environment/constant_type_cache_holder.rb +43 -0
- data/lib/rigor/environment/lockfile_resolver.rb +1 -1
- data/lib/rigor/environment/rbs_collection_discovery.rb +1 -2
- data/lib/rigor/environment/rbs_coverage_report.rb +2 -1
- data/lib/rigor/environment/rbs_loader.rb +74 -5
- data/lib/rigor/environment.rb +17 -7
- data/lib/rigor/flow_contribution/fact.rb +1 -1
- data/lib/rigor/flow_contribution.rb +3 -5
- data/lib/rigor/inference/acceptance.rb +17 -9
- data/lib/rigor/inference/block_parameter_binder.rb +2 -3
- data/lib/rigor/inference/body_fixpoint.rb +89 -0
- data/lib/rigor/inference/budget_trace.rb +29 -2
- data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -2
- data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -2
- data/lib/rigor/inference/builtins/method_catalog.rb +19 -0
- data/lib/rigor/inference/builtins/string_catalog.rb +9 -1
- data/lib/rigor/inference/expression_typer.rb +1072 -71
- data/lib/rigor/inference/hkt_body.rb +8 -11
- data/lib/rigor/inference/hkt_body_parser.rb +10 -12
- data/lib/rigor/inference/hkt_registry.rb +10 -11
- data/lib/rigor/inference/macro_block_self_type.rb +2 -2
- data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
- data/lib/rigor/inference/method_dispatcher/call_context.rb +1 -4
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +210 -35
- data/lib/rigor/inference/method_dispatcher/data_folding.rb +9 -73
- data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -7
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +10 -16
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +25 -13
- data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +93 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -3
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +24 -22
- data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +237 -24
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +303 -0
- data/lib/rigor/inference/method_dispatcher.rb +112 -49
- data/lib/rigor/inference/method_parameter_binder.rb +56 -2
- data/lib/rigor/inference/multi_target_binder.rb +46 -3
- data/lib/rigor/inference/mutation_widening.rb +147 -11
- data/lib/rigor/inference/narrowing.rb +284 -53
- data/lib/rigor/inference/parameter_inference_collector.rb +367 -0
- data/lib/rigor/inference/project_patched_methods.rb +4 -7
- data/lib/rigor/inference/project_patched_scanner.rb +2 -13
- data/lib/rigor/inference/protection_scanner.rb +86 -0
- data/lib/rigor/inference/scope_indexer.rb +821 -76
- data/lib/rigor/inference/statement_evaluator.rb +1179 -102
- data/lib/rigor/inference/struct_fold_safety.rb +181 -0
- data/lib/rigor/inference/synthetic_method.rb +7 -7
- data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
- data/lib/rigor/language_server/completion_provider.rb +6 -12
- data/lib/rigor/language_server/diagnostic_publisher.rb +4 -4
- data/lib/rigor/language_server/document_symbol_provider.rb +3 -3
- data/lib/rigor/language_server/hover_provider.rb +2 -3
- data/lib/rigor/language_server/hover_renderer.rb +2 -11
- data/lib/rigor/language_server/server.rb +9 -17
- data/lib/rigor/language_server.rb +4 -5
- data/lib/rigor/plugin/base.rb +245 -87
- data/lib/rigor/plugin/macro/block_as_method.rb +25 -25
- data/lib/rigor/plugin/macro/heredoc_template.rb +4 -7
- data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
- data/lib/rigor/plugin/macro/trait_registry.rb +3 -6
- data/lib/rigor/plugin/macro.rb +6 -8
- data/lib/rigor/plugin/manifest.rb +49 -90
- data/lib/rigor/plugin/node_rule_walk.rb +59 -14
- data/lib/rigor/plugin/registry.rb +18 -18
- data/lib/rigor/plugin/type_node_resolver.rb +6 -8
- data/lib/rigor/protection/mutation_scanner.rb +120 -0
- data/lib/rigor/protection/mutator.rb +246 -0
- data/lib/rigor/rbs_extended.rb +24 -36
- data/lib/rigor/reflection.rb +4 -7
- data/lib/rigor/scope/discovery_index.rb +16 -2
- data/lib/rigor/scope.rb +185 -16
- data/lib/rigor/sig_gen/generator.rb +8 -0
- data/lib/rigor/sig_gen/observed_call.rb +3 -3
- data/lib/rigor/sig_gen/writer.rb +40 -2
- data/lib/rigor/source/constant_path.rb +62 -0
- data/lib/rigor/source.rb +1 -0
- data/lib/rigor/triage/catalogue.rb +4 -19
- data/lib/rigor/triage.rb +69 -1
- data/lib/rigor/type/bound_method.rb +2 -11
- data/lib/rigor/type/combinator.rb +45 -3
- data/lib/rigor/type/constant.rb +2 -11
- data/lib/rigor/type/data_class.rb +2 -11
- data/lib/rigor/type/data_instance.rb +2 -11
- data/lib/rigor/type/hash_shape.rb +2 -11
- data/lib/rigor/type/integer_range.rb +2 -11
- data/lib/rigor/type/intersection.rb +2 -11
- data/lib/rigor/type/nominal.rb +2 -11
- data/lib/rigor/type/plain_lattice.rb +37 -0
- data/lib/rigor/type/refined.rb +72 -13
- data/lib/rigor/type/singleton.rb +2 -11
- data/lib/rigor/type/struct_class.rb +75 -0
- data/lib/rigor/type/struct_instance.rb +93 -0
- data/lib/rigor/type/tuple.rb +5 -15
- data/lib/rigor/type.rb +2 -0
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +3 -3
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +16 -32
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +5 -13
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +11 -17
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +34 -100
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +3 -2
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +26 -27
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +5 -7
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +9 -8
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +9 -11
- data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +8 -9
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +13 -12
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +3 -4
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +8 -8
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +9 -11
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +7 -8
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +18 -49
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +12 -13
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +15 -23
- data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +4 -4
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +3 -3
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +2 -4
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +27 -11
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +22 -35
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -6
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +12 -18
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +16 -23
- 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 +3 -4
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +1 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +21 -27
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +0 -1
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +5 -4
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +2 -3
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +7 -11
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +4 -5
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +6 -9
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +5 -15
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +52 -40
- data/sig/rigor/analysis/fact_store.rbs +3 -0
- data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
- data/sig/rigor/plugin/base.rbs +5 -2
- data/sig/rigor/plugin/manifest.rbs +1 -2
- data/sig/rigor/scope.rbs +18 -1
- data/sig/rigor/type.rbs +37 -1
- data/sig/rigor.rbs +1 -1
- data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
- data/skills/rigor-plugin-author/SKILL.md +6 -4
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
- data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
- metadata +25 -2
- data/lib/rigor/plugin/macro/external_file.rb +0 -143
|
@@ -4,6 +4,7 @@ require "prism"
|
|
|
4
4
|
|
|
5
5
|
require_relative "../reflection"
|
|
6
6
|
require_relative "../source/node_walker"
|
|
7
|
+
require_relative "../source/constant_path"
|
|
7
8
|
require_relative "../type"
|
|
8
9
|
require_relative "diagnostic"
|
|
9
10
|
require_relative "dependency_recorder"
|
|
@@ -12,41 +13,35 @@ require_relative "check_rules/always_truthy_condition_collector"
|
|
|
12
13
|
require_relative "check_rules/unreachable_clause_collector"
|
|
13
14
|
require_relative "check_rules/dead_assignment_collector"
|
|
14
15
|
require_relative "check_rules/ivar_write_collector"
|
|
16
|
+
require_relative "check_rules/main_pass_collector"
|
|
15
17
|
require_relative "check_rules/self_closedness_scanner"
|
|
16
18
|
|
|
17
19
|
module Rigor
|
|
18
20
|
module Analysis
|
|
19
|
-
#
|
|
21
|
+
# Catalogue of `rigor check` diagnostic rules.
|
|
20
22
|
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
# scope index produced by
|
|
23
|
+
# Rules fire ONLY when the engine is confident enough to make a
|
|
24
|
+
# useful claim and MUST NOT raise on unrecognised AST shapes,
|
|
25
|
+
# RBS gaps, or missing scope information. Each rule consumes
|
|
26
|
+
# the per-node scope index produced by
|
|
26
27
|
# `Rigor::Inference::ScopeIndexer.index` and yields zero or
|
|
27
28
|
# more `Rigor::Analysis::Diagnostic` values.
|
|
28
29
|
#
|
|
29
|
-
# The
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
# method table. This is the canonical "type check" signal
|
|
35
|
-
# ("Foo has no method bar"), but it explicitly does NOT fire
|
|
36
|
-
# for:
|
|
30
|
+
# The primary rule (`call.undefined-method`) flags an
|
|
31
|
+
# explicit-receiver `Prism::CallNode` whose receiver statically
|
|
32
|
+
# resolves to a class known to the RBS environment and whose
|
|
33
|
+
# method name does not appear on that class's method table.
|
|
34
|
+
# It does NOT fire for:
|
|
37
35
|
#
|
|
38
36
|
# - implicit-self calls (no `node.receiver`) — too noisy
|
|
39
37
|
# without per-method RBS for every helper in the class.
|
|
40
38
|
# - dynamic / unknown receivers (`Dynamic[T]`, `Top`, `Union`)
|
|
41
39
|
# — by definition we cannot enumerate the method set.
|
|
42
|
-
# - shape carriers
|
|
43
|
-
#
|
|
44
|
-
#
|
|
40
|
+
# - shape carriers: `Tuple` → "Array", `HashShape` → "Hash",
|
|
41
|
+
# `Constant` → the constant's class — `concrete_class_name`
|
|
42
|
+
# resolves these to their runtime class for dispatch.
|
|
45
43
|
# - receivers whose class name is NOT registered in the
|
|
46
44
|
# loader (RBS-blind environments, unknown stdlib).
|
|
47
|
-
#
|
|
48
|
-
# The above list is the deliberate conservative envelope of
|
|
49
|
-
# the first preview; later slices broaden it.
|
|
50
45
|
# rubocop:disable Metrics/ModuleLength
|
|
51
46
|
module CheckRules
|
|
52
47
|
# Canonical identifiers for each rule. Per ADR-8 §
|
|
@@ -163,59 +158,149 @@ module Rigor
|
|
|
163
158
|
# @param root [Prism::Node]
|
|
164
159
|
# @param scope_index [Hash{Prism::Node => Rigor::Scope}]
|
|
165
160
|
# @return [Array<Rigor::Analysis::Diagnostic>]
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
diagnostics << override_return if override_return
|
|
179
|
-
override_param = override_param_narrowed_diagnostic(path, node, scope_index)
|
|
180
|
-
diagnostics << override_param if override_param
|
|
181
|
-
when Prism::IfNode, Prism::UnlessNode
|
|
182
|
-
unreachable = unreachable_branch_diagnostic(path, node, scope_index)
|
|
183
|
-
diagnostics << unreachable if unreachable
|
|
184
|
-
end
|
|
185
|
-
end
|
|
161
|
+
#
|
|
162
|
+
# ADR-53 B4 — when `node_collectors` is supplied, the converged
|
|
163
|
+
# {Plugin::NodeRuleWalk} traversal has already populated the built-in
|
|
164
|
+
# collectors (including the main pass) in one shared walk with the
|
|
165
|
+
# plugin node-rules, so they are consumed as-is. When it is nil (a
|
|
166
|
+
# direct caller with no plugin walk, e.g. a unit test), the standalone
|
|
167
|
+
# {RuleWalk} walk runs here instead, so `diagnose` stays correct
|
|
168
|
+
# without the converged path.
|
|
169
|
+
def diagnose(path:, root:, scope_index:, self_call_misses: [], comments: [], disabled_rules: [],
|
|
170
|
+
node_collectors: nil)
|
|
171
|
+
collectors = node_collectors || run_node_collectors(path, root, scope_index)
|
|
172
|
+
diagnostics = collectors[:main_pass].results.dup
|
|
186
173
|
diagnostics.concat(self_undefined_method_diagnostics(path, self_call_misses, root, scope_index))
|
|
187
|
-
|
|
188
|
-
diagnostics.concat(
|
|
189
|
-
diagnostics.concat(
|
|
190
|
-
diagnostics.concat(
|
|
191
|
-
diagnostics.concat(dead_assignment_diagnostics(path, root, scope_index))
|
|
174
|
+
diagnostics.concat(always_truthy_condition_diagnostics(path, collectors[:always_truthy].results))
|
|
175
|
+
diagnostics.concat(unreachable_clause_diagnostics(path, collectors[:unreachable_clauses].results))
|
|
176
|
+
diagnostics.concat(ivar_write_mismatch_diagnostics(path, collectors[:ivar_writes].results))
|
|
177
|
+
diagnostics.concat(dead_assignment_diagnostics(path, collectors[:dead_assignments].results))
|
|
192
178
|
filter_suppressed(diagnostics, comments: comments, disabled_rules: disabled_rules)
|
|
193
179
|
end
|
|
194
180
|
|
|
195
|
-
#
|
|
196
|
-
#
|
|
197
|
-
#
|
|
198
|
-
#
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
181
|
+
# The verbatim per-node dispatch of the former inline main pass
|
|
182
|
+
# (`diagnose`'s `Source::NodeWalker.each` `case`), now invoked by
|
|
183
|
+
# {MainPassCollector} on the shared {RuleWalk}. Returns the
|
|
184
|
+
# diagnostics for one node, in the same emission order as before.
|
|
185
|
+
def main_pass_node_diagnostics(path, node, scope_index)
|
|
186
|
+
case node
|
|
187
|
+
when Prism::CallNode
|
|
188
|
+
call_node_diagnostics(path, node, scope_index)
|
|
189
|
+
when Prism::DefNode
|
|
190
|
+
[
|
|
191
|
+
return_type_mismatch_diagnostic(path, node, scope_index),
|
|
192
|
+
override_visibility_diagnostic(path, node, scope_index),
|
|
193
|
+
override_return_widened_diagnostic(path, node, scope_index),
|
|
194
|
+
override_param_narrowed_diagnostic(path, node, scope_index)
|
|
195
|
+
].compact
|
|
196
|
+
when Prism::IfNode, Prism::UnlessNode
|
|
197
|
+
[unreachable_branch_diagnostic(path, node, scope_index)].compact
|
|
198
|
+
else
|
|
199
|
+
[]
|
|
200
|
+
end
|
|
209
201
|
end
|
|
210
202
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
203
|
+
# Constructs the fresh, unpopulated built-in collector set keyed by
|
|
204
|
+
# role, including the main pass. Split out so the converged walk
|
|
205
|
+
# (ADR-53 B4) can build the collectors, drive them via a
|
|
206
|
+
# {RuleWalk::CollectorDriver} inside the single {Plugin::NodeRuleWalk}
|
|
207
|
+
# traversal, and hand the populated set back to {.diagnose} as
|
|
208
|
+
# `node_collectors:`. The main pass needs `path` because its per-node
|
|
209
|
+
# diagnostics carry it (ADR-53 B3c hosts it on the same walk).
|
|
210
|
+
def build_node_collectors(path, scope_index)
|
|
211
|
+
{
|
|
212
|
+
main_pass: MainPassCollector.new(->(node) { main_pass_node_diagnostics(path, node, scope_index) }),
|
|
213
|
+
always_truthy: AlwaysTruthyConditionCollector.new(scope_index),
|
|
214
|
+
unreachable_clauses: UnreachableClauseCollector.new(scope_index),
|
|
215
|
+
ivar_writes: IvarWriteCollector.new(scope_index),
|
|
216
|
+
dead_assignments: DeadAssignmentCollector.new(scope_index)
|
|
217
|
+
}
|
|
218
|
+
end
|
|
215
219
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
220
|
+
# A {RuleWalk::CollectorDriver} over a built-in collector set, for a
|
|
221
|
+
# foreign traversal to drive (ADR-53 B4). The driver visits each node
|
|
222
|
+
# and derives child contexts exactly as the standalone {RuleWalk}
|
|
223
|
+
# walk would.
|
|
224
|
+
def node_collector_driver(collectors)
|
|
225
|
+
RuleWalk::CollectorDriver.new(collectors.values)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# ADR-53 Track B — the {RuleWalk}-hosted built-in collectors (the main
|
|
229
|
+
# pass and the four fact collectors) all ride one traversal of the
|
|
230
|
+
# file instead of one walk each. Returns the populated collectors
|
|
231
|
+
# keyed by role so the caller can build the diagnostics from each
|
|
232
|
+
# collector's `results`. Used on the standalone path (no converged
|
|
233
|
+
# plugin walk); the converged path populates the same collector set
|
|
234
|
+
# via {.node_collector_driver} instead.
|
|
235
|
+
#
|
|
236
|
+
# Under `RIGOR_SHADOW_RULE_WALK=1` each hosted collector's legacy
|
|
237
|
+
# single-collector `#collect` walk also runs as the oracle and any
|
|
238
|
+
# divergence aborts the run — the corpus-scale half of the
|
|
239
|
+
# equivalence harness (the curated half is `rule_walk_equivalence_spec`).
|
|
240
|
+
def run_node_collectors(path, root, scope_index)
|
|
241
|
+
collectors = build_node_collectors(path, scope_index)
|
|
242
|
+
RuleWalk.run(root, collectors.values)
|
|
243
|
+
shadow_verify_node_collectors(path, root, scope_index, collectors) if ENV["RIGOR_SHADOW_RULE_WALK"]
|
|
244
|
+
collectors
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def shadow_verify_node_collectors(path, root, scope_index, collectors)
|
|
248
|
+
divergences = collectors.filter_map do |role, collector|
|
|
249
|
+
legacy = oracle_results(role, collector, path, root, scope_index)
|
|
250
|
+
next if comparable(legacy) == comparable(collector.results)
|
|
251
|
+
|
|
252
|
+
"#{role} legacy=#{legacy.size} walk=#{collector.results.size}"
|
|
253
|
+
end
|
|
254
|
+
return if divergences.empty?
|
|
255
|
+
|
|
256
|
+
raise "RIGOR_SHADOW_RULE_WALK divergence: #{divergences.join('; ')}"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Normalises a collector's result for value comparison. The fact
|
|
260
|
+
# collectors return `Data` / Hash structures that already compare by
|
|
261
|
+
# value; the main pass returns {Diagnostic} objects (plain objects
|
|
262
|
+
# with identity `==`), so serialise those to hashes first.
|
|
263
|
+
def comparable(results)
|
|
264
|
+
return results.map(&:to_h) if results.is_a?(Array) && results.first.is_a?(Diagnostic)
|
|
265
|
+
|
|
266
|
+
results
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# The oracle each hosted collector's walk result is checked against.
|
|
270
|
+
# The fact collectors re-run their legacy single-collector `#collect`
|
|
271
|
+
# walk; the main pass re-runs the former inline `Source::NodeWalker`
|
|
272
|
+
# `case` (`main_pass_oracle`) since its diagnostics are the result.
|
|
273
|
+
def oracle_results(role, collector, path, root, scope_index)
|
|
274
|
+
return main_pass_oracle(path, root, scope_index) if role == :main_pass
|
|
275
|
+
|
|
276
|
+
collector.class.new(scope_index).collect(root)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# The former inline main pass, kept as the shadow oracle: walks the
|
|
280
|
+
# tree with `Source::NodeWalker.each` and accumulates the same
|
|
281
|
+
# per-node diagnostics in the same order {MainPassCollector} now
|
|
282
|
+
# produces them on the shared walk.
|
|
283
|
+
def main_pass_oracle(path, root, scope_index)
|
|
284
|
+
diagnostics = []
|
|
285
|
+
Source::NodeWalker.each(root) do |node|
|
|
286
|
+
diagnostics.concat(main_pass_node_diagnostics(path, node, scope_index))
|
|
287
|
+
end
|
|
288
|
+
diagnostics
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# ADR-53 B4 — corpus-scale oracle for the CONVERGED walk: the
|
|
292
|
+
# collectors (including the main pass, ADR-53 B3c) were populated by
|
|
293
|
+
# the {Plugin::NodeRuleWalk} traversal, not by `RuleWalk.run`, so
|
|
294
|
+
# re-run each collector's legacy oracle (the fact collectors'
|
|
295
|
+
# `#collect` walk, the main pass's inline `Source::NodeWalker` `case`)
|
|
296
|
+
# and assert the converged walk produced byte-identical results. Same
|
|
297
|
+
# divergence contract as {.shadow_verify_node_collectors}; nil
|
|
298
|
+
# collectors (caller without built-in collection) is a no-op. `path`
|
|
299
|
+
# is threaded because the main pass's oracle carries it.
|
|
300
|
+
def shadow_verify_converged_collectors(path, root, scope_index, collectors)
|
|
301
|
+
return if collectors.nil?
|
|
302
|
+
|
|
303
|
+
shadow_verify_node_collectors(path, root, scope_index, collectors)
|
|
219
304
|
end
|
|
220
305
|
|
|
221
306
|
def call_node_diagnostics(path, node, scope_index)
|
|
@@ -252,8 +337,8 @@ module Rigor
|
|
|
252
337
|
# Class-level ivars (`@x = 1` outside any def, in the
|
|
253
338
|
# class body) are also skipped — they're a separate
|
|
254
339
|
# surface (`Module#@var`) the engine doesn't yet model.
|
|
255
|
-
def ivar_write_mismatch_diagnostics(path,
|
|
256
|
-
|
|
340
|
+
def ivar_write_mismatch_diagnostics(path, ivar_writes)
|
|
341
|
+
ivar_writes.flat_map do |class_name, writes_by_ivar|
|
|
257
342
|
writes_by_ivar.flat_map do |ivar_name, writes|
|
|
258
343
|
ivar_mismatch_diagnostics_for(path, class_name, ivar_name, writes)
|
|
259
344
|
end
|
|
@@ -266,8 +351,8 @@ module Rigor
|
|
|
266
351
|
# read in the same body. The
|
|
267
352
|
# `Analysis::CheckRules::DeadAssignmentCollector` describes
|
|
268
353
|
# the conservative envelope.
|
|
269
|
-
def dead_assignment_diagnostics(path,
|
|
270
|
-
|
|
354
|
+
def dead_assignment_diagnostics(path, dead_assignments)
|
|
355
|
+
dead_assignments.map do |result|
|
|
271
356
|
build_dead_assignment_diagnostic(path, result[:write_node], result[:def_node])
|
|
272
357
|
end
|
|
273
358
|
end
|
|
@@ -421,9 +506,24 @@ module Rigor
|
|
|
421
506
|
scope = scope_index[call_node]
|
|
422
507
|
return nil if scope.nil?
|
|
423
508
|
|
|
424
|
-
|
|
509
|
+
# N3 — a safe-navigation call (`recv&.m`) never dispatches on the
|
|
510
|
+
# nil edge of its receiver: at runtime it short-circuits to nil.
|
|
511
|
+
# A receiver that types as exactly `nil` yields nil with no call at
|
|
512
|
+
# all, so it is silent (no dead-code diagnostic — `&.` is the
|
|
513
|
+
# nil-skip operator by design, and frightening working
|
|
514
|
+
# `@x = nil; @x&.m` code would breach FP discipline). A nil-bearing
|
|
515
|
+
# *union* receiver is left to flow through unchanged: a `T | nil`
|
|
516
|
+
# union has no single concrete class, so the rule already bails
|
|
517
|
+
# below — preserving that keeps `&.` from newly firing on the
|
|
518
|
+
# non-nil constituent (which, for a cross-file project def, would
|
|
519
|
+
# be a working-code false positive).
|
|
520
|
+
receiver_type = safe_navigation_receiver(call_node, scope)
|
|
425
521
|
class_name = concrete_class_name(receiver_type)
|
|
426
|
-
|
|
522
|
+
# A union receiver has no single concrete class. The scalar path
|
|
523
|
+
# below cannot reason about it, but the call is still definitely
|
|
524
|
+
# undefined when EVERY arm lacks the method — see
|
|
525
|
+
# `union_undefined_method_diagnostic`.
|
|
526
|
+
return union_undefined_method_diagnostic(path, call_node, receiver_type, scope) if class_name.nil?
|
|
427
527
|
|
|
428
528
|
# ADR-26 — a plugin may declare a class "open": one
|
|
429
529
|
# known to respond beyond its RBS-declared method
|
|
@@ -602,6 +702,20 @@ module Rigor
|
|
|
602
702
|
when Type::Tuple then "Array"
|
|
603
703
|
when Type::HashShape then "Hash"
|
|
604
704
|
when Type::Constant then constant_class_name(type.value)
|
|
705
|
+
# A refinement IS its base class for method dispatch — its method
|
|
706
|
+
# surface is the base's. Resolve to the base so the call rules
|
|
707
|
+
# (undefined-method / wrong-arity / argument-type-mismatch) reason
|
|
708
|
+
# about it instead of bailing. `Type::Refined` carries string-family
|
|
709
|
+
# refinements (`lowercase-string`, …) over an explicit `.base`;
|
|
710
|
+
# `Type::IntegerRange` carries the bounded-int refinements
|
|
711
|
+
# (`non-negative-int`, `positive-int`, `int<1,5>`), every one an
|
|
712
|
+
# Integer; `Type::Difference` (`A - B`) carries the non-empty /
|
|
713
|
+
# non-zero refinements (`non-empty-string` = `String - ""`,
|
|
714
|
+
# `non-empty-array` = `Array - []`, `non-zero-int` = `Integer - 0`)
|
|
715
|
+
# — subtracting values never changes the method surface, so the
|
|
716
|
+
# base (minuend) class dispatches.
|
|
717
|
+
when Type::Refined, Type::Difference then concrete_class_name(type.base)
|
|
718
|
+
when Type::IntegerRange then "Integer"
|
|
605
719
|
end
|
|
606
720
|
end
|
|
607
721
|
|
|
@@ -678,12 +792,90 @@ module Rigor
|
|
|
678
792
|
scope = scope_index[miss.node]
|
|
679
793
|
next if scope.nil?
|
|
680
794
|
next unless confidently_closed_self_class?(miss.class_name, scope)
|
|
795
|
+
next if method_defined_on_known_subclass?(miss.class_name, miss.method_name, scope)
|
|
681
796
|
|
|
682
797
|
build_self_undefined_method_diagnostic(path, miss)
|
|
683
798
|
end
|
|
684
799
|
end
|
|
685
800
|
|
|
801
|
+
# ADR-24 slice 4 — subclass-aware gating (the abstract / template-method
|
|
802
|
+
# base-class false positive the WD4 corpus eval surfaced). A base class
|
|
803
|
+
# legitimately calls a method its subclasses implement
|
|
804
|
+
# (`Mail::CommonField#decoded` calls `do_decode`, which
|
|
805
|
+
# `Mail::UnstructuredField < CommonField` and its siblings define; the
|
|
806
|
+
# same shape covers `Mail::Retriever#find` → POP3 / IMAP). When the
|
|
807
|
+
# missed method is discovered on ANY known subclass of the self-class,
|
|
808
|
+
# the call is a template-method hook, not a typo — suppress. Walks the
|
|
809
|
+
# project subclass closure (the `discovered_superclasses` child→parent
|
|
810
|
+
# map inverted, cycle-guarded). A pure narrowing — it only ever
|
|
811
|
+
# suppresses a firing the closed-class gate would otherwise emit.
|
|
812
|
+
def method_defined_on_known_subclass?(class_name, method_name, scope)
|
|
813
|
+
supers = scope.discovered_superclasses
|
|
814
|
+
seen = {}
|
|
815
|
+
queue = direct_subclasses(class_name, supers)
|
|
816
|
+
until queue.empty?
|
|
817
|
+
subclass = queue.shift
|
|
818
|
+
next if seen[subclass]
|
|
819
|
+
|
|
820
|
+
seen[subclass] = true
|
|
821
|
+
return true if method_known_on_class?(subclass, method_name, scope)
|
|
822
|
+
|
|
823
|
+
queue.concat(direct_subclasses(subclass, supers))
|
|
824
|
+
end
|
|
825
|
+
false
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
# The directly-recorded subclasses of `class_name`. `discovered_superclasses`
|
|
829
|
+
# keys the child fully-qualified (`Mail::POP3`) but records the parent
|
|
830
|
+
# *as written* (`Retriever`), so a qualified miss class (`Mail::Retriever`)
|
|
831
|
+
# is matched by resolving the parent name in the child's namespace.
|
|
832
|
+
def direct_subclasses(class_name, discovered_superclasses)
|
|
833
|
+
discovered_superclasses.filter_map { |child, parent| child if parent_matches?(child, parent, class_name) }
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
# Ruby constant lookup: a recorded parent `Retriever` on child
|
|
837
|
+
# `Mail::POP3` resolves to `Mail::Retriever` (walk the child's namespace
|
|
838
|
+
# prefixes, longest first), matched against the miss's fully-qualified
|
|
839
|
+
# class name. Namespace-anchored, so it cannot match a same-named base in
|
|
840
|
+
# an unrelated namespace.
|
|
841
|
+
def parent_matches?(child, parent, class_name)
|
|
842
|
+
parent_name = parent.to_s
|
|
843
|
+
return true if parent_name == class_name
|
|
844
|
+
|
|
845
|
+
segments = child.to_s.split("::")[0...-1]
|
|
846
|
+
until segments.empty?
|
|
847
|
+
return true if "#{segments.join('::')}::#{parent_name}" == class_name
|
|
848
|
+
|
|
849
|
+
segments.pop
|
|
850
|
+
end
|
|
851
|
+
false
|
|
852
|
+
end
|
|
853
|
+
|
|
854
|
+
# Whether `method_name` is defined on `class_name` in the project — a
|
|
855
|
+
# plain `def` (the def-node table) or a dynamic definition
|
|
856
|
+
# (`define_method` / `attr_*` / `alias`). `discovered_method?` alone
|
|
857
|
+
# misses plain defs, which is exactly what the abstract hooks are.
|
|
858
|
+
def method_known_on_class?(class_name, method_name, scope)
|
|
859
|
+
!scope.user_def_for(class_name, method_name).nil? ||
|
|
860
|
+
scope.discovered_method?(class_name, method_name, :instance)
|
|
861
|
+
end
|
|
862
|
+
|
|
863
|
+
# ADR-24 slice 4 — the universal bases. A recorded self-call miss tagged
|
|
864
|
+
# with one of these means the engine fell back to the root self-type
|
|
865
|
+
# because it could NOT resolve the real class (a class-body macro context
|
|
866
|
+
# where self is the Class object, top-level `main`, `instance_eval`, an
|
|
867
|
+
# FFI / `define_method` metaprogramming surface). Their instance method
|
|
868
|
+
# set is never "project-known and complete" — every object also responds
|
|
869
|
+
# to whatever the unresolved real class adds — so a miss there is a
|
|
870
|
+
# resolution gap, not a typo. This is the dominant false-positive class
|
|
871
|
+
# the WD4 corpus eval surfaced (protobuf 73 / tdiary 199 / pycall 10 /
|
|
872
|
+
# … FFI + class-macro calls, 287 firings across the corpus); excluding
|
|
873
|
+
# it is a pure narrowing.
|
|
874
|
+
SELF_UNDEFINED_UNIVERSAL_BASES = %w[Object BasicObject Kernel].to_set.freeze
|
|
875
|
+
private_constant :SELF_UNDEFINED_UNIVERSAL_BASES
|
|
876
|
+
|
|
686
877
|
def confidently_closed_self_class?(class_name, scope)
|
|
878
|
+
return false if SELF_UNDEFINED_UNIVERSAL_BASES.include?(class_name)
|
|
687
879
|
return false if unbounded_receiver_surface?(class_name, scope)
|
|
688
880
|
return false if scope.discovered_method?(class_name, :method_missing, :instance)
|
|
689
881
|
# A superclass or mixin extends the surface beyond what this file
|
|
@@ -742,7 +934,7 @@ module Rigor
|
|
|
742
934
|
# by `undefined_method_diagnostic`; it returns nil
|
|
743
935
|
# when the call's receiver / RBS coverage / call shape
|
|
744
936
|
# disqualifies the rule.
|
|
745
|
-
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
937
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
|
|
746
938
|
def wrong_arity_diagnostic(path, call_node, scope_index)
|
|
747
939
|
return nil if call_node.receiver.nil?
|
|
748
940
|
return nil unless plain_positional_call?(call_node)
|
|
@@ -755,6 +947,15 @@ module Rigor
|
|
|
755
947
|
return nil if class_name.nil?
|
|
756
948
|
|
|
757
949
|
kind = receiver_type.is_a?(Type::Singleton) ? :singleton : :instance
|
|
950
|
+
# `Struct.new(:a, :b).new(...)` chained: the inner
|
|
951
|
+
# `Struct.new(...)` is an anonymous Struct *subclass* whose
|
|
952
|
+
# `.new` accepts any positional arity (one slot per member,
|
|
953
|
+
# all defaulting to nil) — including zero. The receiver types
|
|
954
|
+
# as `Singleton[Struct]` (so the call-site `.new` dispatches,
|
|
955
|
+
# per the dispatcher's `struct_new_lift`), but validating that
|
|
956
|
+
# `.new` against the real `Struct.new(*Symbol)` signature is a
|
|
957
|
+
# false positive. Skip arity-checking the chained position.
|
|
958
|
+
return nil if anonymous_struct_new_call?(call_node, class_name, kind)
|
|
758
959
|
return nil if scope.discovered_method?(class_name, call_node.name, kind)
|
|
759
960
|
|
|
760
961
|
return nil unless Rigor::Reflection.rbs_class_known?(class_name, scope: scope)
|
|
@@ -772,7 +973,25 @@ module Rigor
|
|
|
772
973
|
|
|
773
974
|
build_arity_diagnostic(path, call_node, class_name, min, max, actual)
|
|
774
975
|
end
|
|
775
|
-
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
976
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
|
|
977
|
+
|
|
978
|
+
# True for the outer `.new` of a chained `Struct.new(...).new`:
|
|
979
|
+
# `class_name`/`kind` already pin the receiver to
|
|
980
|
+
# `Singleton[Struct]`, and the receiver node is itself a
|
|
981
|
+
# `Struct.new` (or `::Struct.new`) call — the anonymous subclass.
|
|
982
|
+
def anonymous_struct_new_call?(call_node, class_name, kind)
|
|
983
|
+
return false unless class_name == "Struct" && kind == :singleton
|
|
984
|
+
return false unless call_node.name == :new
|
|
985
|
+
|
|
986
|
+
receiver = call_node.receiver
|
|
987
|
+
return false unless receiver.is_a?(Prism::CallNode) && receiver.name == :new
|
|
988
|
+
|
|
989
|
+
inner = receiver.receiver
|
|
990
|
+
return true if inner.is_a?(Prism::ConstantReadNode) && inner.name == :Struct
|
|
991
|
+
|
|
992
|
+
# `::Struct.new(...).new` — a top-level constant path.
|
|
993
|
+
inner.is_a?(Prism::ConstantPathNode) && inner.parent.nil? && inner.name == :Struct
|
|
994
|
+
end
|
|
776
995
|
|
|
777
996
|
def plain_positional_call?(call_node)
|
|
778
997
|
arguments = call_node.arguments
|
|
@@ -864,6 +1083,17 @@ module Rigor
|
|
|
864
1083
|
scope = scope_index[call_node]
|
|
865
1084
|
return nil if scope.nil?
|
|
866
1085
|
|
|
1086
|
+
# ADR-58 WD1 — a receiver whose `nil` constituent is purely
|
|
1087
|
+
# declaration-sourced (the class-ivar index seed of a ctor
|
|
1088
|
+
# `@x = nil` written in another method, possibly copied into a
|
|
1089
|
+
# local via `r = @right`) does not fire by default: the working
|
|
1090
|
+
# program's cross-method invariant is assumed per the robustness
|
|
1091
|
+
# principle. The nil stays in the displayed type; only its use as
|
|
1092
|
+
# diagnostic fuel is withheld. Any flow-live touch (method-local
|
|
1093
|
+
# nil write, failed-guard narrowing) drops the mark upstream, so
|
|
1094
|
+
# flow-observed nil keeps firing exactly as before.
|
|
1095
|
+
return nil if scope.declaration_sourced?(:local, call_node.receiver.name)
|
|
1096
|
+
|
|
867
1097
|
receiver_type = scope.type_of(call_node.receiver)
|
|
868
1098
|
return nil unless receiver_type.is_a?(Type::Union)
|
|
869
1099
|
|
|
@@ -883,6 +1113,21 @@ module Rigor
|
|
|
883
1113
|
union.members.any? { |member| nil_member?(member) }
|
|
884
1114
|
end
|
|
885
1115
|
|
|
1116
|
+
# The receiver type the `call.undefined-method` existence check
|
|
1117
|
+
# should reason about. For a safe-navigation call whose receiver
|
|
1118
|
+
# types as exactly `nil`, this is `Type::Bot` — the call is
|
|
1119
|
+
# statically skipped at runtime, and `concrete_class_name(Bot)` is
|
|
1120
|
+
# nil so the rule bails (silent). Every other receiver (including a
|
|
1121
|
+
# `T | nil` union, which already has no single concrete class) flows
|
|
1122
|
+
# through unchanged.
|
|
1123
|
+
def safe_navigation_receiver(call_node, scope)
|
|
1124
|
+
receiver_type = scope.type_of(call_node.receiver)
|
|
1125
|
+
return receiver_type unless call_node.safe_navigation?
|
|
1126
|
+
return receiver_type unless nil_member?(receiver_type)
|
|
1127
|
+
|
|
1128
|
+
Type::Combinator.bot
|
|
1129
|
+
end
|
|
1130
|
+
|
|
886
1131
|
def nil_member?(member)
|
|
887
1132
|
(member.is_a?(Type::Constant) && member.value.nil?) ||
|
|
888
1133
|
(member.is_a?(Type::Nominal) && member.class_name == "NilClass")
|
|
@@ -919,6 +1164,71 @@ module Rigor
|
|
|
919
1164
|
!definition.methods[method_name.to_sym].nil?
|
|
920
1165
|
end
|
|
921
1166
|
|
|
1167
|
+
# Teeth on a *union* receiver. The scalar `undefined_method_diagnostic`
|
|
1168
|
+
# bails when the receiver has no single concrete class; here we fire
|
|
1169
|
+
# when EVERY non-nil arm is a fully-known, bounded, instance class on
|
|
1170
|
+
# which the method is absent — the call is then undefined regardless of
|
|
1171
|
+
# which arm the value takes at runtime (`A | B` responds to `m` only if
|
|
1172
|
+
# both `A` and `B` do). FP-safe by construction: `method_present_anywhere?`
|
|
1173
|
+
# returns "present" for any Dynamic / unknown / unbuildable /
|
|
1174
|
+
# source-declared arm, so the `any?` short-circuits and we never fire on
|
|
1175
|
+
# uncertainty; `union_arm_blocks_undefined_fire?` additionally bails on
|
|
1176
|
+
# any open (ADR-26) / synthesized / singleton / module-mixin arm. This is
|
|
1177
|
+
# no more aggressive than the scalar rule — it just applies the same
|
|
1178
|
+
# certainty test to each arm.
|
|
1179
|
+
#
|
|
1180
|
+
# Nil-bearing unions are deferred: their nil arm interacts with the
|
|
1181
|
+
# `possible-nil-receiver` rule, safe-navigation, and ADR-58
|
|
1182
|
+
# declaration-sourced nil. Slice 1 handles pure non-nil unions
|
|
1183
|
+
# (e.g. `String | Symbol`).
|
|
1184
|
+
def union_undefined_method_diagnostic(path, call_node, receiver_type, scope)
|
|
1185
|
+
return nil unless receiver_type.is_a?(Type::Union)
|
|
1186
|
+
return nil if call_node.safe_navigation?
|
|
1187
|
+
|
|
1188
|
+
members = receiver_type.members
|
|
1189
|
+
# Nil-bearing unions (`T | nil`) stay silent — the deliberate N3
|
|
1190
|
+
# decision (`safe_navigation_undefined_method_spec.rb`). A corpus FP
|
|
1191
|
+
# study of a bundled-arm-narrowed candidate (12 projects incl.
|
|
1192
|
+
# ActiveSupport-heavy) found ~zero real firings yet a demonstrated
|
|
1193
|
+
# loss-of-specificity false positive, so the silence is kept. See
|
|
1194
|
+
# ADR-62 and `docs/notes/20260613-mutation-teeth-harness.md`.
|
|
1195
|
+
return nil if members.any? { |member| nil_member?(member) }
|
|
1196
|
+
return nil if members.any? { |member| union_arm_blocks_undefined_fire?(member, scope) }
|
|
1197
|
+
# Only a genuinely multi-class union ("the value is an A or a B") gains
|
|
1198
|
+
# from this rule. A union whose arms all resolve to ONE class
|
|
1199
|
+
# (`Hash[K1, V1] | Hash[K2, V2]`) is a shape-join artifact — checking
|
|
1200
|
+
# method existence there is the scalar rule's job, and when the join is
|
|
1201
|
+
# a misinference it is a false positive (a corpus probe caught mail's
|
|
1202
|
+
# `compose_codepoints` typed `Hash | Hash` for an `Array`, flagging
|
|
1203
|
+
# `.pack`). Require at least two distinct arm classes.
|
|
1204
|
+
return nil if members.map { |member| concrete_class_name(member) }.uniq.size < 2
|
|
1205
|
+
return nil if members.any? { |member| method_present_anywhere?(member, call_node.name, scope) }
|
|
1206
|
+
|
|
1207
|
+
build_undefined_method_diagnostic(path, call_node, receiver_type)
|
|
1208
|
+
end
|
|
1209
|
+
|
|
1210
|
+
# An arm that makes a sound "undefined on every arm" verdict
|
|
1211
|
+
# impossible: a non-class surface (Dynamic / Top / Bot), a singleton
|
|
1212
|
+
# (slice 1 reasons about instance arms only), the generic metaclass
|
|
1213
|
+
# `Class` / `Module` (a value typed as one is *some* class/module object
|
|
1214
|
+
# whose singleton methods cannot be enumerated from the metaclass — e.g.
|
|
1215
|
+
# `plugin_class : Class` really holds a `Plugin` subclass with
|
|
1216
|
+
# `.manifest`), an unbounded receiver (ADR-26 open class or a synthesized
|
|
1217
|
+
# stub), or a module mixin whose Object-inherited methods the per-arm
|
|
1218
|
+
# lookup would miss.
|
|
1219
|
+
METACLASS_ARMS = %w[Class Module].to_set.freeze
|
|
1220
|
+
private_constant :METACLASS_ARMS
|
|
1221
|
+
|
|
1222
|
+
def union_arm_blocks_undefined_fire?(member, scope)
|
|
1223
|
+
class_name = concrete_class_name(member)
|
|
1224
|
+
return true if class_name.nil?
|
|
1225
|
+
return true if member.is_a?(Type::Singleton)
|
|
1226
|
+
return true if METACLASS_ARMS.include?(class_name)
|
|
1227
|
+
return true if unbounded_receiver_surface?(class_name, scope)
|
|
1228
|
+
|
|
1229
|
+
module_mixin_receiver?(member, scope)
|
|
1230
|
+
end
|
|
1231
|
+
|
|
922
1232
|
# Slice 7 phase 19 — PHPStan-style `dump_type(value)`.
|
|
923
1233
|
# When the engine recognises a call to `dump_type` (with
|
|
924
1234
|
# any of the supported receiver shapes — implicit self
|
|
@@ -1011,27 +1321,12 @@ module Rigor
|
|
|
1011
1321
|
receiver = call_node.receiver
|
|
1012
1322
|
return true if receiver.nil?
|
|
1013
1323
|
|
|
1014
|
-
name =
|
|
1324
|
+
name = Source::ConstantPath.qualified_name_or_nil(receiver)
|
|
1015
1325
|
return false if name.nil?
|
|
1016
1326
|
|
|
1017
1327
|
RIGOR_TESTING_RECEIVERS.include?(name)
|
|
1018
1328
|
end
|
|
1019
1329
|
|
|
1020
|
-
def constant_name_of(node)
|
|
1021
|
-
case node
|
|
1022
|
-
when Prism::ConstantReadNode then node.name.to_s
|
|
1023
|
-
when Prism::ConstantPathNode then render_constant_path(node)
|
|
1024
|
-
end
|
|
1025
|
-
end
|
|
1026
|
-
|
|
1027
|
-
def render_constant_path(node)
|
|
1028
|
-
parent = node.parent
|
|
1029
|
-
base = constant_name_of(parent)
|
|
1030
|
-
return nil if parent && base.nil?
|
|
1031
|
-
|
|
1032
|
-
parent ? "#{base}::#{node.name}" : node.name.to_s
|
|
1033
|
-
end
|
|
1034
|
-
|
|
1035
1330
|
def build_assert_type_diagnostic(path, call_node, expected, actual)
|
|
1036
1331
|
Diagnostic.from_message_loc(
|
|
1037
1332
|
call_node,
|
|
@@ -1048,7 +1343,8 @@ module Rigor
|
|
|
1048
1343
|
rule: RULE_NIL_RECEIVER,
|
|
1049
1344
|
path: path,
|
|
1050
1345
|
message: "possible nil receiver: `#{call_node.name}' is undefined on NilClass",
|
|
1051
|
-
severity: :error
|
|
1346
|
+
severity: :error,
|
|
1347
|
+
method_name: call_node.name.to_s
|
|
1052
1348
|
)
|
|
1053
1349
|
end
|
|
1054
1350
|
|
|
@@ -1236,7 +1532,9 @@ module Rigor
|
|
|
1236
1532
|
rule: RULE_VISIBILITY_MISMATCH,
|
|
1237
1533
|
path: path,
|
|
1238
1534
|
message: "private method `#{call_node.name}' called on #{receiver_type.class_name} receiver",
|
|
1239
|
-
severity: :error
|
|
1535
|
+
severity: :error,
|
|
1536
|
+
receiver_type: receiver_type.class_name,
|
|
1537
|
+
method_name: call_node.name.to_s
|
|
1240
1538
|
)
|
|
1241
1539
|
end
|
|
1242
1540
|
|
|
@@ -1382,6 +1680,21 @@ module Rigor
|
|
|
1382
1680
|
UNIVERSAL_EQUALITY_METHODS = %i[== != eql? equal? <=>].to_set.freeze
|
|
1383
1681
|
private_constant :UNIVERSAL_EQUALITY_METHODS
|
|
1384
1682
|
|
|
1683
|
+
# ADR-64 WD1 — the binary arithmetic / bit / ordering operators
|
|
1684
|
+
# dispatch through Ruby's `coerce` protocol (and `<=>` for the
|
|
1685
|
+
# comparisons): `5 + Money.new` is valid at runtime because
|
|
1686
|
+
# `Integer#+` calls `Money#coerce(5)`, even though no RBS `Integer#+`
|
|
1687
|
+
# overload lists `Money`. A non-`Numeric` argument to them is therefore
|
|
1688
|
+
# NOT statically refutable — any user type may define `coerce` — so the
|
|
1689
|
+
# *non-nil* argument-type-mismatch channel excludes them (a fixed
|
|
1690
|
+
# allow-list, modelled on {UNIVERSAL_EQUALITY_METHODS}, not `coerce`
|
|
1691
|
+
# detection). `nil` never coerces, so the nil channel stays in force
|
|
1692
|
+
# here; the exclusion applies to the non-nil case only. `<=>` and the
|
|
1693
|
+
# `==` family are already excluded wholesale by
|
|
1694
|
+
# {UNIVERSAL_EQUALITY_METHODS}.
|
|
1695
|
+
COERCE_DISPATCH_METHODS = %i[+ - * / % ** & | ^ << >> < > <= >=].to_set.freeze
|
|
1696
|
+
private_constant :COERCE_DISPATCH_METHODS
|
|
1697
|
+
|
|
1385
1698
|
def argument_type_diagnostic(path, call_node, scope_index)
|
|
1386
1699
|
return nil if call_node.receiver.nil?
|
|
1387
1700
|
return nil if UNIVERSAL_EQUALITY_METHODS.include?(call_node.name)
|
|
@@ -1405,15 +1718,266 @@ module Rigor
|
|
|
1405
1718
|
|
|
1406
1719
|
method_def = lookup_method(receiver_type, class_name, call_node.name, scope)
|
|
1407
1720
|
return nil if method_def.nil? || method_def == true
|
|
1408
|
-
return nil unless method_def.method_types.size == 1
|
|
1409
1721
|
|
|
1410
1722
|
param_overrides = Rigor::RbsExtended.param_type_override_map(method_def, environment: scope.environment)
|
|
1411
|
-
mismatch =
|
|
1723
|
+
mismatch = argument_mismatch(method_def.method_types, call_node, scope, param_overrides)
|
|
1412
1724
|
return nil if mismatch.nil?
|
|
1413
1725
|
|
|
1414
1726
|
build_argument_type_diagnostic(path, call_node, class_name, mismatch)
|
|
1415
1727
|
end
|
|
1416
1728
|
|
|
1729
|
+
# Single overload → the exact per-argument acceptance (unchanged).
|
|
1730
|
+
# Multiple overloads → the nil channel (a pure-`nil` argument every
|
|
1731
|
+
# overload rejects) plus, on non-coerce methods, the non-nil channel
|
|
1732
|
+
# (a single-concrete-class argument every overload rejects). See
|
|
1733
|
+
# {#multi_overload_argument_mismatch}.
|
|
1734
|
+
def argument_mismatch(method_types, call_node, scope, param_overrides)
|
|
1735
|
+
if method_types.size == 1
|
|
1736
|
+
first_argument_mismatch(method_types.first, call_node, scope, param_overrides)
|
|
1737
|
+
else
|
|
1738
|
+
multi_overload_argument_mismatch(method_types, call_node, scope, param_overrides)
|
|
1739
|
+
end
|
|
1740
|
+
end
|
|
1741
|
+
|
|
1742
|
+
# Multi-overload argument-type-mismatch. The dispatcher's per-overload
|
|
1743
|
+
# acceptance plumbing is not run here; instead the FP-safe shape mirrors
|
|
1744
|
+
# the "absent on every arm" union-undefined-method rule: an argument is
|
|
1745
|
+
# a mismatch only when EVERY overload's matching positional param
|
|
1746
|
+
# rejects it.
|
|
1747
|
+
#
|
|
1748
|
+
# Two channels, both gated on a positively-refuted argument:
|
|
1749
|
+
# - **nil** (any method): a pure `nil` no overload admits is a
|
|
1750
|
+
# guaranteed `TypeError` — `nil` never coerces.
|
|
1751
|
+
# - **non-nil** (ADR-64, non-coerce methods only): an argument that
|
|
1752
|
+
# types to a single concrete RBS-known class that no overload admits.
|
|
1753
|
+
# Excludes {COERCE_DISPATCH_METHODS} (`5 + Money.new` is valid via
|
|
1754
|
+
# `coerce`), restricts to a single concrete class (WD3 — a union arg
|
|
1755
|
+
# stays deferred), and decides acceptance on the RBS param type
|
|
1756
|
+
# ({#param_accepts_arg_class?}) so it sees through the `int` / `string`
|
|
1757
|
+
# interface-aliases the translator degrades.
|
|
1758
|
+
def multi_overload_argument_mismatch(method_types, call_node, scope, param_overrides)
|
|
1759
|
+
functions = method_types.map(&:type)
|
|
1760
|
+
return nil unless functions.all? { |function| argument_check_eligible?(function) }
|
|
1761
|
+
|
|
1762
|
+
coerce_method = COERCE_DISPATCH_METHODS.include?(call_node.name)
|
|
1763
|
+
arguments = call_node.arguments&.arguments || []
|
|
1764
|
+
arguments.each_with_index do |arg, index|
|
|
1765
|
+
arg_type = scope.type_of(arg)
|
|
1766
|
+
params = overload_positional_params(functions, index)
|
|
1767
|
+
next if params.nil? # arity divergence — some overload lacks a param here
|
|
1768
|
+
|
|
1769
|
+
mismatch =
|
|
1770
|
+
if nil_member?(arg_type) # pure nil only — not a `T | nil` union
|
|
1771
|
+
nil_arg_overload_mismatch(arg, arg_type, params, param_overrides, scope)
|
|
1772
|
+
elsif !coerce_method
|
|
1773
|
+
non_nil_arg_overload_mismatch(arg, arg_type, params, param_overrides, scope)
|
|
1774
|
+
end
|
|
1775
|
+
return mismatch if mismatch
|
|
1776
|
+
end
|
|
1777
|
+
nil
|
|
1778
|
+
end
|
|
1779
|
+
|
|
1780
|
+
# The nil channel: a pure `nil` argument no overload admits (ADR-58
|
|
1781
|
+
# parity excuses a declaration-sourced ivar nil).
|
|
1782
|
+
def nil_arg_overload_mismatch(arg, arg_type, params, param_overrides, scope)
|
|
1783
|
+
return nil if declaration_sourced_nil_argument?(arg, scope)
|
|
1784
|
+
return nil if params.any? { |param| param_admits_nil?(param, param_overrides, scope) }
|
|
1785
|
+
|
|
1786
|
+
{ node: arg, name: nil, expected: overload_param_expected_label(params), actual: arg_type }
|
|
1787
|
+
end
|
|
1788
|
+
|
|
1789
|
+
# The non-nil channel (ADR-64 WD2/WD3): a single-concrete-class
|
|
1790
|
+
# argument no overload admits, on a non-coerce method.
|
|
1791
|
+
def non_nil_arg_overload_mismatch(arg, arg_type, params, param_overrides, scope)
|
|
1792
|
+
return nil unless single_concrete_arg_class?(arg_type, scope)
|
|
1793
|
+
return nil if params.any? { |param| param_accepts_arg_class?(param, arg_type, param_overrides, scope) }
|
|
1794
|
+
|
|
1795
|
+
{ node: arg, name: nil, expected: overload_param_expected_label(params), actual: arg_type }
|
|
1796
|
+
end
|
|
1797
|
+
|
|
1798
|
+
# The matching positional RBS param across every overload, or nil when
|
|
1799
|
+
# any overload has no param at `index` (arity divergence — the
|
|
1800
|
+
# wrong-arity rule's concern, not this one's).
|
|
1801
|
+
def overload_positional_params(functions, index)
|
|
1802
|
+
params = functions.map { |function| (function.required_positionals + function.optional_positionals)[index] }
|
|
1803
|
+
params.any?(&:nil?) ? nil : params
|
|
1804
|
+
end
|
|
1805
|
+
|
|
1806
|
+
# The class names whose instances `nil` IS — `NilClass` and every
|
|
1807
|
+
# ancestor. A parameter typed as any other class instance rejects nil.
|
|
1808
|
+
NIL_COMPATIBLE_CLASS_NAMES = %w[NilClass Object BasicObject Kernel].to_set.freeze
|
|
1809
|
+
private_constant :NIL_COMPATIBLE_CLASS_NAMES
|
|
1810
|
+
|
|
1811
|
+
# Does this parameter admit a `nil` argument? Decided on the RBS
|
|
1812
|
+
# parameter type (a `rigor:v1:param` override takes precedence).
|
|
1813
|
+
# Conservative throughout: any case we cannot decide returns true
|
|
1814
|
+
# (admits → do not fire), so the rule never fires on uncertainty.
|
|
1815
|
+
def param_admits_nil?(param, param_overrides, scope)
|
|
1816
|
+
override = param_overrides[param.name]
|
|
1817
|
+
return rigor_type_admits_nil?(override) if override
|
|
1818
|
+
|
|
1819
|
+
rbs_type_admits_nil?(param.type, scope)
|
|
1820
|
+
end
|
|
1821
|
+
|
|
1822
|
+
# The `rigor:v1:param` override variant — a refinement
|
|
1823
|
+
# (`non-empty-string`) rejects nil; an explicit nil / nilable union /
|
|
1824
|
+
# gradual override admits it.
|
|
1825
|
+
def rigor_type_admits_nil?(type)
|
|
1826
|
+
return true if type.is_a?(Type::Dynamic) || type.is_a?(Type::Top)
|
|
1827
|
+
return true if nil_member?(type)
|
|
1828
|
+
return union_contains_nil?(type) if type.is_a?(Type::Union)
|
|
1829
|
+
|
|
1830
|
+
false
|
|
1831
|
+
end
|
|
1832
|
+
|
|
1833
|
+
# Walks the RBS parameter type. The load-bearing cases are `Alias`
|
|
1834
|
+
# (`string` = `String | _ToStr`) and `Interface` (`_ToStr`), which
|
|
1835
|
+
# {Inference::RbsTypeTranslator} degrades to `untyped` — the reason a
|
|
1836
|
+
# `nil` argument is invisible after translation (the interface-alias
|
|
1837
|
+
# gap). Resolving them here recovers the rejection. Only a concrete
|
|
1838
|
+
# class instance that is not a `nil` ancestor, and an interface
|
|
1839
|
+
# NilClass does not satisfy, return false; everything else admits.
|
|
1840
|
+
def rbs_type_admits_nil?(rbs_type, scope)
|
|
1841
|
+
case rbs_type
|
|
1842
|
+
when RBS::Types::Union then rbs_type.types.any? { |member| rbs_type_admits_nil?(member, scope) }
|
|
1843
|
+
when RBS::Types::Alias
|
|
1844
|
+
expanded = scope.environment&.rbs_loader&.expand_type_alias(rbs_type)
|
|
1845
|
+
expanded.nil? || rbs_type_admits_nil?(expanded, scope)
|
|
1846
|
+
when RBS::Types::ClassInstance
|
|
1847
|
+
NIL_COMPATIBLE_CLASS_NAMES.include?(rbs_type.name.to_s.delete_prefix("::"))
|
|
1848
|
+
when RBS::Types::Interface then interface_admits_nil?(rbs_type, scope)
|
|
1849
|
+
else true # Optional / bases / variable / tuple / record / proc / literal / intersection → conservative admit
|
|
1850
|
+
end
|
|
1851
|
+
end
|
|
1852
|
+
|
|
1853
|
+
# An interface parameter (`_ToStr`) admits nil only when NilClass
|
|
1854
|
+
# implements every method it requires (`to_str`, `to_int`, … — which
|
|
1855
|
+
# NilClass does not, so `string` / `int` params reject nil; a
|
|
1856
|
+
# hypothetical `_ToS` would admit, since NilClass#to_s exists).
|
|
1857
|
+
# Unresolvable → conservative true.
|
|
1858
|
+
def interface_admits_nil?(rbs_type, scope)
|
|
1859
|
+
loader = scope.environment&.rbs_loader
|
|
1860
|
+
return true if loader.nil?
|
|
1861
|
+
|
|
1862
|
+
methods = loader.interface_method_names(rbs_type.name.to_s)
|
|
1863
|
+
return true if methods.nil? || methods.empty?
|
|
1864
|
+
|
|
1865
|
+
methods.all? { |method_name| nil_class_has_method?(method_name, scope) }
|
|
1866
|
+
end
|
|
1867
|
+
|
|
1868
|
+
# ADR-64 WD3 — the non-nil channel fires only on an argument that types
|
|
1869
|
+
# to a single concrete, RBS-known class. A union arg mirrors the
|
|
1870
|
+
# union-receiver story and stays deferred; a class/module object
|
|
1871
|
+
# (`Singleton`) has a special acceptance surface and is skipped; a
|
|
1872
|
+
# non-RBS project class is skipped because its conversion protocol (a
|
|
1873
|
+
# duck-typed `to_int` / `to_str`) is invisible to us, so we cannot
|
|
1874
|
+
# refute acceptance.
|
|
1875
|
+
def single_concrete_arg_class?(arg_type, scope)
|
|
1876
|
+
return false if arg_type.is_a?(Type::Union)
|
|
1877
|
+
return false if arg_type.is_a?(Type::Singleton)
|
|
1878
|
+
|
|
1879
|
+
class_name = concrete_class_name(arg_type)
|
|
1880
|
+
return false if class_name.nil?
|
|
1881
|
+
|
|
1882
|
+
Rigor::Reflection.rbs_class_known?(class_name, scope: scope)
|
|
1883
|
+
end
|
|
1884
|
+
|
|
1885
|
+
# ADR-64 WD2 — does this parameter accept the (non-nil) argument?
|
|
1886
|
+
# The non-nil generalization of {#param_admits_nil?}: decided on the RBS
|
|
1887
|
+
# parameter type (a `rigor:v1:param` override takes precedence) so it
|
|
1888
|
+
# sees through the `int` / `string` interface-aliases the translator
|
|
1889
|
+
# degrades to gradual. Conservative throughout — any case we cannot
|
|
1890
|
+
# decide returns true (accepts → do not fire).
|
|
1891
|
+
def param_accepts_arg_class?(param, arg_type, param_overrides, scope)
|
|
1892
|
+
override = param_overrides[param.name]
|
|
1893
|
+
return rigor_type_accepts_arg?(override, arg_type) if override
|
|
1894
|
+
|
|
1895
|
+
rbs_type_accepts_arg?(param.type, arg_type, scope)
|
|
1896
|
+
end
|
|
1897
|
+
|
|
1898
|
+
# The `rigor:v1:param` override variant — a Rigor `Type`, so the
|
|
1899
|
+
# acceptance engine decides directly (gradual; only a proven rejection
|
|
1900
|
+
# refutes). Dynamic / Top admit unconditionally.
|
|
1901
|
+
def rigor_type_accepts_arg?(param_type, arg_type)
|
|
1902
|
+
return true if param_type.is_a?(Type::Dynamic) || param_type.is_a?(Type::Top)
|
|
1903
|
+
|
|
1904
|
+
!Inference::Acceptance.accepts(param_type, arg_type, mode: :gradual).no?
|
|
1905
|
+
end
|
|
1906
|
+
|
|
1907
|
+
# Walks the RBS parameter type, mirroring {#rbs_type_admits_nil?}. The
|
|
1908
|
+
# load-bearing cases are `Alias` / `Interface` (`int` = `Integer |
|
|
1909
|
+
# _ToInt`), which the translator degrades to gradual — resolving them
|
|
1910
|
+
# here recovers the rejection. A faithfully-translated `ClassInstance`
|
|
1911
|
+
# is handed to the acceptance engine; everything undecidable admits.
|
|
1912
|
+
def rbs_type_accepts_arg?(rbs_type, arg_type, scope)
|
|
1913
|
+
case rbs_type
|
|
1914
|
+
when RBS::Types::Union then rbs_type.types.any? { |member| rbs_type_accepts_arg?(member, arg_type, scope) }
|
|
1915
|
+
when RBS::Types::Alias
|
|
1916
|
+
expanded = scope.environment&.rbs_loader&.expand_type_alias(rbs_type)
|
|
1917
|
+
expanded.nil? || rbs_type_accepts_arg?(expanded, arg_type, scope)
|
|
1918
|
+
when RBS::Types::ClassInstance then class_instance_accepts_arg?(rbs_type, arg_type, scope)
|
|
1919
|
+
when RBS::Types::Interface then interface_accepts_arg?(rbs_type, arg_type, scope)
|
|
1920
|
+
else true # bases / variable / tuple / record / proc / literal / intersection / optional → conservative admit
|
|
1921
|
+
end
|
|
1922
|
+
end
|
|
1923
|
+
|
|
1924
|
+
# A `ClassInstance` param (`Integer`, `Numeric`, …) is translated
|
|
1925
|
+
# faithfully (no interface degradation), so the acceptance engine — the
|
|
1926
|
+
# canonical RBS-ancestry / generic-aware subtype check — decides it.
|
|
1927
|
+
# Only a proven rejection refutes; an unresolvable class is `:maybe`,
|
|
1928
|
+
# which admits.
|
|
1929
|
+
def class_instance_accepts_arg?(rbs_type, arg_type, scope)
|
|
1930
|
+
translated = translate_param_type(rbs_type, scope.environment)
|
|
1931
|
+
return true if translated.is_a?(Type::Dynamic) || translated.is_a?(Type::Top)
|
|
1932
|
+
|
|
1933
|
+
!Inference::Acceptance.accepts(translated, arg_type, mode: :gradual).no?
|
|
1934
|
+
end
|
|
1935
|
+
|
|
1936
|
+
# An interface param (`_ToInt`) accepts the arg only when the arg's
|
|
1937
|
+
# class implements every method it requires (`to_int`, …). The non-nil
|
|
1938
|
+
# mirror of {#interface_admits_nil?}: ask the arg class, not NilClass.
|
|
1939
|
+
# Unresolvable anywhere → conservative true (admit).
|
|
1940
|
+
def interface_accepts_arg?(rbs_type, arg_type, scope)
|
|
1941
|
+
loader = scope.environment&.rbs_loader
|
|
1942
|
+
return true if loader.nil?
|
|
1943
|
+
|
|
1944
|
+
methods = loader.interface_method_names(rbs_type.name.to_s)
|
|
1945
|
+
return true if methods.nil? || methods.empty?
|
|
1946
|
+
|
|
1947
|
+
class_name = concrete_class_name(arg_type)
|
|
1948
|
+
return true if class_name.nil?
|
|
1949
|
+
|
|
1950
|
+
methods.all? { |method_name| arg_class_has_method?(class_name, method_name, scope) }
|
|
1951
|
+
end
|
|
1952
|
+
|
|
1953
|
+
# The non-nil mirror of {#nil_class_has_method?}, but conservative on
|
|
1954
|
+
# the unknown side: an unresolvable definition returns true (the class
|
|
1955
|
+
# *might* implement the method — e.g. a metaprogrammed conversion), so
|
|
1956
|
+
# the channel never fires on uncertainty.
|
|
1957
|
+
def arg_class_has_method?(class_name, method_name, scope)
|
|
1958
|
+
definition = Rigor::Reflection.instance_definition(class_name, scope: scope)
|
|
1959
|
+
return true if definition.nil?
|
|
1960
|
+
|
|
1961
|
+
!definition.methods[method_name.to_sym].nil?
|
|
1962
|
+
end
|
|
1963
|
+
|
|
1964
|
+
# A readable "expected" label for a multi-overload mismatch — the RBS
|
|
1965
|
+
# parameter type(s) as written (`string`, or the per-overload set), since
|
|
1966
|
+
# the translated Rigor type degrades the interface-alias the rejection
|
|
1967
|
+
# hinges on. Shared by the nil and non-nil channels.
|
|
1968
|
+
def overload_param_expected_label(params)
|
|
1969
|
+
params.map { |param| param.type.to_s.delete_prefix("::") }.uniq.join(" | ")
|
|
1970
|
+
end
|
|
1971
|
+
|
|
1972
|
+
# ADR-58 parity for the nil channel: a declaration-sourced ivar read
|
|
1973
|
+
# that types as nil is the same not-diagnostic-fuel case the union
|
|
1974
|
+
# path gates in {#declaration_sourced_nil_only_mismatch?}; suppress it
|
|
1975
|
+
# here too so a ctor-seeded `@x = nil` read passed as an argument does
|
|
1976
|
+
# not fire on a working program's cross-method invariant.
|
|
1977
|
+
def declaration_sourced_nil_argument?(arg, scope)
|
|
1978
|
+
arg.is_a?(Prism::InstanceVariableReadNode) && scope.declaration_sourced?(:ivar, arg.name)
|
|
1979
|
+
end
|
|
1980
|
+
|
|
1417
1981
|
def first_argument_mismatch(method_type, call_node, scope, param_overrides)
|
|
1418
1982
|
function = method_type.type
|
|
1419
1983
|
return nil unless argument_check_eligible?(function)
|
|
@@ -1424,21 +1988,71 @@ module Rigor
|
|
|
1424
1988
|
param = params[index]
|
|
1425
1989
|
next if param.nil? # arity mismatch is the wrong-arity rule's concern.
|
|
1426
1990
|
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
param_type = param_overrides[param.name] || translate_param_type(param.type, scope.environment)
|
|
1433
|
-
next if param_type.is_a?(Type::Dynamic) || param_type.is_a?(Type::Top)
|
|
1991
|
+
mismatch = single_argument_mismatch(param, arg, scope, param_overrides)
|
|
1992
|
+
return mismatch if mismatch
|
|
1993
|
+
end
|
|
1994
|
+
nil
|
|
1995
|
+
end
|
|
1434
1996
|
|
|
1435
|
-
|
|
1436
|
-
|
|
1997
|
+
# The mismatch (or nil) for one positional argument against one
|
|
1998
|
+
# parameter. The nil channel decides a pure `nil` argument on the RBS
|
|
1999
|
+
# parameter type — seeing through the `string` / `int` interface-alias
|
|
2000
|
+
# the translator degrades to gradual (so `"a" + nil` fires), excusing a
|
|
2001
|
+
# declaration-sourced ivar nil (ADR-58 parity). The non-nil channel is
|
|
2002
|
+
# the original translated-acceptance check, with a `rigor:v1:param`
|
|
2003
|
+
# override taking precedence over the RBS-declared type.
|
|
2004
|
+
def single_argument_mismatch(param, arg, scope, param_overrides)
|
|
2005
|
+
arg_type = scope.type_of(arg)
|
|
2006
|
+
|
|
2007
|
+
if nil_member?(arg_type)
|
|
2008
|
+
return nil if declaration_sourced_nil_argument?(arg, scope)
|
|
2009
|
+
return nil if param_admits_nil?(param, param_overrides, scope)
|
|
1437
2010
|
|
|
1438
|
-
|
|
1439
|
-
return { node: arg, name: param.name, expected: param_type, actual: arg_type } if result.no?
|
|
2011
|
+
return { node: arg, name: param.name, expected: overload_param_expected_label([param]), actual: arg_type }
|
|
1440
2012
|
end
|
|
1441
|
-
|
|
2013
|
+
|
|
2014
|
+
param_type = param_overrides[param.name] || translate_param_type(param.type, scope.environment)
|
|
2015
|
+
return nil if param_type.is_a?(Type::Dynamic) || param_type.is_a?(Type::Top)
|
|
2016
|
+
return nil if arg_type.is_a?(Type::Dynamic) || arg_type.is_a?(Type::Top)
|
|
2017
|
+
return nil unless argument_genuinely_mismatches?(arg, arg_type, param_type, scope)
|
|
2018
|
+
|
|
2019
|
+
{ node: arg, name: param.name, expected: param_type, actual: arg_type }
|
|
2020
|
+
end
|
|
2021
|
+
|
|
2022
|
+
# The parameter rejects the argument AND the rejection is not a
|
|
2023
|
+
# withheld declaration-sourced-nil case.
|
|
2024
|
+
def argument_genuinely_mismatches?(arg, arg_type, param_type, scope)
|
|
2025
|
+
return false unless Inference::Acceptance.accepts(param_type, arg_type, mode: :gradual).no?
|
|
2026
|
+
|
|
2027
|
+
# ADR-58 (N2 extension) — the same declaration-sourced-nil-is-not-
|
|
2028
|
+
# diagnostic-fuel criterion that governs `possible-nil-receiver`
|
|
2029
|
+
# applies here. When the only reason the argument is rejected is a
|
|
2030
|
+
# *declaration-sourced* nil constituent (the class-ivar index seed
|
|
2031
|
+
# of a ctor `@x = nil` / a non-definitely-assigned ivar read), and
|
|
2032
|
+
# the argument type with that nil removed WOULD be accepted, the
|
|
2033
|
+
# working program's cross-method invariant is assumed and we do not
|
|
2034
|
+
# fire. Flow-live nil (a method-local `@x = nil` write, a failed-
|
|
2035
|
+
# guard narrowing) drops the provenance mark upstream and still
|
|
2036
|
+
# fires. The argument's type is unchanged — only the firing
|
|
2037
|
+
# decision is gated.
|
|
2038
|
+
!declaration_sourced_nil_only_mismatch?(arg, arg_type, param_type, scope)
|
|
2039
|
+
end
|
|
2040
|
+
|
|
2041
|
+
# True when `arg` is a declaration-sourced ivar read whose rejection is
|
|
2042
|
+
# caused solely by its nil constituent: stripping nil from the argument
|
|
2043
|
+
# type yields a type the parameter accepts (gradual mode). Mirrors the
|
|
2044
|
+
# `possible-nil-receiver` WD1 gate, keyed on the ivar provenance mark
|
|
2045
|
+
# rather than a local copy.
|
|
2046
|
+
def declaration_sourced_nil_only_mismatch?(arg, arg_type, param_type, scope)
|
|
2047
|
+
return false unless arg.is_a?(Prism::InstanceVariableReadNode)
|
|
2048
|
+
return false unless scope.declaration_sourced?(:ivar, arg.name)
|
|
2049
|
+
return false unless arg_type.is_a?(Type::Union)
|
|
2050
|
+
return false unless union_contains_nil?(arg_type)
|
|
2051
|
+
|
|
2052
|
+
non_nil = Type::Combinator.union(*arg_type.members.reject { |m| nil_member?(m) })
|
|
2053
|
+
return false if non_nil.is_a?(Type::Bot)
|
|
2054
|
+
|
|
2055
|
+
Inference::Acceptance.accepts(param_type, non_nil, mode: :gradual).yes?
|
|
1442
2056
|
end
|
|
1443
2057
|
|
|
1444
2058
|
def argument_check_eligible?(function)
|
|
@@ -1463,15 +2077,19 @@ module Rigor
|
|
|
1463
2077
|
def build_argument_type_diagnostic(path, call_node, class_name, mismatch)
|
|
1464
2078
|
method_label = "`#{call_node.name}' on #{class_name}"
|
|
1465
2079
|
parameter_label = mismatch[:name] ? "parameter `#{mismatch[:name]}' of #{method_label}" : method_label
|
|
2080
|
+
expected = mismatch[:expected]
|
|
2081
|
+
expected_description = expected.is_a?(String) ? expected : expected.describe(:short)
|
|
1466
2082
|
message = "argument type mismatch at #{parameter_label}: " \
|
|
1467
|
-
"expected #{
|
|
2083
|
+
"expected #{expected_description}, " \
|
|
1468
2084
|
"got #{mismatch[:actual].describe(:short)}"
|
|
1469
2085
|
Diagnostic.from_node(
|
|
1470
2086
|
mismatch[:node],
|
|
1471
2087
|
rule: RULE_ARGUMENT_TYPE,
|
|
1472
2088
|
path: path,
|
|
1473
2089
|
message: message,
|
|
1474
|
-
severity: :error
|
|
2090
|
+
severity: :error,
|
|
2091
|
+
receiver_type: class_name,
|
|
2092
|
+
method_name: call_node.name.to_s
|
|
1475
2093
|
)
|
|
1476
2094
|
end
|
|
1477
2095
|
|
|
@@ -1484,7 +2102,9 @@ module Rigor
|
|
|
1484
2102
|
rule: RULE_WRONG_ARITY,
|
|
1485
2103
|
path: path,
|
|
1486
2104
|
message: message,
|
|
1487
|
-
severity: :error
|
|
2105
|
+
severity: :error,
|
|
2106
|
+
receiver_type: class_name,
|
|
2107
|
+
method_name: call_node.name.to_s
|
|
1488
2108
|
)
|
|
1489
2109
|
end
|
|
1490
2110
|
|
|
@@ -1657,7 +2277,8 @@ module Rigor
|
|
|
1657
2277
|
path: path,
|
|
1658
2278
|
message: "return-type mismatch on `#{def_node.name}': " \
|
|
1659
2279
|
"declared #{declared.describe(:short)}, inferred #{inferred.describe(:short)}",
|
|
1660
|
-
severity: severity
|
|
2280
|
+
severity: severity,
|
|
2281
|
+
method_name: def_node.name.to_s
|
|
1661
2282
|
)
|
|
1662
2283
|
end
|
|
1663
2284
|
|
|
@@ -1809,7 +2430,8 @@ module Rigor
|
|
|
1809
2430
|
message: "visibility of `#{def_node.name}' reduced from #{parent_visibility} to " \
|
|
1810
2431
|
"#{override_visibility} (overrides #{parent_class}##{def_node.name}); " \
|
|
1811
2432
|
"breaks substitutability",
|
|
1812
|
-
severity: :warning
|
|
2433
|
+
severity: :warning,
|
|
2434
|
+
method_name: def_node.name.to_s
|
|
1813
2435
|
)
|
|
1814
2436
|
end
|
|
1815
2437
|
|
|
@@ -1833,7 +2455,14 @@ module Rigor
|
|
|
1833
2455
|
# everything, so they stay silent (FP-safe). `self`/`instance`
|
|
1834
2456
|
# are translated with `self_type: nil` on both sides, so a
|
|
1835
2457
|
# parent `-> self` and an override `-> self` never fire.
|
|
1836
|
-
|
|
2458
|
+
# The authored-override resolution shared by the Liskov override
|
|
2459
|
+
# rules (`def.override-return-widened` and
|
|
2460
|
+
# `def.override-param-narrowed`): the def must be an instance method
|
|
2461
|
+
# whose own class declares it in RBS, and a project-discovered
|
|
2462
|
+
# ancestor must also declare it. Returns
|
|
2463
|
+
# `[scope, override_method, parent_class, parent_method]`, or nil
|
|
2464
|
+
# (the rule does not fire) when any gate is unmet.
|
|
2465
|
+
def resolve_authored_override(def_node, scope_index)
|
|
1837
2466
|
return nil unless def_node.receiver.nil? # instance methods only (singleton: follow-on)
|
|
1838
2467
|
|
|
1839
2468
|
scope = scope_index[def_node]
|
|
@@ -1853,6 +2482,14 @@ module Rigor
|
|
|
1853
2482
|
return nil if parent.nil?
|
|
1854
2483
|
|
|
1855
2484
|
parent_class, parent_method = parent
|
|
2485
|
+
[scope, override_method, parent_class, parent_method]
|
|
2486
|
+
end
|
|
2487
|
+
|
|
2488
|
+
def override_return_widened_diagnostic(path, def_node, scope_index)
|
|
2489
|
+
resolved = resolve_authored_override(def_node, scope_index)
|
|
2490
|
+
return nil if resolved.nil?
|
|
2491
|
+
|
|
2492
|
+
scope, override_method, parent_class, parent_method = resolved
|
|
1856
2493
|
override_return = declared_return_union(override_method, scope.environment)
|
|
1857
2494
|
parent_return = declared_return_union(parent_method, scope.environment)
|
|
1858
2495
|
return nil if override_return.nil? || parent_return.nil?
|
|
@@ -1902,7 +2539,8 @@ module Rigor
|
|
|
1902
2539
|
message: "return type of `#{def_node.name}' widened from #{parent_return.describe(:short)} " \
|
|
1903
2540
|
"to #{override_return.describe(:short)} (overrides #{parent_class}##{def_node.name}); " \
|
|
1904
2541
|
"breaks substitutability",
|
|
1905
|
-
severity: :warning
|
|
2542
|
+
severity: :warning,
|
|
2543
|
+
method_name: def_node.name.to_s
|
|
1906
2544
|
)
|
|
1907
2545
|
end
|
|
1908
2546
|
|
|
@@ -1924,25 +2562,10 @@ module Rigor
|
|
|
1924
2562
|
# overload-arm ambiguity, both sides must have exactly one
|
|
1925
2563
|
# method type.
|
|
1926
2564
|
def override_param_narrowed_diagnostic(path, def_node, scope_index)
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
scope = scope_index[def_node]
|
|
1930
|
-
return nil if scope.nil?
|
|
1931
|
-
|
|
1932
|
-
self_type = scope.self_type
|
|
1933
|
-
return nil unless self_type.respond_to?(:class_name)
|
|
1934
|
-
|
|
1935
|
-
class_name = self_type.class_name.to_s
|
|
1936
|
-
method_name = def_node.name
|
|
2565
|
+
resolved = resolve_authored_override(def_node, scope_index)
|
|
2566
|
+
return nil if resolved.nil?
|
|
1937
2567
|
|
|
1938
|
-
override_method
|
|
1939
|
-
return nil if override_method.nil?
|
|
1940
|
-
return nil unless defined_on?(override_method, class_name)
|
|
1941
|
-
|
|
1942
|
-
parent = nearest_ancestor_method_def(scope, class_name, method_name)
|
|
1943
|
-
return nil if parent.nil?
|
|
1944
|
-
|
|
1945
|
-
parent_class, parent_method = parent
|
|
2568
|
+
_scope, override_method, parent_class, parent_method = resolved
|
|
1946
2569
|
override_params = positional_param_types(override_method)
|
|
1947
2570
|
parent_params = positional_param_types(parent_method)
|
|
1948
2571
|
return nil if override_params.nil? || parent_params.nil?
|
|
@@ -2006,7 +2629,8 @@ module Rigor
|
|
|
2006
2629
|
message: "parameter #{index + 1} of `#{def_node.name}' narrowed from " \
|
|
2007
2630
|
"#{parent_param.describe(:short)} to #{override_param.describe(:short)} " \
|
|
2008
2631
|
"(overrides #{parent_class}##{def_node.name}); breaks substitutability",
|
|
2009
|
-
severity: :warning
|
|
2632
|
+
severity: :warning,
|
|
2633
|
+
method_name: def_node.name.to_s
|
|
2010
2634
|
)
|
|
2011
2635
|
end
|
|
2012
2636
|
end
|