rigortype 0.1.16 → 0.1.17
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/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +100 -0
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +209 -0
- data/lib/rigor/analysis/check_rules.rb +149 -70
- data/lib/rigor/analysis/dependency_recorder.rb +122 -0
- data/lib/rigor/analysis/diagnostic.rb +18 -0
- data/lib/rigor/analysis/incremental.rb +162 -0
- data/lib/rigor/analysis/incremental_session.rb +337 -0
- data/lib/rigor/analysis/rule_catalog.rb +48 -0
- data/lib/rigor/analysis/runner.rb +434 -37
- data/lib/rigor/analysis/self_call_resolution_recorder.rb +121 -0
- data/lib/rigor/builtins/static_return_refinements.rb +7 -1
- data/lib/rigor/cache/descriptor.rb +50 -49
- data/lib/rigor/cache/incremental_snapshot.rb +147 -0
- data/lib/rigor/cache/rbs_cache_producer.rb +30 -0
- data/lib/rigor/cache/rbs_class_ancestor_table.rb +2 -8
- data/lib/rigor/cache/rbs_class_type_param_names.rb +2 -8
- data/lib/rigor/cache/rbs_constant_table.rb +2 -8
- data/lib/rigor/cache/rbs_environment.rb +2 -8
- data/lib/rigor/cache/rbs_instance_definitions.rb +3 -16
- data/lib/rigor/cache/rbs_known_class_names.rb +2 -8
- data/lib/rigor/cache/store.rb +99 -1
- data/lib/rigor/cli/annotate_command.rb +2 -7
- data/lib/rigor/cli/baseline_command.rb +2 -7
- data/lib/rigor/cli/command.rb +47 -0
- data/lib/rigor/cli/coverage_command.rb +3 -23
- data/lib/rigor/cli/coverage_renderer.rb +3 -8
- data/lib/rigor/cli/diff_command.rb +3 -7
- data/lib/rigor/cli/explain_command.rb +2 -7
- data/lib/rigor/cli/lsp_command.rb +3 -7
- data/lib/rigor/cli/mcp_command.rb +3 -7
- data/lib/rigor/cli/options.rb +57 -0
- data/lib/rigor/cli/plugin_command.rb +3 -7
- data/lib/rigor/cli/plugins_command.rb +2 -7
- data/lib/rigor/cli/renderable.rb +26 -0
- data/lib/rigor/cli/sig_gen_command.rb +2 -7
- data/lib/rigor/cli/skill_command.rb +3 -7
- data/lib/rigor/cli/triage_command.rb +2 -7
- data/lib/rigor/cli/type_of_command.rb +5 -38
- data/lib/rigor/cli/type_of_renderer.rb +4 -9
- data/lib/rigor/cli/type_scan_command.rb +3 -23
- data/lib/rigor/cli/type_scan_renderer.rb +4 -9
- data/lib/rigor/cli.rb +125 -43
- data/lib/rigor/configuration/dependencies.rb +18 -1
- data/lib/rigor/configuration/severity_profile.rb +22 -3
- data/lib/rigor/configuration.rb +13 -3
- data/lib/rigor/environment/rbs_loader.rb +76 -3
- data/lib/rigor/inference/block_parameter_binder.rb +1 -2
- data/lib/rigor/inference/builtins/array_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/complex_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/date_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/encoding_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/exception_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/hash_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/method_catalog.rb +15 -0
- data/lib/rigor/inference/builtins/numeric_catalog.rb +21 -93
- data/lib/rigor/inference/builtins/pathname_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/proc_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/random_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/range_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/rational_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/re_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/set_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/string_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/struct_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/time_catalog.rb +2 -5
- data/lib/rigor/inference/expression_typer.rb +140 -20
- data/lib/rigor/inference/method_dispatcher/block_folding.rb +5 -1
- data/lib/rigor/inference/method_dispatcher/call_context.rb +65 -0
- data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +11 -10
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +12 -6
- data/lib/rigor/inference/method_dispatcher/data_folding.rb +246 -0
- data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -2
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -2
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -1
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +4 -1
- data/lib/rigor/inference/method_dispatcher/math_folding.rb +6 -6
- data/lib/rigor/inference/method_dispatcher/method_folding.rb +12 -7
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +23 -13
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +9 -9
- data/lib/rigor/inference/method_dispatcher/set_folding.rb +6 -6
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +120 -9
- data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +12 -12
- data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +49 -0
- data/lib/rigor/inference/method_dispatcher/time_folding.rb +6 -6
- data/lib/rigor/inference/method_dispatcher/uri_folding.rb +9 -9
- data/lib/rigor/inference/method_dispatcher.rb +99 -59
- data/lib/rigor/inference/narrowing.rb +202 -5
- data/lib/rigor/inference/scope_indexer.rb +134 -7
- data/lib/rigor/inference/statement_evaluator.rb +105 -26
- data/lib/rigor/language_server/buffer_resolution.rb +33 -0
- data/lib/rigor/language_server/completion_provider.rb +4 -4
- data/lib/rigor/language_server/document_symbol_provider.rb +4 -4
- data/lib/rigor/language_server/folding_range_provider.rb +4 -4
- data/lib/rigor/language_server/hover_provider.rb +4 -4
- data/lib/rigor/language_server/selection_range_provider.rb +4 -4
- data/lib/rigor/language_server/signature_help_provider.rb +4 -4
- data/lib/rigor/plugin/base.rb +20 -4
- data/lib/rigor/plugin/registry.rb +39 -1
- data/lib/rigor/rbs_extended/conformance_checker.rb +208 -0
- data/lib/rigor/rbs_extended.rb +39 -0
- data/lib/rigor/scope.rb +123 -9
- data/lib/rigor/type/acceptance_router.rb +19 -0
- data/lib/rigor/type/accepts_result.rb +3 -10
- data/lib/rigor/type/app.rb +3 -7
- data/lib/rigor/type/bot.rb +2 -3
- data/lib/rigor/type/bound_method.rb +5 -12
- data/lib/rigor/type/combinator.rb +17 -0
- data/lib/rigor/type/constant.rb +2 -3
- data/lib/rigor/type/data_class.rb +80 -0
- data/lib/rigor/type/data_instance.rb +100 -0
- data/lib/rigor/type/difference.rb +5 -10
- data/lib/rigor/type/dynamic.rb +5 -10
- data/lib/rigor/type/hash_shape.rb +5 -15
- data/lib/rigor/type/integer_range.rb +5 -10
- data/lib/rigor/type/intersection.rb +5 -10
- data/lib/rigor/type/nominal.rb +5 -10
- data/lib/rigor/type/refined.rb +5 -10
- data/lib/rigor/type/singleton.rb +5 -10
- data/lib/rigor/type/top.rb +2 -3
- data/lib/rigor/type/tuple.rb +5 -10
- data/lib/rigor/type/union.rb +5 -10
- data/lib/rigor/type.rb +2 -0
- data/lib/rigor/value_semantics.rb +77 -0
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +1 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +12 -2
- data/sig/rigor/cache.rbs +19 -0
- data/sig/rigor/inference.rbs +22 -0
- data/sig/rigor/rbs_extended.rbs +2 -0
- data/sig/rigor/scope.rbs +5 -0
- data/sig/rigor/type.rbs +58 -1
- data/sig/rigor.rbs +6 -1
- metadata +22 -1
|
@@ -6,9 +6,12 @@ require_relative "../reflection"
|
|
|
6
6
|
require_relative "../source/node_walker"
|
|
7
7
|
require_relative "../type"
|
|
8
8
|
require_relative "diagnostic"
|
|
9
|
+
require_relative "dependency_recorder"
|
|
9
10
|
require_relative "check_rules/always_truthy_condition_collector"
|
|
11
|
+
require_relative "check_rules/unreachable_clause_collector"
|
|
10
12
|
require_relative "check_rules/dead_assignment_collector"
|
|
11
13
|
require_relative "check_rules/ivar_write_collector"
|
|
14
|
+
require_relative "check_rules/self_closedness_scanner"
|
|
12
15
|
|
|
13
16
|
module Rigor
|
|
14
17
|
module Analysis
|
|
@@ -57,6 +60,7 @@ module Rigor
|
|
|
57
60
|
# system; new rules MUST register here so user configuration
|
|
58
61
|
# can refer to them.
|
|
59
62
|
RULE_UNDEFINED_METHOD = "call.undefined-method"
|
|
63
|
+
RULE_SELF_UNDEFINED_METHOD = "call.self-undefined-method"
|
|
60
64
|
RULE_UNRESOLVED_TOPLEVEL = "call.unresolved-toplevel"
|
|
61
65
|
RULE_WRONG_ARITY = "call.wrong-arity"
|
|
62
66
|
RULE_ARGUMENT_TYPE = "call.argument-type-mismatch"
|
|
@@ -73,9 +77,11 @@ module Rigor
|
|
|
73
77
|
RULE_IVAR_WRITE_MISMATCH = "def.ivar-write-mismatch"
|
|
74
78
|
RULE_DEAD_ASSIGNMENT = "flow.dead-assignment"
|
|
75
79
|
RULE_ALWAYS_TRUTHY_CONDITION = "flow.always-truthy-condition"
|
|
80
|
+
RULE_UNREACHABLE_CLAUSE = "flow.unreachable-clause"
|
|
76
81
|
|
|
77
82
|
ALL_RULES = [
|
|
78
83
|
RULE_UNDEFINED_METHOD,
|
|
84
|
+
RULE_SELF_UNDEFINED_METHOD,
|
|
79
85
|
RULE_UNRESOLVED_TOPLEVEL,
|
|
80
86
|
RULE_WRONG_ARITY,
|
|
81
87
|
RULE_ARGUMENT_TYPE,
|
|
@@ -86,6 +92,7 @@ module Rigor
|
|
|
86
92
|
RULE_UNREACHABLE_BRANCH,
|
|
87
93
|
RULE_DEAD_ASSIGNMENT,
|
|
88
94
|
RULE_ALWAYS_TRUTHY_CONDITION,
|
|
95
|
+
RULE_UNREACHABLE_CLAUSE,
|
|
89
96
|
RULE_RETURN_TYPE,
|
|
90
97
|
RULE_VISIBILITY_MISMATCH,
|
|
91
98
|
RULE_OVERRIDE_VISIBILITY_REDUCED,
|
|
@@ -104,6 +111,7 @@ module Rigor
|
|
|
104
111
|
# both spellings resolve identically.
|
|
105
112
|
LEGACY_RULE_ALIASES = {
|
|
106
113
|
"undefined-method" => RULE_UNDEFINED_METHOD,
|
|
114
|
+
"self-undefined-method" => RULE_SELF_UNDEFINED_METHOD,
|
|
107
115
|
"wrong-arity" => RULE_WRONG_ARITY,
|
|
108
116
|
"argument-type-mismatch" => RULE_ARGUMENT_TYPE,
|
|
109
117
|
"possible-nil-receiver" => RULE_NIL_RECEIVER,
|
|
@@ -114,7 +122,8 @@ module Rigor
|
|
|
114
122
|
"method-visibility-mismatch" => RULE_VISIBILITY_MISMATCH,
|
|
115
123
|
"ivar-write-mismatch" => RULE_IVAR_WRITE_MISMATCH,
|
|
116
124
|
"dead-assignment" => RULE_DEAD_ASSIGNMENT,
|
|
117
|
-
"always-truthy-condition" => RULE_ALWAYS_TRUTHY_CONDITION
|
|
125
|
+
"always-truthy-condition" => RULE_ALWAYS_TRUTHY_CONDITION,
|
|
126
|
+
"unreachable-clause" => RULE_UNREACHABLE_CLAUSE
|
|
118
127
|
}.freeze
|
|
119
128
|
|
|
120
129
|
# Family wildcard — a `<family>` token in a suppression
|
|
@@ -153,7 +162,7 @@ module Rigor
|
|
|
153
162
|
# @param root [Prism::Node]
|
|
154
163
|
# @param scope_index [Hash{Prism::Node => Rigor::Scope}]
|
|
155
164
|
# @return [Array<Rigor::Analysis::Diagnostic>]
|
|
156
|
-
def diagnose(path:, root:, scope_index:, comments: [], disabled_rules: [])
|
|
165
|
+
def diagnose(path:, root:, scope_index:, self_call_misses: [], comments: [], disabled_rules: [])
|
|
157
166
|
diagnostics = []
|
|
158
167
|
Source::NodeWalker.each(root) do |node|
|
|
159
168
|
case node
|
|
@@ -173,7 +182,9 @@ module Rigor
|
|
|
173
182
|
diagnostics << unreachable if unreachable
|
|
174
183
|
end
|
|
175
184
|
end
|
|
185
|
+
diagnostics.concat(self_undefined_method_diagnostics(path, self_call_misses, root, scope_index))
|
|
176
186
|
diagnostics.concat(always_truthy_condition_diagnostics(path, root, scope_index))
|
|
187
|
+
diagnostics.concat(unreachable_clause_diagnostics(path, root, scope_index))
|
|
177
188
|
diagnostics.concat(ivar_write_mismatch_diagnostics(path, root, scope_index))
|
|
178
189
|
diagnostics.concat(dead_assignment_diagnostics(path, root, scope_index))
|
|
179
190
|
filter_suppressed(diagnostics, comments: comments, disabled_rules: disabled_rules)
|
|
@@ -246,6 +257,16 @@ module Rigor
|
|
|
246
257
|
end
|
|
247
258
|
end
|
|
248
259
|
|
|
260
|
+
# ADR-47 — `flow.unreachable-clause`. One diagnostic per `when` clause
|
|
261
|
+
# the flow engine's narrowing proves can never match (its narrowed
|
|
262
|
+
# subject is `bot`). The squiggle lands on the dead clause's body,
|
|
263
|
+
# mirroring `flow.unreachable-branch`.
|
|
264
|
+
def unreachable_clause_diagnostics(path, root, scope_index)
|
|
265
|
+
UnreachableClauseCollector.new(scope_index).collect(root).map do |result|
|
|
266
|
+
build_unreachable_clause_diagnostic(path, result)
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
249
270
|
def ivar_mismatch_diagnostics_for(path, class_name, ivar_name, writes)
|
|
250
271
|
return [] if writes.size < 2
|
|
251
272
|
|
|
@@ -527,11 +548,9 @@ module Rigor
|
|
|
527
548
|
end
|
|
528
549
|
|
|
529
550
|
def build_unresolved_toplevel_diagnostic(path, call_node)
|
|
530
|
-
|
|
531
|
-
|
|
551
|
+
Diagnostic.from_message_loc(
|
|
552
|
+
call_node,
|
|
532
553
|
path: path,
|
|
533
|
-
line: location.start_line,
|
|
534
|
-
column: location.start_column + 1,
|
|
535
554
|
message: "unresolved toplevel call to `#{call_node.name}`. " \
|
|
536
555
|
"If a project file defines `#{call_node.name}` via a toplevel " \
|
|
537
556
|
"`def` or a monkey-patch on Object/Kernel, list that file in " \
|
|
@@ -605,6 +624,65 @@ module Rigor
|
|
|
605
624
|
end
|
|
606
625
|
end
|
|
607
626
|
|
|
627
|
+
# ADR-24 slice 4 — `call.self-undefined-method`. Consumes the engine's
|
|
628
|
+
# recorded unresolved implicit-self calls
|
|
629
|
+
# ({Analysis::SelfCallResolutionRecorder}) and adds only the
|
|
630
|
+
# closedness POLICY — it NEVER recomputes resolution (the reverted
|
|
631
|
+
# attempt-1 mistake that produced 135 FPs). A miss reaches here only
|
|
632
|
+
# because the engine's real resolution found the method nowhere.
|
|
633
|
+
#
|
|
634
|
+
# The v1 gate is deliberately the most conservative "confidently
|
|
635
|
+
# closed" shape: a STANDALONE project class — no superclass and no
|
|
636
|
+
# `include`/`prepend` (so its in-file method surface is complete) —
|
|
637
|
+
# that is not a module / mixin contract, defines no `method_missing`,
|
|
638
|
+
# has no dynamic `attr_*(*splat)` accessor, and is not an ADR-26 open
|
|
639
|
+
# receiver. Widening to superclass / include chains is a later slice,
|
|
640
|
+
# each behind the external corpus FP gate. Authored `:warning` but
|
|
641
|
+
# mapped to `:off` in every shipped profile until that gate is green
|
|
642
|
+
# (ADR-24 § "Slice 4"); a project opts in via `severity_overrides:`.
|
|
643
|
+
def self_undefined_method_diagnostics(path, self_call_misses, root, scope_index)
|
|
644
|
+
return [] if self_call_misses.empty?
|
|
645
|
+
|
|
646
|
+
open_names = SelfClosednessScanner.new(root).open_class_names
|
|
647
|
+
self_call_misses.filter_map do |miss|
|
|
648
|
+
next if open_names.include?(miss.class_name)
|
|
649
|
+
|
|
650
|
+
scope = scope_index[miss.node]
|
|
651
|
+
next if scope.nil?
|
|
652
|
+
next unless confidently_closed_self_class?(miss.class_name, scope)
|
|
653
|
+
|
|
654
|
+
build_self_undefined_method_diagnostic(path, miss)
|
|
655
|
+
end
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
def confidently_closed_self_class?(class_name, scope)
|
|
659
|
+
return false if unbounded_receiver_surface?(class_name, scope)
|
|
660
|
+
return false if scope.discovered_method?(class_name, :method_missing, :instance)
|
|
661
|
+
# A superclass or mixin extends the surface beyond what this file
|
|
662
|
+
# declares; the engine's ancestor walk may have hit an unresolvable
|
|
663
|
+
# ancestor, so a miss is not provably a typo. Defer to a later slice.
|
|
664
|
+
return false if scope.superclass_of(class_name)
|
|
665
|
+
return false unless scope.includes_of(class_name).empty?
|
|
666
|
+
|
|
667
|
+
true
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
def build_self_undefined_method_diagnostic(path, miss)
|
|
671
|
+
Diagnostic.new(
|
|
672
|
+
path: path,
|
|
673
|
+
line: miss.line || 1,
|
|
674
|
+
column: miss.column || 1,
|
|
675
|
+
message: "implicit-self call to `#{miss.method_name}` resolves to no method on " \
|
|
676
|
+
"`#{miss.class_name}` (a standalone class with a complete, project-known " \
|
|
677
|
+
"method surface). Likely a typo or a missing `def`.",
|
|
678
|
+
severity: :warning,
|
|
679
|
+
rule: RULE_SELF_UNDEFINED_METHOD,
|
|
680
|
+
source_family: :builtin,
|
|
681
|
+
receiver_type: miss.class_name,
|
|
682
|
+
method_name: miss.method_name
|
|
683
|
+
)
|
|
684
|
+
end
|
|
685
|
+
|
|
608
686
|
def lookup_method(receiver_type, class_name, method_name, scope)
|
|
609
687
|
if receiver_type.is_a?(Type::Singleton)
|
|
610
688
|
Rigor::Reflection.singleton_method_definition(class_name, method_name, scope: scope)
|
|
@@ -832,11 +910,9 @@ module Rigor
|
|
|
832
910
|
return nil if inside_rigor_testing?(scope)
|
|
833
911
|
|
|
834
912
|
type = scope.type_of(arg)
|
|
835
|
-
|
|
836
|
-
|
|
913
|
+
Diagnostic.from_message_loc(
|
|
914
|
+
call_node,
|
|
837
915
|
path: path,
|
|
838
|
-
line: location.start_line,
|
|
839
|
-
column: location.start_column + 1,
|
|
840
916
|
message: "dump_type: #{type.describe(:short)}",
|
|
841
917
|
severity: :info,
|
|
842
918
|
rule: RULE_DUMP_TYPE
|
|
@@ -929,24 +1005,20 @@ module Rigor
|
|
|
929
1005
|
end
|
|
930
1006
|
|
|
931
1007
|
def build_assert_type_diagnostic(path, call_node, expected, actual)
|
|
932
|
-
|
|
933
|
-
|
|
1008
|
+
Diagnostic.from_message_loc(
|
|
1009
|
+
call_node,
|
|
934
1010
|
rule: RULE_ASSERT_TYPE,
|
|
935
1011
|
path: path,
|
|
936
|
-
line: location.start_line,
|
|
937
|
-
column: location.start_column + 1,
|
|
938
1012
|
message: "assert_type mismatch: expected #{expected.inspect}, got #{actual.inspect}",
|
|
939
1013
|
severity: :error
|
|
940
1014
|
)
|
|
941
1015
|
end
|
|
942
1016
|
|
|
943
1017
|
def build_nil_receiver_diagnostic(path, call_node)
|
|
944
|
-
|
|
945
|
-
|
|
1018
|
+
Diagnostic.from_message_loc(
|
|
1019
|
+
call_node,
|
|
946
1020
|
rule: RULE_NIL_RECEIVER,
|
|
947
1021
|
path: path,
|
|
948
|
-
line: location.start_line,
|
|
949
|
-
column: location.start_column + 1,
|
|
950
1022
|
message: "possible nil receiver: `#{call_node.name}' is undefined on NilClass",
|
|
951
1023
|
severity: :error
|
|
952
1024
|
)
|
|
@@ -1009,12 +1081,10 @@ module Rigor
|
|
|
1009
1081
|
end
|
|
1010
1082
|
|
|
1011
1083
|
def build_always_raises_diagnostic(path, call_node)
|
|
1012
|
-
|
|
1013
|
-
|
|
1084
|
+
Diagnostic.from_message_loc(
|
|
1085
|
+
call_node,
|
|
1014
1086
|
rule: RULE_ALWAYS_RAISES,
|
|
1015
1087
|
path: path,
|
|
1016
|
-
line: location.start_line,
|
|
1017
|
-
column: location.start_column + 1,
|
|
1018
1088
|
message: "always raises ZeroDivisionError: `#{call_node.name}' by zero on Integer receiver",
|
|
1019
1089
|
severity: :error
|
|
1020
1090
|
)
|
|
@@ -1133,12 +1203,10 @@ module Rigor
|
|
|
1133
1203
|
end
|
|
1134
1204
|
|
|
1135
1205
|
def build_visibility_mismatch_diagnostic(path, call_node, receiver_type)
|
|
1136
|
-
|
|
1137
|
-
|
|
1206
|
+
Diagnostic.from_message_loc(
|
|
1207
|
+
call_node,
|
|
1138
1208
|
rule: RULE_VISIBILITY_MISMATCH,
|
|
1139
1209
|
path: path,
|
|
1140
|
-
line: location.start_line,
|
|
1141
|
-
column: location.start_column + 1,
|
|
1142
1210
|
message: "private method `#{call_node.name}' called on #{receiver_type.class_name} receiver",
|
|
1143
1211
|
severity: :error
|
|
1144
1212
|
)
|
|
@@ -1166,36 +1234,55 @@ module Rigor
|
|
|
1166
1234
|
end
|
|
1167
1235
|
|
|
1168
1236
|
def build_always_truthy_condition_diagnostic(path, predicate_node, polarity)
|
|
1169
|
-
|
|
1170
|
-
|
|
1237
|
+
Diagnostic.from_node(
|
|
1238
|
+
predicate_node,
|
|
1171
1239
|
rule: RULE_ALWAYS_TRUTHY_CONDITION,
|
|
1172
1240
|
path: path,
|
|
1173
|
-
line: location.start_line,
|
|
1174
|
-
column: location.start_column + 1,
|
|
1175
1241
|
message: "condition is always #{polarity} (the surrounding flow proves it folds to a constant)",
|
|
1176
1242
|
severity: :warning
|
|
1177
1243
|
)
|
|
1178
1244
|
end
|
|
1179
1245
|
|
|
1246
|
+
def build_unreachable_clause_diagnostic(path, result)
|
|
1247
|
+
Diagnostic.from_node(
|
|
1248
|
+
result.body,
|
|
1249
|
+
rule: RULE_UNREACHABLE_CLAUSE,
|
|
1250
|
+
path: path,
|
|
1251
|
+
message: unreachable_clause_message(result),
|
|
1252
|
+
severity: :warning
|
|
1253
|
+
)
|
|
1254
|
+
end
|
|
1255
|
+
|
|
1256
|
+
def unreachable_clause_message(result)
|
|
1257
|
+
subject = result.subject_name
|
|
1258
|
+
kw = result.keyword
|
|
1259
|
+
case result.kind
|
|
1260
|
+
when :prior_exhaustion
|
|
1261
|
+
"unreachable `#{kw} #{result.condition_source}': `#{subject}' is already covered " \
|
|
1262
|
+
"by an earlier `#{kw}' clause"
|
|
1263
|
+
when :exhausted_else
|
|
1264
|
+
"unreachable `else': the `#{kw}' clauses already cover every value `#{subject}' can take here"
|
|
1265
|
+
else # :disjoint
|
|
1266
|
+
"unreachable `#{kw} #{result.condition_source}': `#{subject}' can never be " \
|
|
1267
|
+
"#{result.condition_source} here (the flow proves the subject disjoint)"
|
|
1268
|
+
end
|
|
1269
|
+
end
|
|
1270
|
+
|
|
1180
1271
|
def build_dead_assignment_diagnostic(path, write_node, def_node)
|
|
1181
|
-
|
|
1182
|
-
|
|
1272
|
+
Diagnostic.from_name_loc(
|
|
1273
|
+
write_node,
|
|
1183
1274
|
rule: RULE_DEAD_ASSIGNMENT,
|
|
1184
1275
|
path: path,
|
|
1185
|
-
line: location.start_line,
|
|
1186
|
-
column: location.start_column + 1,
|
|
1187
1276
|
message: "local `#{write_node.name}' assigned in `#{def_node.name}' but never read",
|
|
1188
1277
|
severity: :warning
|
|
1189
1278
|
)
|
|
1190
1279
|
end
|
|
1191
1280
|
|
|
1192
1281
|
def build_ivar_write_mismatch_diagnostic(path, node, class_name, ivar_name, first_class, other_class)
|
|
1193
|
-
|
|
1194
|
-
|
|
1282
|
+
Diagnostic.from_name_loc(
|
|
1283
|
+
node,
|
|
1195
1284
|
rule: RULE_IVAR_WRITE_MISMATCH,
|
|
1196
1285
|
path: path,
|
|
1197
|
-
line: location.start_line,
|
|
1198
|
-
column: location.start_column + 1,
|
|
1199
1286
|
message: "instance variable `#{ivar_name}' on #{class_name} was previously assigned " \
|
|
1200
1287
|
"#{first_class}; this write assigns #{other_class}",
|
|
1201
1288
|
severity: :error
|
|
@@ -1214,12 +1301,10 @@ module Rigor
|
|
|
1214
1301
|
end
|
|
1215
1302
|
|
|
1216
1303
|
def build_unreachable_branch_diagnostic(path, dead_branch, polarity)
|
|
1217
|
-
|
|
1218
|
-
|
|
1304
|
+
Diagnostic.from_node(
|
|
1305
|
+
dead_branch,
|
|
1219
1306
|
rule: RULE_UNREACHABLE_BRANCH,
|
|
1220
1307
|
path: path,
|
|
1221
|
-
line: location.start_line,
|
|
1222
|
-
column: location.start_column + 1,
|
|
1223
1308
|
message: "unreachable branch: literal predicate is always #{polarity}",
|
|
1224
1309
|
severity: :warning
|
|
1225
1310
|
)
|
|
@@ -1348,39 +1433,34 @@ module Rigor
|
|
|
1348
1433
|
end
|
|
1349
1434
|
|
|
1350
1435
|
def build_argument_type_diagnostic(path, call_node, class_name, mismatch)
|
|
1351
|
-
location = mismatch[:node].location
|
|
1352
1436
|
method_label = "`#{call_node.name}' on #{class_name}"
|
|
1353
1437
|
parameter_label = mismatch[:name] ? "parameter `#{mismatch[:name]}' of #{method_label}" : method_label
|
|
1354
1438
|
message = "argument type mismatch at #{parameter_label}: " \
|
|
1355
1439
|
"expected #{mismatch[:expected].describe(:short)}, " \
|
|
1356
1440
|
"got #{mismatch[:actual].describe(:short)}"
|
|
1357
|
-
Diagnostic.
|
|
1441
|
+
Diagnostic.from_node(
|
|
1442
|
+
mismatch[:node],
|
|
1358
1443
|
rule: RULE_ARGUMENT_TYPE,
|
|
1359
1444
|
path: path,
|
|
1360
|
-
line: location.start_line,
|
|
1361
|
-
column: location.start_column + 1,
|
|
1362
1445
|
message: message,
|
|
1363
1446
|
severity: :error
|
|
1364
1447
|
)
|
|
1365
1448
|
end
|
|
1366
1449
|
|
|
1367
1450
|
def build_arity_diagnostic(path, call_node, class_name, min, max, actual)
|
|
1368
|
-
location = call_node.message_loc || call_node.location
|
|
1369
1451
|
range = min == max ? min.to_s : "#{min}..#{max}"
|
|
1370
1452
|
method_label = "`#{call_node.name}' on #{class_name}"
|
|
1371
1453
|
message = "wrong number of arguments to #{method_label} (given #{actual}, expected #{range})"
|
|
1372
|
-
Diagnostic.
|
|
1454
|
+
Diagnostic.from_message_loc(
|
|
1455
|
+
call_node,
|
|
1373
1456
|
rule: RULE_WRONG_ARITY,
|
|
1374
1457
|
path: path,
|
|
1375
|
-
line: location.start_line,
|
|
1376
|
-
column: location.start_column + 1,
|
|
1377
1458
|
message: message,
|
|
1378
1459
|
severity: :error
|
|
1379
1460
|
)
|
|
1380
1461
|
end
|
|
1381
1462
|
|
|
1382
1463
|
def build_undefined_method_diagnostic(path, call_node, receiver_type, definition_site = nil, class_name = nil)
|
|
1383
|
-
location = call_node.message_loc || call_node.location
|
|
1384
1464
|
rendered_receiver = receiver_type.describe
|
|
1385
1465
|
message = "undefined method `#{call_node.name}' for #{rendered_receiver}"
|
|
1386
1466
|
# ADR-17 — when the project itself defines this method on the
|
|
@@ -1396,11 +1476,10 @@ module Rigor
|
|
|
1396
1476
|
"#{definition_site} — Rigor does not apply project monkey-patches " \
|
|
1397
1477
|
"cross-file; list that file in `.rigor.yml`'s `pre_eval:` (ADR-17)"
|
|
1398
1478
|
end
|
|
1399
|
-
Diagnostic.
|
|
1479
|
+
Diagnostic.from_message_loc(
|
|
1480
|
+
call_node,
|
|
1400
1481
|
rule: RULE_UNDEFINED_METHOD,
|
|
1401
1482
|
path: path,
|
|
1402
|
-
line: location.start_line,
|
|
1403
|
-
column: location.start_column + 1,
|
|
1404
1483
|
message: message,
|
|
1405
1484
|
severity: :error,
|
|
1406
1485
|
receiver_type: rendered_receiver,
|
|
@@ -1544,12 +1623,10 @@ module Rigor
|
|
|
1544
1623
|
end
|
|
1545
1624
|
|
|
1546
1625
|
def build_return_type_mismatch_diagnostic(path, def_node, declared, inferred, severity)
|
|
1547
|
-
|
|
1548
|
-
|
|
1626
|
+
Diagnostic.from_name_loc(
|
|
1627
|
+
def_node,
|
|
1549
1628
|
rule: RULE_RETURN_TYPE,
|
|
1550
1629
|
path: path,
|
|
1551
|
-
line: location.start_line,
|
|
1552
|
-
column: location.start_column + 1,
|
|
1553
1630
|
message: "return-type mismatch on `#{def_node.name}': " \
|
|
1554
1631
|
"declared #{declared.describe(:short)}, inferred #{inferred.describe(:short)}",
|
|
1555
1632
|
severity: severity
|
|
@@ -1679,6 +1756,14 @@ module Rigor
|
|
|
1679
1756
|
candidate = (segments[0, i] + [raw_ancestor]).join("::")
|
|
1680
1757
|
return candidate if known_user_class?(scope, candidate)
|
|
1681
1758
|
end
|
|
1759
|
+
# ADR-46 slice 3 — the override checker reads the class graph
|
|
1760
|
+
# directly (not through the recorder's `Scope` choke points), and
|
|
1761
|
+
# short-circuits when the ancestor resolves to no project class, so
|
|
1762
|
+
# an incremental re-check has no edge telling it to re-check this
|
|
1763
|
+
# subclass when that ancestor is later defined. Record a negative
|
|
1764
|
+
# class edge (keyed on the unqualified name) so the appeared-class
|
|
1765
|
+
# widening picks it up.
|
|
1766
|
+
DependencyRecorder.read_missing(:class, raw_ancestor.to_s.split("::").last) if DependencyRecorder.active?
|
|
1682
1767
|
nil
|
|
1683
1768
|
end
|
|
1684
1769
|
|
|
@@ -1689,12 +1774,10 @@ module Rigor
|
|
|
1689
1774
|
end
|
|
1690
1775
|
|
|
1691
1776
|
def build_override_visibility_diagnostic(path, def_node, parent_class, parent_visibility, override_visibility)
|
|
1692
|
-
|
|
1693
|
-
|
|
1777
|
+
Diagnostic.from_name_loc(
|
|
1778
|
+
def_node,
|
|
1694
1779
|
rule: RULE_OVERRIDE_VISIBILITY_REDUCED,
|
|
1695
1780
|
path: path,
|
|
1696
|
-
line: location.start_line,
|
|
1697
|
-
column: location.start_column + 1,
|
|
1698
1781
|
message: "visibility of `#{def_node.name}' reduced from #{parent_visibility} to " \
|
|
1699
1782
|
"#{override_visibility} (overrides #{parent_class}##{def_node.name}); " \
|
|
1700
1783
|
"breaks substitutability",
|
|
@@ -1784,12 +1867,10 @@ module Rigor
|
|
|
1784
1867
|
end
|
|
1785
1868
|
|
|
1786
1869
|
def build_override_return_widened_diagnostic(path, def_node, parent_class, parent_return, override_return)
|
|
1787
|
-
|
|
1788
|
-
|
|
1870
|
+
Diagnostic.from_name_loc(
|
|
1871
|
+
def_node,
|
|
1789
1872
|
rule: RULE_OVERRIDE_RETURN_WIDENED,
|
|
1790
1873
|
path: path,
|
|
1791
|
-
line: location.start_line,
|
|
1792
|
-
column: location.start_column + 1,
|
|
1793
1874
|
message: "return type of `#{def_node.name}' widened from #{parent_return.describe(:short)} " \
|
|
1794
1875
|
"to #{override_return.describe(:short)} (overrides #{parent_class}##{def_node.name}); " \
|
|
1795
1876
|
"breaks substitutability",
|
|
@@ -1890,12 +1971,10 @@ module Rigor
|
|
|
1890
1971
|
end
|
|
1891
1972
|
|
|
1892
1973
|
def build_override_param_narrowed_diagnostic(path, def_node, parent_class, index, parent_param, override_param)
|
|
1893
|
-
|
|
1894
|
-
|
|
1974
|
+
Diagnostic.from_name_loc(
|
|
1975
|
+
def_node,
|
|
1895
1976
|
rule: RULE_OVERRIDE_PARAM_NARROWED,
|
|
1896
1977
|
path: path,
|
|
1897
|
-
line: location.start_line,
|
|
1898
|
-
column: location.start_column + 1,
|
|
1899
1978
|
message: "parameter #{index + 1} of `#{def_node.name}' narrowed from " \
|
|
1900
1979
|
"#{parent_param.describe(:short)} to #{override_param.describe(:short)} " \
|
|
1901
1980
|
"(overrides #{parent_class}##{def_node.name}); breaks substitutability",
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Analysis
|
|
5
|
+
# ADR-46 slice 1 — records, per analyzed file, which OTHER source
|
|
6
|
+
# files its analysis read declarations / method bodies from (the
|
|
7
|
+
# cross-file dependency edges), plus the cross-file lookups that
|
|
8
|
+
# resolved to nothing (negative edges — adding that symbol later must
|
|
9
|
+
# re-check the consumer).
|
|
10
|
+
#
|
|
11
|
+
# Thread-local and activated per `analyze_file` only when the runner
|
|
12
|
+
# opts in (`record_dependencies: true`); a normal run never activates
|
|
13
|
+
# it, so {active?} is a single nil-check and the instrumented `Scope`
|
|
14
|
+
# accessors pay nothing. Recording is purely observational — it never
|
|
15
|
+
# changes a diagnostic.
|
|
16
|
+
#
|
|
17
|
+
# Modelled on {Inference::BudgetTrace}: process-thread-local state, a
|
|
18
|
+
# cheap disabled fast path, and a frozen snapshot for consumers.
|
|
19
|
+
module DependencyRecorder
|
|
20
|
+
KEY = :__rigor_dependency_recorder__
|
|
21
|
+
private_constant :KEY
|
|
22
|
+
|
|
23
|
+
# Mutable per-consumer accumulator. Frozen into a {Record} snapshot
|
|
24
|
+
# when `record_for` returns.
|
|
25
|
+
class Accumulator
|
|
26
|
+
attr_reader :consumer, :sources, :missing, :symbol_sources, :ancestry_sources
|
|
27
|
+
|
|
28
|
+
def initialize(consumer)
|
|
29
|
+
@consumer = consumer
|
|
30
|
+
@sources = Set.new
|
|
31
|
+
@missing = Set.new
|
|
32
|
+
# ADR-46 slice 4 — symbol-granularity tracking.
|
|
33
|
+
# `symbol_sources`: source_path → Set<"ClassName#method"> for method-call deps.
|
|
34
|
+
# `ancestry_sources`: Set<source_path> for class-ancestry (superclass / include)
|
|
35
|
+
# deps — file-granularity by nature (a superclass edge touches the whole class).
|
|
36
|
+
@symbol_sources = Hash.new { |h, k| h[k] = Set.new }
|
|
37
|
+
@ancestry_sources = Set.new
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def snapshot
|
|
41
|
+
frozen_sym = @symbol_sources.transform_values(&:freeze).freeze
|
|
42
|
+
Record.new(
|
|
43
|
+
consumer: consumer,
|
|
44
|
+
sources: sources.dup.freeze,
|
|
45
|
+
missing: missing.dup.freeze,
|
|
46
|
+
symbol_sources: frozen_sym,
|
|
47
|
+
ancestry_sources: ancestry_sources.dup.freeze
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Frozen record of one file's cross-file reads.
|
|
53
|
+
# `symbol_sources`: source_path → frozen Set<"ClassName#method"> (method-call edges).
|
|
54
|
+
# `ancestry_sources`: frozen Set<source_path> (class-ancestry edges, file-granularity).
|
|
55
|
+
Record = Data.define(:consumer, :sources, :missing, :symbol_sources, :ancestry_sources)
|
|
56
|
+
|
|
57
|
+
# Module-level activation count so the disabled fast path
|
|
58
|
+
# ({active?}) is a plain integer read rather than a `Thread.current`
|
|
59
|
+
# hash lookup — `user_def_for` (the instrumented accessor) is on the
|
|
60
|
+
# per-dispatch hot path, so a normal (non-recording) run must pay as
|
|
61
|
+
# little as possible. The per-thread accumulator still isolates the
|
|
62
|
+
# actual recording, so a non-recording thread seeing `active?` true
|
|
63
|
+
# (another thread is recording) just performs an extra nil-check.
|
|
64
|
+
@active_count = 0
|
|
65
|
+
@mutex = Mutex.new
|
|
66
|
+
|
|
67
|
+
module_function
|
|
68
|
+
|
|
69
|
+
# Activates recording for `consumer` (the path being analyzed) for
|
|
70
|
+
# the duration of the block and returns the frozen {Record}. Nests
|
|
71
|
+
# safely (the inner consumer's reads do not leak to the outer one);
|
|
72
|
+
# restores the previous recorder on exit.
|
|
73
|
+
def record_for(consumer)
|
|
74
|
+
previous = Thread.current[KEY]
|
|
75
|
+
accumulator = Accumulator.new(consumer.to_s)
|
|
76
|
+
Thread.current[KEY] = accumulator
|
|
77
|
+
@mutex.synchronize { @active_count += 1 }
|
|
78
|
+
yield
|
|
79
|
+
accumulator.snapshot
|
|
80
|
+
ensure
|
|
81
|
+
Thread.current[KEY] = previous
|
|
82
|
+
@mutex.synchronize { @active_count -= 1 }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Plain integer read (GVL-atomic) — no `Thread.current` lookup on the
|
|
86
|
+
# disabled fast path.
|
|
87
|
+
def active?
|
|
88
|
+
@active_count.positive?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Records that the current consumer read a declaration / body whose
|
|
92
|
+
# definition site is `path_line` (a `"path:line"` String, or nil).
|
|
93
|
+
# When `symbol` is given (a `"ClassName#method"` String), the read is
|
|
94
|
+
# a method-call edge and is recorded at symbol granularity in
|
|
95
|
+
# `symbol_sources` in addition to the coarse `sources` set.
|
|
96
|
+
# Without `symbol` the read is a class-ancestry edge (file-granularity)
|
|
97
|
+
# and is added to `ancestry_sources` only.
|
|
98
|
+
# Self-reads and nil sites are ignored in all cases.
|
|
99
|
+
def read_site(path_line, symbol = nil)
|
|
100
|
+
accumulator = Thread.current[KEY]
|
|
101
|
+
return if accumulator.nil? || path_line.nil?
|
|
102
|
+
|
|
103
|
+
path = path_line.split(":", 2).first
|
|
104
|
+
return unless path && path != accumulator.consumer
|
|
105
|
+
|
|
106
|
+
accumulator.sources << path
|
|
107
|
+
if symbol
|
|
108
|
+
accumulator.symbol_sources[path] << symbol
|
|
109
|
+
else
|
|
110
|
+
accumulator.ancestry_sources << path
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Records a cross-file lookup of `name` (kind `:method` / `:class` /
|
|
115
|
+
# `:const` / …) that resolved to nothing — a negative dependency.
|
|
116
|
+
def read_missing(kind, name)
|
|
117
|
+
accumulator = Thread.current[KEY]
|
|
118
|
+
accumulator&.missing&.add("#{kind}:#{name}")
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -99,6 +99,24 @@ module Rigor
|
|
|
99
99
|
)
|
|
100
100
|
end
|
|
101
101
|
|
|
102
|
+
# Builds a Diagnostic at a call node's `message_loc` (the
|
|
103
|
+
# method-name / matcher span), falling back to the receiver-
|
|
104
|
+
# spanning `node.location` when no message location is available.
|
|
105
|
+
# Absorbs the `node.message_loc || node.location` idiom the
|
|
106
|
+
# call-related rules otherwise repeat; all other fields forward to
|
|
107
|
+
# {.from_location}.
|
|
108
|
+
def self.from_message_loc(node, **)
|
|
109
|
+
from_location(node.message_loc || node.location, **)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Builds a Diagnostic at a definition / assignment node's
|
|
113
|
+
# `name_loc` (the declared name span), falling back to
|
|
114
|
+
# `node.location`. Absorbs the `node.name_loc || node.location`
|
|
115
|
+
# idiom the def / write rules otherwise repeat.
|
|
116
|
+
def self.from_name_loc(node, **)
|
|
117
|
+
from_location(node.name_loc || node.location, **)
|
|
118
|
+
end
|
|
119
|
+
|
|
102
120
|
def error?
|
|
103
121
|
severity == :error
|
|
104
122
|
end
|