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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6a6dc65f5d9bf7eb3cd87b6cd7b276f42a7c5e7c053b001017cb89a3e7c636d7
4
- data.tar.gz: 7b70c37b3fe532121a0f3d76c67e07570b9a070155c63f6bcec7e612dfd15ea5
3
+ metadata.gz: 47346567152367cb408115b6a33214e43468f2255bd632b4894d8c68164b8ada
4
+ data.tar.gz: 407d5cda9e08342670a4eeb05ae2c565d398866174d292c586793cb213a77fdb
5
5
  SHA512:
6
- metadata.gz: 167ca081df2d10e10b529014f53333cd34424a5fb68a88e2ab33a044eba9d72fa89fdf8b6c43e4cd5f3ee69b355f16d4d2762cbb8356e13649b55d4287aeb808
7
- data.tar.gz: 3cbcffa0ce1d659512c42cfa608357b6d06dbc45b3769ca7719bfad4c021e3f165ff8a77a58fbde024768addd862b2c51170167ef46263aec04fb1656588f6cb
6
+ metadata.gz: f6ffecd573e1bbb387c861cbe3a6d909d217dfadef453a7a84339a4f7fd44e2c86dbbac54c8369298b56a2de3771c0f75c28cfe098cb895355a52155657f4f9d
7
+ data.tar.gz: 6f924aed4bc15581bae58ffa62a5d8194db3fabbdc3056f2b6cfc4f068f972063e0148f85df8b8ef3e5f5003fad35b4e9016e534ef604cbfc581d0cdab76bb7b
@@ -33,8 +33,9 @@ classes:
33
33
  arity: -1
34
34
  defined_at: references/ruby/range.c:2851
35
35
  c_body_at: references/ruby/range.c:103
36
- c_effects: []
37
- purity: leaf
36
+ c_effects:
37
+ - mutate
38
+ purity: mutates_self
38
39
  rbs:
39
40
  - "(Elem from, Elem to, ?boolish exclude_end) -> void"
40
41
  rbs_at: references/rbs/core/range.rbs:608
@@ -44,8 +45,9 @@ classes:
44
45
  arity: 1
45
46
  defined_at: references/ruby/range.c:2852
46
47
  c_body_at: references/ruby/range.c:115
47
- c_effects: []
48
- purity: leaf
48
+ c_effects:
49
+ - mutate
50
+ purity: mutates_self
49
51
  "==":
50
52
  source: c
51
53
  cfunc: range_eq
@@ -50,8 +50,9 @@ classes:
50
50
  arity: 1
51
51
  defined_at: references/ruby/string.c:12794
52
52
  c_body_at: references/ruby/string.c:6548
53
- c_effects: []
54
- purity: leaf
53
+ c_effects:
54
+ - mutate
55
+ purity: mutates_self
55
56
  rbs:
56
57
  - "(string other_string) -> self"
57
58
  rbs_at: references/rbs/core/string.rbs:4019
@@ -61,8 +62,9 @@ classes:
61
62
  arity: 1
62
63
  defined_at: references/ruby/string.c:12795
63
64
  c_body_at: references/ruby/string.c:6548
64
- c_effects: []
65
- purity: leaf
65
+ c_effects:
66
+ - mutate
67
+ purity: mutates_self
66
68
  "<=>":
67
69
  source: c
68
70
  cfunc: rb_str_cmp_m
@@ -1167,8 +1169,9 @@ classes:
1167
1169
  arity: -1
1168
1170
  defined_at: references/ruby/string.c:12904
1169
1171
  c_body_at: references/ruby/string.c:10172
1170
- c_effects: []
1171
- purity: leaf
1172
+ c_effects:
1173
+ - mutate
1174
+ purity: mutates_self
1172
1175
  rbs:
1173
1176
  - "(nil) -> nil"
1174
1177
  - "(?string? separator) -> self?"
@@ -1223,8 +1226,9 @@ classes:
1223
1226
  arity: 1
1224
1227
  defined_at: references/ruby/string.c:12909
1225
1228
  c_body_at: references/ruby/string.c:11476
1226
- c_effects: []
1227
- purity: leaf
1229
+ c_effects:
1230
+ - mutate
1231
+ purity: mutates_self
1228
1232
  rbs:
1229
1233
  - "(string suffix) -> self?"
1230
1234
  rbs_at: references/rbs/core/string.rbs:2664
@@ -1459,8 +1463,9 @@ classes:
1459
1463
  arity: 1
1460
1464
  defined_at: references/ruby/string.c:12937
1461
1465
  c_body_at: references/ruby/string.c:11563
1462
- c_effects: []
1463
- purity: leaf
1466
+ c_effects:
1467
+ - mutate
1468
+ purity: mutates_self
1464
1469
  rbs:
1465
1470
  - "(encoding enc) -> self"
1466
1471
  rbs_at: references/rbs/core/string.rbs:3207
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Analysis
7
+ module CheckRules
8
+ # Walks a parse tree and collects every `IfNode` / `UnlessNode`
9
+ # whose predicate folds to a `Type::Constant` AND is NOT
10
+ # disqualified by the rule's conservative envelope.
11
+ #
12
+ # The companion rule (`flow.always-truthy-condition`) is
13
+ # the inferred-constant counterpart to
14
+ # `flow.unreachable-branch` (which only fires on syntactic
15
+ # literals). The literal-only rule was the v0.1.2 first-cut
16
+ # because Rigor's incomplete loop / mutation / RBS-strictness
17
+ # modelling produces inferred constants that look real but
18
+ # are pragmatically false-positives. This collector adds two
19
+ # surgical skips to bring the inferred-constant case in
20
+ # without resurfacing those false positives:
21
+ #
22
+ # - **Inside a loop / block**: predicates nested inside
23
+ # `WhileNode` / `UntilNode` / `ForNode` / `BlockNode`
24
+ # ancestors. Mutation tracking through loop bodies is
25
+ # incomplete (`shift = 0; loop { shift += 7 }` keeps
26
+ # `shift` at `Constant<7>` per the analyzer), so a
27
+ # `Constant<bool>` predicate inside the loop body is
28
+ # suspect.
29
+ # - **Defensive predicate calls**: `.nil?` / `.empty?` /
30
+ # `.zero?` / `.any?` / `.none?` / `.all?`. These read
31
+ # like the user explicitly checking for a runtime
32
+ # condition that the type system already proves can't
33
+ # happen — Rigor's strict-on-returns RBS often disagrees
34
+ # with the user's defensive check (e.g. `Module#name`
35
+ # returns `String` per RBS but anonymous classes really
36
+ # do return `nil`). Skipping these forms keeps the rule
37
+ # useful for genuine logic errors without faulting
38
+ # defensive code.
39
+ #
40
+ # Also skipped (so the literal-only `unreachable-branch`
41
+ # rule doesn't double-fire alongside this one):
42
+ #
43
+ # - Predicates whose direct AST shape is a literal
44
+ # (`TrueNode` / `FalseNode` / `NilNode` / numeric / string
45
+ # / symbol / regexp).
46
+ class AlwaysTruthyConditionCollector
47
+ DEFENSIVE_PREDICATES = %i[nil? empty? zero? any? none? all? respond_to?].freeze
48
+ LOOP_OR_BLOCK_NODE_CLASSES = [
49
+ Prism::WhileNode, Prism::UntilNode, Prism::ForNode, Prism::BlockNode
50
+ ].freeze
51
+ LITERAL_PREDICATE_NODE_CLASSES = [
52
+ Prism::TrueNode, Prism::FalseNode, Prism::NilNode,
53
+ Prism::IntegerNode, Prism::FloatNode,
54
+ Prism::StringNode, Prism::SymbolNode, Prism::RegularExpressionNode
55
+ ].freeze
56
+
57
+ Result = Data.define(:node, :polarity)
58
+
59
+ # @return [Array<Result>] one entry per qualifying
60
+ # predicate. Empty when the tree carries no firing
61
+ # predicates.
62
+ def initialize(scope_index)
63
+ @scope_index = scope_index
64
+ @results = []
65
+ end
66
+
67
+ def collect(root)
68
+ walk(root, in_loop_or_block: false)
69
+ @results.freeze
70
+ end
71
+
72
+ private
73
+
74
+ def walk(node, in_loop_or_block:)
75
+ return unless node.is_a?(Prism::Node)
76
+
77
+ collect_predicate(node) if conditional_node?(node) && !in_loop_or_block
78
+
79
+ child_in_loop_or_block = in_loop_or_block || enters_loop_or_block?(node)
80
+ node.compact_child_nodes.each { |child| walk(child, in_loop_or_block: child_in_loop_or_block) }
81
+ end
82
+
83
+ def conditional_node?(node)
84
+ node.is_a?(Prism::IfNode) || node.is_a?(Prism::UnlessNode)
85
+ end
86
+
87
+ def enters_loop_or_block?(node)
88
+ LOOP_OR_BLOCK_NODE_CLASSES.any? { |klass| node.is_a?(klass) }
89
+ end
90
+
91
+ def collect_predicate(node)
92
+ predicate = node.predicate
93
+ return if literal_predicate?(predicate)
94
+ return if defensive_predicate?(predicate)
95
+
96
+ scope = @scope_index[node]
97
+ return if scope.nil?
98
+
99
+ predicate_type = scope.type_of(predicate)
100
+ return unless predicate_type.is_a?(Type::Constant)
101
+
102
+ polarity = predicate_type.value.nil? || predicate_type.value == false ? :falsey : :truthy
103
+ @results << Result.new(node: predicate, polarity: polarity)
104
+ end
105
+
106
+ def literal_predicate?(predicate)
107
+ LITERAL_PREDICATE_NODE_CLASSES.any? { |klass| predicate.is_a?(klass) }
108
+ end
109
+
110
+ def defensive_predicate?(predicate)
111
+ predicate.is_a?(Prism::CallNode) && DEFENSIVE_PREDICATES.include?(predicate.name)
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Analysis
7
+ module CheckRules
8
+ # Walks a parse tree and collects every `LocalVariableWriteNode`
9
+ # inside a `DefNode` body whose target name is **never read**
10
+ # within the same body.
11
+ #
12
+ # The collector is the read-side companion to the
13
+ # `def.dead-assignment` rule. v0.1.2 ships the narrowest
14
+ # envelope that catches the most common typo / refactoring-
15
+ # leftover shape ("wrote and never used") without surfacing
16
+ # false positives:
17
+ #
18
+ # - Only `Prism::DefNode` bodies are scanned. Top-level
19
+ # scripts and class-body assignments are skipped — their
20
+ # variable surface bleeds across requires / introspection
21
+ # in ways the rule cannot reason about.
22
+ # - Only plain `LocalVariableWriteNode` writes are
23
+ # considered. Operator-writes (`x += 1`), and-/or-writes
24
+ # (`x ||= 1`), and `MultiWriteNode` destructures (`a, b =
25
+ # foo`) are skipped because their write semantics are
26
+ # intertwined with reads or with a wider tuple binding.
27
+ # - The "is this name ever read" question is answered by
28
+ # any `LocalVariableReadNode` anywhere in the def
29
+ # subtree — so a closure capture, a `return x`, a
30
+ # block-call argument, an interpolated string all count
31
+ # as a read.
32
+ # - A write whose name starts with `_` is skipped per the
33
+ # Ruby convention that `_` / `_foo` declares
34
+ # "intentionally unused".
35
+ # - The last statement of a def body is skipped — Ruby's
36
+ # implicit return treats `def foo; x = 1; end` as
37
+ # returning `1`, so the trailing write is intentional.
38
+ class DeadAssignmentCollector
39
+ # Returns `Array<{def_class:, def_name:, write_node:}>`
40
+ # — one entry per dead-assignment write. Empty when
41
+ # the tree has no qualifying writes.
42
+ def initialize(scope_index)
43
+ @scope_index = scope_index
44
+ @results = []
45
+ end
46
+
47
+ def collect(root)
48
+ walk_for_def_nodes(root)
49
+ @results.freeze
50
+ end
51
+
52
+ private
53
+
54
+ def walk_for_def_nodes(node)
55
+ return unless node.is_a?(Prism::Node)
56
+
57
+ collect_def_assignments(node) if node.is_a?(Prism::DefNode)
58
+ node.compact_child_nodes.each { |child| walk_for_def_nodes(child) }
59
+ end
60
+
61
+ def collect_def_assignments(def_node)
62
+ body = def_node.body
63
+ return if body.nil?
64
+
65
+ read_names = gather_read_names(body)
66
+ last_node = trailing_statement(body)
67
+
68
+ gather_write_nodes(body).each do |write|
69
+ next if write.equal?(last_node)
70
+ next if write.name.to_s.start_with?("_")
71
+ next if read_names.include?(write.name)
72
+
73
+ @results << { def_node: def_node, write_node: write }
74
+ end
75
+ end
76
+
77
+ def gather_read_names(node, accumulator = Set.new)
78
+ return accumulator unless node.is_a?(Prism::Node)
79
+
80
+ accumulator << node.name if node.is_a?(Prism::LocalVariableReadNode)
81
+ # Operator/and/or-writes implicitly read the prior
82
+ # binding — count them too so `x = 1; x ||= 2; x` /
83
+ # similar shapes don't trip the rule.
84
+ accumulator << node.name if reading_assignment?(node)
85
+
86
+ node.compact_child_nodes.each { |child| gather_read_names(child, accumulator) }
87
+ accumulator
88
+ end
89
+
90
+ def reading_assignment?(node)
91
+ node.is_a?(Prism::LocalVariableOperatorWriteNode) ||
92
+ node.is_a?(Prism::LocalVariableAndWriteNode) ||
93
+ node.is_a?(Prism::LocalVariableOrWriteNode)
94
+ end
95
+
96
+ def gather_write_nodes(node, accumulator = [])
97
+ return accumulator unless node.is_a?(Prism::Node)
98
+
99
+ accumulator << node if node.is_a?(Prism::LocalVariableWriteNode)
100
+ # Don't recurse into nested DefNodes — their bodies
101
+ # carry their own dead-assignment scope and the
102
+ # outer walker visits them separately.
103
+ return accumulator if node.is_a?(Prism::DefNode) && !accumulator.last.equal?(node)
104
+
105
+ node.compact_child_nodes.each { |child| gather_write_nodes(child, accumulator) }
106
+ accumulator
107
+ end
108
+
109
+ # Returns the final statement of a body node, descending
110
+ # into wrappers Ruby preserves verbatim (`begin ... end`
111
+ # blocks). Used to skip the implicit-return write at the
112
+ # tail of a method body.
113
+ def trailing_statement(body)
114
+ case body
115
+ when Prism::StatementsNode then body.body.last
116
+ when Prism::BeginNode
117
+ body.statements ? trailing_statement(body.statements) : nil
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Analysis
7
+ module CheckRules
8
+ # Walks a parse tree and collects every
9
+ # `Prism::InstanceVariableWriteNode` inside an instance
10
+ # method body of a `Prism::ClassNode` / `Prism::ModuleNode`,
11
+ # grouped by (class qualified name, ivar name).
12
+ #
13
+ # The collector is the read-side companion to
14
+ # `Inference::ScopeIndexer#build_class_ivar_index`. The
15
+ # indexer unions the rvalue types into a single per-ivar
16
+ # carrier; this collector preserves the per-write list so
17
+ # the `def.ivar-write-mismatch` rule can compare each
18
+ # write's concrete class against the first.
19
+ #
20
+ # Skipped on purpose:
21
+ #
22
+ # - Singleton-method bodies (`def self.foo`). Their ivars
23
+ # live on the class object, not on instances.
24
+ # - Class-body ivar writes outside any def — the
25
+ # `Module#@var` surface is a separate slice the engine
26
+ # doesn't yet model.
27
+ # - Nested classes / modules / defs inside a method body
28
+ # are barriers, mirroring the indexer's
29
+ # `IVAR_BARRIER_NODES` policy.
30
+ class IvarWriteCollector
31
+ BARRIER_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode].freeze
32
+ private_constant :BARRIER_NODES
33
+
34
+ # Returns `Hash[class_name (String) => Hash[ivar_name
35
+ # (Symbol) => Array<{node:, type:}>]]`. Empty when the
36
+ # tree has no qualifying writes.
37
+ def initialize(scope_index)
38
+ @scope_index = scope_index
39
+ @accumulator = {}
40
+ end
41
+
42
+ def collect(root)
43
+ walk(root, [])
44
+ @accumulator.transform_values(&:freeze).freeze
45
+ end
46
+
47
+ private
48
+
49
+ def walk(node, qualified_prefix)
50
+ return unless node.is_a?(Prism::Node)
51
+
52
+ case node
53
+ when Prism::ClassNode, Prism::ModuleNode
54
+ name = qualified_name_for(node.constant_path)
55
+ if name
56
+ walk(node.body, qualified_prefix + [name]) if node.body
57
+ return
58
+ end
59
+ when Prism::DefNode
60
+ collect_def_writes(node, qualified_prefix)
61
+ return
62
+ end
63
+
64
+ node.compact_child_nodes.each { |child| walk(child, qualified_prefix) }
65
+ end
66
+
67
+ def collect_def_writes(def_node, qualified_prefix)
68
+ return if def_node.body.nil? || qualified_prefix.empty?
69
+ return if def_node.receiver.is_a?(Prism::SelfNode)
70
+
71
+ class_name = qualified_prefix.join("::")
72
+ gather_writes(def_node.body, class_name)
73
+ end
74
+
75
+ def gather_writes(node, class_name)
76
+ return unless node.is_a?(Prism::Node)
77
+
78
+ record_write(node, class_name) if node.is_a?(Prism::InstanceVariableWriteNode)
79
+ return if BARRIER_NODES.any? { |klass| node.is_a?(klass) }
80
+
81
+ node.compact_child_nodes.each { |child| gather_writes(child, class_name) }
82
+ end
83
+
84
+ def record_write(node, class_name)
85
+ scope = @scope_index[node]
86
+ return if scope.nil?
87
+
88
+ rvalue_type = scope.type_of(node.value)
89
+ @accumulator[class_name] ||= {}
90
+ @accumulator[class_name][node.name] ||= []
91
+ @accumulator[class_name][node.name] << { node: node, type: rvalue_type }
92
+ end
93
+
94
+ # Same shape resolution as `ScopeIndexer.qualified_name_for`
95
+ # (single-segment ConstantReadNode and dotted
96
+ # ConstantPathNode). Inlined here to keep the collector
97
+ # self-contained — the rule lives outside the indexer's
98
+ # private surface.
99
+ def qualified_name_for(constant_path_node)
100
+ case constant_path_node
101
+ when Prism::ConstantReadNode then constant_path_node.name.to_s
102
+ when Prism::ConstantPathNode then render_constant_path(constant_path_node)
103
+ end
104
+ end
105
+
106
+ def render_constant_path(node)
107
+ prefix =
108
+ case node.parent
109
+ when Prism::ConstantReadNode then "#{node.parent.name}::"
110
+ when Prism::ConstantPathNode then "#{render_constant_path(node.parent)}::"
111
+ else ""
112
+ end
113
+ "#{prefix}#{node.name}"
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end