rigortype 0.1.1 → 0.1.2

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.
@@ -6,6 +6,9 @@ require_relative "../reflection"
6
6
  require_relative "../source/node_walker"
7
7
  require_relative "../type"
8
8
  require_relative "diagnostic"
9
+ require_relative "check_rules/always_truthy_condition_collector"
10
+ require_relative "check_rules/dead_assignment_collector"
11
+ require_relative "check_rules/ivar_write_collector"
9
12
 
10
13
  module Rigor
11
14
  module Analysis
@@ -60,7 +63,12 @@ module Rigor
60
63
  RULE_DUMP_TYPE = "dump.type"
61
64
  RULE_ASSERT_TYPE = "assert.type-mismatch"
62
65
  RULE_ALWAYS_RAISES = "flow.always-raises"
66
+ RULE_UNREACHABLE_BRANCH = "flow.unreachable-branch"
63
67
  RULE_RETURN_TYPE = "def.return-type-mismatch"
68
+ RULE_VISIBILITY_MISMATCH = "def.method-visibility-mismatch"
69
+ RULE_IVAR_WRITE_MISMATCH = "def.ivar-write-mismatch"
70
+ RULE_DEAD_ASSIGNMENT = "flow.dead-assignment"
71
+ RULE_ALWAYS_TRUTHY_CONDITION = "flow.always-truthy-condition"
64
72
 
65
73
  ALL_RULES = [
66
74
  RULE_UNDEFINED_METHOD,
@@ -70,7 +78,12 @@ module Rigor
70
78
  RULE_DUMP_TYPE,
71
79
  RULE_ASSERT_TYPE,
72
80
  RULE_ALWAYS_RAISES,
73
- RULE_RETURN_TYPE
81
+ RULE_UNREACHABLE_BRANCH,
82
+ RULE_DEAD_ASSIGNMENT,
83
+ RULE_ALWAYS_TRUTHY_CONDITION,
84
+ RULE_RETURN_TYPE,
85
+ RULE_VISIBILITY_MISMATCH,
86
+ RULE_IVAR_WRITE_MISMATCH
74
87
  ].freeze
75
88
 
76
89
  # Backward-compat alias table (ADR-8 § "Backward
@@ -88,7 +101,12 @@ module Rigor
88
101
  "possible-nil-receiver" => RULE_NIL_RECEIVER,
89
102
  "dump-type" => RULE_DUMP_TYPE,
90
103
  "assert-type" => RULE_ASSERT_TYPE,
91
- "always-raises" => RULE_ALWAYS_RAISES
104
+ "always-raises" => RULE_ALWAYS_RAISES,
105
+ "unreachable-branch" => RULE_UNREACHABLE_BRANCH,
106
+ "method-visibility-mismatch" => RULE_VISIBILITY_MISMATCH,
107
+ "ivar-write-mismatch" => RULE_IVAR_WRITE_MISMATCH,
108
+ "dead-assignment" => RULE_DEAD_ASSIGNMENT,
109
+ "always-truthy-condition" => RULE_ALWAYS_TRUTHY_CONDITION
92
110
  }.freeze
93
111
 
94
112
  # Family wildcard — a `<family>` token in a suppression
@@ -124,13 +142,20 @@ module Rigor
124
142
  def diagnose(path:, root:, scope_index:, comments: [], disabled_rules: [])
125
143
  diagnostics = []
126
144
  Source::NodeWalker.each(root) do |node|
127
- if node.is_a?(Prism::CallNode)
145
+ case node
146
+ when Prism::CallNode
128
147
  diagnostics.concat(call_node_diagnostics(path, node, scope_index))
129
- elsif node.is_a?(Prism::DefNode)
148
+ when Prism::DefNode
130
149
  return_diagnostic = return_type_mismatch_diagnostic(path, node, scope_index)
131
150
  diagnostics << return_diagnostic if return_diagnostic
151
+ when Prism::IfNode, Prism::UnlessNode
152
+ unreachable = unreachable_branch_diagnostic(path, node, scope_index)
153
+ diagnostics << unreachable if unreachable
132
154
  end
133
155
  end
156
+ diagnostics.concat(always_truthy_condition_diagnostics(path, root, scope_index))
157
+ diagnostics.concat(ivar_write_mismatch_diagnostics(path, root, scope_index))
158
+ diagnostics.concat(dead_assignment_diagnostics(path, root, scope_index))
134
159
  filter_suppressed(diagnostics, comments: comments, disabled_rules: disabled_rules)
135
160
  end
136
161
 
@@ -142,17 +167,92 @@ module Rigor
142
167
  nil_receiver_diagnostic(path, node, scope_index),
143
168
  dump_type_diagnostic(path, node, scope_index),
144
169
  assert_type_diagnostic(path, node, scope_index),
145
- always_raises_diagnostic(path, node, scope_index)
170
+ always_raises_diagnostic(path, node, scope_index),
171
+ visibility_mismatch_diagnostic(path, node, scope_index)
146
172
  ].compact
147
173
  end
148
174
 
149
- # v0.0.2 #6 diagnostic suppression. Two kinds of
175
+ # v0.1.2 — `def.ivar-write-mismatch`. Walks every
176
+ # ClassNode / ModuleNode body, gathers per-class ivar
177
+ # writes with their rvalue types, and emits a diagnostic
178
+ # when a later write's concrete class disagrees with the
179
+ # first write's. The first write per (class, ivar) is
180
+ # treated as the "declared" type; subsequent writes that
181
+ # land on a different concrete class trigger.
182
+ #
183
+ # Conservative envelope:
184
+ # - Only fires when both the first and the offending
185
+ # write resolve to a `concrete_class_name` (Nominal /
186
+ # Singleton / Constant / Tuple → "Array" / HashShape →
187
+ # "Hash"). Unions / Dynamic / IntegerRange / shape-
188
+ # varied carriers fall through.
189
+ # - `NilClass` is an intentional widening idiom (`@x =
190
+ # "value"` then later `@x = nil` to "clear") — skipped.
191
+ # - Singleton-method (`def self.foo`) bodies are skipped.
192
+ # Class-level ivars (`@x = 1` outside any def, in the
193
+ # class body) are also skipped — they're a separate
194
+ # surface (`Module#@var`) the engine doesn't yet model.
195
+ def ivar_write_mismatch_diagnostics(path, root, scope_index)
196
+ IvarWriteCollector.new(scope_index).collect(root).flat_map do |class_name, writes_by_ivar|
197
+ writes_by_ivar.flat_map do |ivar_name, writes|
198
+ ivar_mismatch_diagnostics_for(path, class_name, ivar_name, writes)
199
+ end
200
+ end
201
+ end
202
+
203
+ # v0.1.2 — `flow.dead-assignment`. Walks every `DefNode`
204
+ # body and emits a diagnostic for each plain
205
+ # `LocalVariableWriteNode` whose target name is never
206
+ # read in the same body. The
207
+ # `Analysis::CheckRules::DeadAssignmentCollector` describes
208
+ # the conservative envelope.
209
+ def dead_assignment_diagnostics(path, root, scope_index)
210
+ DeadAssignmentCollector.new(scope_index).collect(root).map do |result|
211
+ build_dead_assignment_diagnostic(path, result[:write_node], result[:def_node])
212
+ end
213
+ end
214
+
215
+ # v0.1.2 — `flow.always-truthy-condition`. Fires on
216
+ # `if` / `unless` / ternary predicates whose inferred
217
+ # type is a `Type::Constant` AND that don't fall in
218
+ # the literal-only / inside-loop-or-block / defensive-
219
+ # predicate skip envelope (see
220
+ # `Analysis::CheckRules::AlwaysTruthyConditionCollector`
221
+ # for the full triage rationale).
222
+ def always_truthy_condition_diagnostics(path, root, scope_index)
223
+ AlwaysTruthyConditionCollector.new(scope_index).collect(root).map do |result|
224
+ build_always_truthy_condition_diagnostic(path, result.node, result.polarity)
225
+ end
226
+ end
227
+
228
+ def ivar_mismatch_diagnostics_for(path, class_name, ivar_name, writes)
229
+ return [] if writes.size < 2
230
+
231
+ first_class = ivar_class_for(writes.first[:type])
232
+ return [] if first_class.nil?
233
+
234
+ writes[1..].filter_map do |write|
235
+ other_class = ivar_class_for(write[:type])
236
+ next nil if other_class.nil? || other_class == "NilClass" || other_class == first_class
237
+
238
+ build_ivar_write_mismatch_diagnostic(path, write[:node], class_name, ivar_name, first_class, other_class)
239
+ end
240
+ end
241
+
242
+ # v0.0.2 #6 — diagnostic suppression. Three kinds of
150
243
  # suppression compose:
151
244
  #
152
245
  # - **Project-level**: `disabled_rules` is the
153
246
  # project's `.rigor.yml` `disable:` list. Any
154
247
  # diagnostic whose `rule` is in the list is dropped.
155
- # - **In-source**: `# rigor:disable <rule1>, <rule2>`
248
+ # - **File-level** (v0.1.2): `# rigor:disable-file <rule1>,
249
+ # <rule2>` anywhere in the file suppresses the matching
250
+ # diagnostic for every line. `# rigor:disable-file all`
251
+ # suppresses every rule across the file. Convention is
252
+ # to put the comment near the top, but Rigor accepts it
253
+ # anywhere — the comment scope is "this file" regardless
254
+ # of position.
255
+ # - **In-source line**: `# rigor:disable <rule1>, <rule2>`
156
256
  # on the same line as the offending expression
157
257
  # suppresses the matching diagnostic for that line
158
258
  # only. `# rigor:disable all` on a line suppresses
@@ -162,34 +262,49 @@ module Rigor
162
262
  # errors, internal analyzer errors) are NEVER
163
263
  # suppressed — they represent failures the user cannot
164
264
  # silence away.
165
- def filter_suppressed(diagnostics, comments:, disabled_rules:)
166
- suppressions = parse_suppression_comments(comments)
265
+ def filter_suppressed(diagnostics, comments:, disabled_rules:) # rubocop:disable Metrics/CyclomaticComplexity
266
+ line_suppressions, file_suppressions = parse_suppression_comments(comments)
167
267
  disabled = expand_rule_tokens(disabled_rules)
168
268
 
169
269
  diagnostics.reject do |diagnostic|
170
270
  rule = diagnostic.rule
171
271
  next false if rule.nil?
172
272
  next true if disabled.include?(rule)
273
+ next true if file_suppressions.include?("all") || file_suppressions.include?(rule)
173
274
 
174
- line_rules = suppressions[diagnostic.line]
275
+ line_rules = line_suppressions[diagnostic.line]
175
276
  line_rules && (line_rules.include?("all") || line_rules.include?(rule))
176
277
  end
177
278
  end
178
279
 
179
- SUPPRESSION_PATTERN = /#\s*rigor:disable\s+(?<rules>[\w.,\s-]+)/
180
- private_constant :SUPPRESSION_PATTERN
280
+ LINE_SUPPRESSION_PATTERN = /#\s*rigor:disable(?!-file)\s+(?<rules>[\w.,\s-]+)/
281
+ private_constant :LINE_SUPPRESSION_PATTERN
181
282
 
283
+ FILE_SUPPRESSION_PATTERN = /#\s*rigor:disable-file\s+(?<rules>[\w.,\s-]+)/
284
+ private_constant :FILE_SUPPRESSION_PATTERN
285
+
286
+ # @return [Array<(Hash{Integer => Set}, Set)>] pair of
287
+ # `(line_suppressions, file_suppressions)`. Line
288
+ # suppressions are keyed by source line number; file
289
+ # suppressions apply to every line.
182
290
  def parse_suppression_comments(comments)
183
- result = Hash.new { |h, k| h[k] = Set.new }
291
+ line_suppressions = Hash.new { |h, k| h[k] = Set.new }
292
+ file_suppressions = Set.new
184
293
  comments.each do |comment|
185
294
  source = comment.location.slice
186
- match = SUPPRESSION_PATTERN.match(source)
187
- next if match.nil?
295
+ if (match = FILE_SUPPRESSION_PATTERN.match(source))
296
+ absorb_suppression_tokens(match[:rules], file_suppressions)
297
+ elsif (match = LINE_SUPPRESSION_PATTERN.match(source))
298
+ absorb_suppression_tokens(match[:rules], line_suppressions[comment.location.start_line])
299
+ end
300
+ end
301
+ [line_suppressions, file_suppressions]
302
+ end
188
303
 
189
- rules = match[:rules].to_s.split(/[\s,]+/).reject(&:empty?)
190
- rules.each { |token| result[comment.location.start_line].merge(expand_token(token)) }
304
+ def absorb_suppression_tokens(raw, target)
305
+ raw.to_s.split(/[\s,]+/).reject(&:empty?).each do |token|
306
+ target.merge(expand_token(token))
191
307
  end
192
- result
193
308
  end
194
309
 
195
310
  # Expands a list of user-supplied rule tokens into the
@@ -698,6 +813,203 @@ module Rigor
698
813
  )
699
814
  end
700
815
 
816
+ # v0.1.2 — `flow.unreachable-branch`. Fires when an
817
+ # `IfNode` / `UnlessNode` whose predicate is a literal
818
+ # `true` / `false` / `nil` (or a literal numeric /
819
+ # string / symbol whose Ruby truthiness is known
820
+ # at-a-glance) has an observable dead branch. The
821
+ # diagnostic points at the dead branch (not the
822
+ # predicate) so the squiggle lands on the code that
823
+ # never runs.
824
+ #
825
+ # Conservative envelope — by deliberate v0.1.2 design:
826
+ # - Only **literal-shaped** predicates fire. Inferred-
827
+ # constant predicates (`x.method?` that happens to
828
+ # fold to `Constant<bool>`) are intentionally
829
+ # skipped — Rigor's loop / mutation / RBS-strictness
830
+ # modelling is incomplete enough that an inferred
831
+ # constant can be a false positive (e.g. accumulator
832
+ # `arr << x` doesn't widen the carrier; defensive
833
+ # `module.name.nil?` checks against anonymous-class
834
+ # nil that the RBS `Module#name -> String` sig hides).
835
+ # The literal-only envelope captures the clear
836
+ # "user wrote `if false`" case without false alarms.
837
+ # - Empty dead branches (e.g. `if false; end` with no
838
+ # body) are skipped — there is no useful location to
839
+ # point at.
840
+ # - Postfix-`if` / `unless` modifiers with a literal
841
+ # predicate ARE flagged (`expr if false` body never
842
+ # runs, exactly like the block form).
843
+ # - Elsif chains (`subsequent` is itself an `IfNode`)
844
+ # ARE flagged — the entire downstream chain is
845
+ # unreachable when the outer predicate is a constant
846
+ # literal.
847
+ #
848
+ # Broadening to inferred-constant predicates is queued
849
+ # for a later v0.1.x release once the loop / mutation
850
+ # gaps named above are closed.
851
+ def unreachable_branch_diagnostic(path, node, scope_index)
852
+ scope = scope_index[node]
853
+ return nil if scope.nil?
854
+
855
+ polarity = literal_predicate_polarity(node.predicate)
856
+ return nil if polarity.nil?
857
+
858
+ dead_branch = unreachable_branch_for(node, polarity == :truthy)
859
+ return nil if dead_branch.nil?
860
+
861
+ build_unreachable_branch_diagnostic(path, dead_branch, polarity)
862
+ end
863
+
864
+ # Returns `:truthy` / `:falsey` for a syntactically-
865
+ # literal predicate, or nil for anything else.
866
+ # `TrueNode`, `FalseNode`, `NilNode` are the
867
+ # unambiguous cases. Numeric / string / symbol literals
868
+ # are always truthy in Ruby (any non-`false` / non-`nil`
869
+ # value is truthy, including `0` and `""`).
870
+ TRUTHY_LITERAL_NODES = [
871
+ Prism::TrueNode, Prism::IntegerNode, Prism::FloatNode,
872
+ Prism::StringNode, Prism::SymbolNode, Prism::RegularExpressionNode
873
+ ].freeze
874
+ private_constant :TRUTHY_LITERAL_NODES
875
+
876
+ FALSEY_LITERAL_NODES = [Prism::FalseNode, Prism::NilNode].freeze
877
+ private_constant :FALSEY_LITERAL_NODES
878
+
879
+ def literal_predicate_polarity(predicate)
880
+ return :truthy if TRUTHY_LITERAL_NODES.any? { |klass| predicate.is_a?(klass) }
881
+ return :falsey if FALSEY_LITERAL_NODES.any? { |klass| predicate.is_a?(klass) }
882
+
883
+ nil
884
+ end
885
+
886
+ # v0.1.2 — `def.method-visibility-mismatch`. Fires when
887
+ # an explicit-receiver `Prism::CallNode` targets a
888
+ # user-class method whose `discovered_method_visibilities`
889
+ # entry is `:private`. The rule is intentionally narrow:
890
+ #
891
+ # - Only `:private`. `:protected` access depends on
892
+ # subclass tracking the engine does not yet model;
893
+ # broadening waits for that surface.
894
+ # - Only user classes whose visibility table the indexer
895
+ # built. RBS-known classes (stdlib, gems) are NOT
896
+ # consulted yet — RBS visibility is reliable but
897
+ # surfacing it would broaden the rule to a level the
898
+ # per-rule false-positive triage hasn't covered.
899
+ # - Implicit-self calls are skipped (always allowed for
900
+ # private). Calls whose receiver is `Prism::SelfNode`
901
+ # are also skipped — Ruby 2.7+ permits `self.foo` for
902
+ # private methods.
903
+ # - Receiver MUST resolve to a `Type::Nominal` so the
904
+ # rule has a single class identity to query. Unions /
905
+ # Dynamic / shape carriers are skipped.
906
+ def visibility_mismatch_diagnostic(path, call_node, scope_index)
907
+ return nil unless explicit_non_self_receiver?(call_node.receiver)
908
+
909
+ scope = scope_index[call_node]
910
+ return nil if scope.nil?
911
+
912
+ receiver_type = scope.type_of(call_node.receiver)
913
+ return nil unless receiver_type.is_a?(Type::Nominal)
914
+
915
+ visibility = scope.discovered_method_visibility(receiver_type.class_name, call_node.name)
916
+ return nil unless visibility == :private
917
+
918
+ build_visibility_mismatch_diagnostic(path, call_node, receiver_type)
919
+ end
920
+
921
+ def explicit_non_self_receiver?(receiver)
922
+ return false if receiver.nil?
923
+ return false if receiver.is_a?(Prism::SelfNode)
924
+
925
+ true
926
+ end
927
+
928
+ def build_visibility_mismatch_diagnostic(path, call_node, receiver_type)
929
+ location = call_node.message_loc || call_node.location
930
+ Diagnostic.new(
931
+ rule: RULE_VISIBILITY_MISMATCH,
932
+ path: path,
933
+ line: location.start_line,
934
+ column: location.start_column + 1,
935
+ message: "private method `#{call_node.name}' called on #{receiver_type.class_name} receiver",
936
+ severity: :error
937
+ )
938
+ end
939
+
940
+ # Pulls a single concrete class name from an ivar write's
941
+ # rvalue type. Returns nil when the type is too unstable
942
+ # to compare (Union / Dynamic / IntegerRange / etc.).
943
+ # `concrete_class_name` already covers Nominal / Singleton
944
+ # / Constant / Tuple / HashShape; the wrapper exists so
945
+ # the ivar rule can extend the envelope (or apply
946
+ # different filters) without disturbing the call rules.
947
+ def ivar_class_for(type)
948
+ concrete_class_name(type)
949
+ end
950
+
951
+ def build_always_truthy_condition_diagnostic(path, predicate_node, polarity)
952
+ location = predicate_node.location
953
+ Diagnostic.new(
954
+ rule: RULE_ALWAYS_TRUTHY_CONDITION,
955
+ path: path,
956
+ line: location.start_line,
957
+ column: location.start_column + 1,
958
+ message: "condition is always #{polarity} (the surrounding flow proves it folds to a constant)",
959
+ severity: :warning
960
+ )
961
+ end
962
+
963
+ def build_dead_assignment_diagnostic(path, write_node, def_node)
964
+ location = write_node.name_loc || write_node.location
965
+ Diagnostic.new(
966
+ rule: RULE_DEAD_ASSIGNMENT,
967
+ path: path,
968
+ line: location.start_line,
969
+ column: location.start_column + 1,
970
+ message: "local `#{write_node.name}' assigned in `#{def_node.name}' but never read",
971
+ severity: :warning
972
+ )
973
+ end
974
+
975
+ # rubocop:disable Metrics/ParameterLists
976
+ def build_ivar_write_mismatch_diagnostic(path, node, class_name, ivar_name, first_class, other_class)
977
+ location = node.name_loc || node.location
978
+ Diagnostic.new(
979
+ rule: RULE_IVAR_WRITE_MISMATCH,
980
+ path: path,
981
+ line: location.start_line,
982
+ column: location.start_column + 1,
983
+ message: "instance variable `#{ivar_name}' on #{class_name} was previously assigned " \
984
+ "#{first_class}; this write assigns #{other_class}",
985
+ severity: :error
986
+ )
987
+ end
988
+ # rubocop:enable Metrics/ParameterLists
989
+
990
+ # Returns the dead-branch node for a literal-predicate
991
+ # if/unless, or nil when no observable branch is dead.
992
+ def unreachable_branch_for(node, truthy)
993
+ dead =
994
+ case node
995
+ when Prism::IfNode then truthy ? node.subsequent : node.statements
996
+ when Prism::UnlessNode then truthy ? node.statements : node.else_clause
997
+ end
998
+ dead unless dead.nil?
999
+ end
1000
+
1001
+ def build_unreachable_branch_diagnostic(path, dead_branch, polarity)
1002
+ location = dead_branch.location
1003
+ Diagnostic.new(
1004
+ rule: RULE_UNREACHABLE_BRANCH,
1005
+ path: path,
1006
+ line: location.start_line,
1007
+ column: location.start_column + 1,
1008
+ message: "unreachable branch: literal predicate is always #{polarity}",
1009
+ severity: :warning
1010
+ )
1011
+ end
1012
+
701
1013
  # v0.0.2 #4 — argument-type-mismatch diagnostic.
702
1014
  # Walks a call's positional arguments and checks each
703
1015
  # against the matching parameter's RBS type via
@@ -915,6 +1227,19 @@ module Rigor
915
1227
  # Method overloads contribute their union of declared
916
1228
  # return types (any one of them satisfying the body
917
1229
  # silences the rule).
1230
+ #
1231
+ # v0.1.2 — when the RBS sig carries a
1232
+ # `%a{rigor:v1:return: <refinement>}` annotation
1233
+ # (recognised by `RbsExtended.read_return_type_override`),
1234
+ # the refinement carrier replaces the RBS-declared
1235
+ # return for this rule. Annotation-driven refinements
1236
+ # — `non-empty-string`, `positive-int`, `non-empty-
1237
+ # array[Integer]`, etc. — are stricter than the
1238
+ # underlying RBS class, so a body whose inferred type
1239
+ # the bare RBS sig would accept may still fail the
1240
+ # refinement (e.g. `def name; ""; end` returns
1241
+ # `Constant[""]`, accepted by `String` but rejected by
1242
+ # `non-empty-string`).
918
1243
  def declared_return_type(def_node, scope_index)
919
1244
  scope = scope_index[def_node]
920
1245
  return nil if scope.nil?
@@ -930,6 +1255,9 @@ module Rigor
930
1255
  end
931
1256
  return nil if method_def.nil?
932
1257
 
1258
+ override = Rigor::RbsExtended.read_return_type_override(method_def)
1259
+ return override if override
1260
+
933
1261
  declared_return_union(method_def, scope.environment)
934
1262
  end
935
1263