branchproof 0.7.0 → 0.8.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/README.md +212 -11
  4. data/doc/Branchproof/Analyzer.md +2 -2
  5. data/doc/Branchproof/CLI.md +4 -0
  6. data/doc/Branchproof/ComparisonReport.md +4 -0
  7. data/doc/Branchproof/Constraints/Solver.md +21 -0
  8. data/doc/Branchproof/Constraints.md +98 -0
  9. data/doc/Branchproof/CoverageIndex.md +3 -0
  10. data/doc/Branchproof/DecisionSyntax.md +6 -4
  11. data/doc/Branchproof/DecisionTable.md +153 -0
  12. data/doc/Branchproof/FocusedReport.md +5 -2
  13. data/doc/Branchproof/Loader.md +0 -4
  14. data/doc/Branchproof/MinitestAdapter.md +0 -3
  15. data/doc/Branchproof/Report.md +18 -0
  16. data/doc/Branchproof/Runtime.md +4 -0
  17. data/doc/Branchproof/SavedReport.md +15 -0
  18. data/doc/Branchproof.md +4 -1
  19. data/doc/CHANGELOG.md +35 -0
  20. data/doc/README.md +212 -11
  21. data/lib/branchproof/analyzer.rb +80 -44
  22. data/lib/branchproof/cli.rb +28 -19
  23. data/lib/branchproof/comparison.rb +207 -19
  24. data/lib/branchproof/comparison_report.rb +49 -1
  25. data/lib/branchproof/constraints.rb +363 -0
  26. data/lib/branchproof/coverage_index.rb +30 -1
  27. data/lib/branchproof/decision_syntax.rb +31 -15
  28. data/lib/branchproof/decision_table.rb +377 -0
  29. data/lib/branchproof/evidence.rb +51 -31
  30. data/lib/branchproof/flow_instrumentation.rb +17 -17
  31. data/lib/branchproof/focused_report.rb +77 -9
  32. data/lib/branchproof/instrumenter.rb +45 -34
  33. data/lib/branchproof/limits.rb +4 -1
  34. data/lib/branchproof/loader.rb +18 -6
  35. data/lib/branchproof/minimizer.rb +18 -13
  36. data/lib/branchproof/minitest_adapter.rb +11 -16
  37. data/lib/branchproof/records.rb +2 -0
  38. data/lib/branchproof/report.rb +301 -68
  39. data/lib/branchproof/runtime.rb +22 -30
  40. data/lib/branchproof/saved_report.rb +200 -14
  41. data/lib/branchproof/source.rb +90 -48
  42. data/lib/branchproof/version.rb +1 -1
  43. data/lib/branchproof/worker.rb +1 -4
  44. data/lib/branchproof.rb +2 -0
  45. data/llms.txt +4 -1
  46. data/sig/branchproof.rbs +47 -1
  47. metadata +6 -1
@@ -0,0 +1,363 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Branchproof
6
+ # Normalizes simple atomic conditions into subject/operator/value records and
7
+ # decides, conservatively, whether a set of required condition truths can hold
8
+ # at the same time.
9
+ #
10
+ # The module never evaluates application code. It only inspects the syntax of
11
+ # a condition, and it answers "contradictory" only when the contradiction
12
+ # follows from the normalized constraints alone. Every other shape stays
13
+ # unrepresented, which leaves the owning decision-table rule reachability
14
+ # +unknown+.
15
+ module Constraints
16
+ VERSION = 2
17
+
18
+ NUMERIC_OPERATORS = %w[< <= > >=].freeze
19
+ COMPARISON_OPERATORS = %w[< <= > >= == !=].freeze
20
+ OPERATORS = (COMPARISON_OPERATORS + ["truthy"]).freeze
21
+ FLIPPED = { "<" => ">", "<=" => ">=", ">" => "<", ">=" => "<=", "==" => "==", "!=" => "!=" }.freeze
22
+ NUMERIC_TYPES = %w[integer float].freeze
23
+ FALSEY_TYPES = %w[nil false].freeze
24
+ LITERAL_TYPES = %w[integer float symbol nil true false].freeze
25
+ SUBJECT_KINDS = %w[local instance class global constant].freeze
26
+ REASONS = %w[
27
+ conflicting_numeric_bounds conflicting_equalities equality_outside_numeric_range
28
+ nil_conflict boolean_literal_conflict
29
+ ].freeze
30
+ REASON_MESSAGES = {
31
+ "conflicting_numeric_bounds" => "conflicting numeric bounds",
32
+ "conflicting_equalities" => "conflicting equalities",
33
+ "equality_outside_numeric_range" => "required equality is outside the required numeric range",
34
+ "nil_conflict" => "conflicting nil requirements",
35
+ "boolean_literal_conflict" => "conflicting Boolean literal requirements"
36
+ }.freeze
37
+
38
+ # Accumulates the constraints one decision-table rule requires and reports
39
+ # the first proven contradiction. Subjects never interact with each other.
40
+ class Solver
41
+ def initialize
42
+ @subjects = {}
43
+ end
44
+
45
+ # Returns a reason code when the rule became unsatisfiable, otherwise nil.
46
+ def add(constraint, truth)
47
+ constraint = Constraints.symbolize(constraint)
48
+ return nil unless Constraints.usable?(constraint)
49
+
50
+ add_prepared(constraint, truth)
51
+ end
52
+
53
+ # Adds a constraint that has already been symbolized and validated by
54
+ # +usable?+. Source inventory can use this path after preparing each leaf
55
+ # once instead of repeating normalization for every solver state.
56
+ def add_prepared(constraint, truth)
57
+ state = (@subjects[Constraints.subject_key(constraint[:subject])] ||= new_state)
58
+ apply(state, constraint[:operator].to_s, constraint[:literal], truth ? true : false)
59
+ end
60
+
61
+ private
62
+
63
+ def new_state
64
+ { lower: nil, upper: nil, equality: nil, exclusions: [], nil_required: nil, truthy: nil,
65
+ comparisons: {} }
66
+ end
67
+
68
+ def apply(state, operator, literal, truth)
69
+ if NUMERIC_OPERATORS.include?(operator)
70
+ key = [operator, literal[:type].to_s, literal[:value]]
71
+ opposite = state[:comparisons][key]
72
+ return "conflicting_numeric_bounds" if !opposite.nil? && opposite != truth
73
+
74
+ state[:comparisons][key] = truth
75
+ end
76
+ case operator
77
+ when "truthy" then truth ? require_truthy(state) : require_falsey(state)
78
+ when "==" then truth ? require_equal(state, literal) : exclude(state, literal)
79
+ when "!=" then truth ? exclude(state, literal) : require_equal(state, literal)
80
+ # A false inequality is not a closed numeric bound: Ruby values such as
81
+ # Float::NAN make both sides of a total-order partition false. Keep the
82
+ # exact same-predicate contradiction above, but otherwise leave false
83
+ # inequalities unknown unless another true inequality proves a clash.
84
+ when "<" then truth ? upper(state, literal, false) : nil
85
+ when "<=" then truth ? upper(state, literal, true) : nil
86
+ when ">" then truth ? lower(state, literal, false) : nil
87
+ when ">=" then truth ? lower(state, literal, true) : nil
88
+ end
89
+ end
90
+
91
+ def require_equal(state, literal)
92
+ type = literal[:type].to_s
93
+ # A nil or truthiness clash is reported before a plain equality clash so
94
+ # the reason code names the most specific contradiction.
95
+ reason = equality_truthiness_conflict(state, type)
96
+ return reason if reason
97
+
98
+ if state[:equality] && !Constraints.same_literal?(state[:equality], literal)
99
+ return nil if Constraints.mixed_numeric_literals?(state[:equality], literal)
100
+
101
+ return "conflicting_equalities"
102
+ end
103
+ return "conflicting_equalities" if state[:exclusions].any? { |item| Constraints.same_literal?(item, literal) }
104
+
105
+ state[:equality] = literal
106
+ state[:nil_required] = type == "nil"
107
+ return "equality_outside_numeric_range" if Constraints.numeric?(literal) && !within_bounds?(state,
108
+ literal[:value])
109
+
110
+ nil
111
+ end
112
+
113
+ def equality_truthiness_conflict(state, type)
114
+ if type == "nil"
115
+ return "nil_conflict" if state[:nil_required] == false || state[:truthy] == true
116
+ elsif state[:nil_required] == true
117
+ return "nil_conflict"
118
+ end
119
+ return "boolean_literal_conflict" if state[:truthy] == true && type == "false"
120
+ return "boolean_literal_conflict" if state[:truthy] == false && !FALSEY_TYPES.include?(type)
121
+
122
+ nil
123
+ end
124
+
125
+ def exclude(state, literal)
126
+ if literal[:type].to_s == "nil"
127
+ return "nil_conflict" if state[:nil_required] == true
128
+ return "nil_conflict" if state[:equality] && state[:equality][:type].to_s == "nil"
129
+
130
+ state[:nil_required] = false
131
+ elsif state[:equality] && Constraints.same_literal?(state[:equality], literal)
132
+ return "conflicting_equalities"
133
+ end
134
+ state[:exclusions] << literal unless state[:exclusions].any? { |item| Constraints.same_literal?(item, literal) }
135
+ nil
136
+ end
137
+
138
+ def require_truthy(state)
139
+ return "boolean_literal_conflict" if state[:truthy] == false
140
+ return "nil_conflict" if state[:nil_required] == true
141
+ return "nil_conflict" if state[:equality] && state[:equality][:type].to_s == "nil"
142
+ return "boolean_literal_conflict" if state[:equality] && state[:equality][:type].to_s == "false"
143
+
144
+ state[:truthy] = true
145
+ state[:nil_required] = false
146
+ nil
147
+ end
148
+
149
+ def require_falsey(state)
150
+ return "boolean_literal_conflict" if state[:truthy] == true
151
+ return "boolean_literal_conflict" if state[:equality] && !FALSEY_TYPES.include?(state[:equality][:type].to_s)
152
+
153
+ state[:truthy] = false
154
+ nil
155
+ end
156
+
157
+ def lower(state, literal, inclusive)
158
+ return nil unless Constraints.numeric?(literal)
159
+ return nil unless comparable_bound?(state, literal)
160
+
161
+ current = state[:lower]
162
+ value = literal[:value]
163
+ if current.nil? || value > current[:value] || (value == current[:value] && !inclusive)
164
+ state[:lower] = { type: literal[:type].to_s, value: value, inclusive: inclusive }
165
+ end
166
+ bounds_conflict(state)
167
+ end
168
+
169
+ def upper(state, literal, inclusive)
170
+ return nil unless Constraints.numeric?(literal)
171
+ return nil unless comparable_bound?(state, literal)
172
+
173
+ current = state[:upper]
174
+ value = literal[:value]
175
+ if current.nil? || value < current[:value] || (value == current[:value] && !inclusive)
176
+ state[:upper] = { type: literal[:type].to_s, value: value, inclusive: inclusive }
177
+ end
178
+ bounds_conflict(state)
179
+ end
180
+
181
+ def bounds_conflict(state)
182
+ low = state[:lower]
183
+ high = state[:upper]
184
+ if low && high && (low[:type] == high[:type])
185
+ return "conflicting_numeric_bounds" if low[:value] > high[:value]
186
+ return "conflicting_numeric_bounds" if low[:value] == high[:value] && !(low[:inclusive] && high[:inclusive])
187
+ end
188
+ equality = state[:equality]
189
+ return nil unless equality && Constraints.numeric?(equality) && !within_bounds?(state, equality[:value])
190
+
191
+ "equality_outside_numeric_range"
192
+ end
193
+
194
+ def within_bounds?(state, value)
195
+ low = state[:lower]
196
+ high = state[:upper]
197
+ return false if low && low[:type] == state[:equality][:type] &&
198
+ (low[:inclusive] ? value < low[:value] : value <= low[:value])
199
+ return false if high && high[:type] == state[:equality][:type] &&
200
+ (high[:inclusive] ? value > high[:value] : value >= high[:value])
201
+
202
+ true
203
+ end
204
+
205
+ def comparable_bound?(state, literal)
206
+ [state[:lower], state[:upper]].compact.all? { |bound| bound[:type] == literal[:type] }
207
+ end
208
+ end
209
+
210
+ module_function
211
+
212
+ # Derives the normalized constraint of one atomic condition, or nil when the
213
+ # expression is outside the supported vocabulary.
214
+ def for_node(node)
215
+ return nil unless node.is_a?(Prism::Node)
216
+
217
+ subject = subject_for(node)
218
+ return { subject: subject, operator: "truthy", literal: nil } if subject
219
+ return nil unless simple_call?(node)
220
+
221
+ name = node.name.to_s
222
+ return nil_constraint(node) if name == "nil?"
223
+ return nil unless FLIPPED.key?(name)
224
+
225
+ comparison_constraint(node, name)
226
+ end
227
+
228
+ def comparison_constraint(node, name)
229
+ argument = single_argument(node)
230
+ return nil unless argument
231
+
232
+ subject = subject_for(node.receiver)
233
+ if subject
234
+ literal = literal_for(argument)
235
+ operator = name
236
+ else
237
+ subject = subject_for(argument)
238
+ literal = subject && literal_for(node.receiver)
239
+ operator = FLIPPED.fetch(name)
240
+ end
241
+ return nil unless subject && literal
242
+ return nil if NUMERIC_OPERATORS.include?(operator) && !NUMERIC_TYPES.include?(literal[:type])
243
+
244
+ { subject: subject, operator: operator, literal: literal }
245
+ end
246
+
247
+ def nil_constraint(node)
248
+ return nil unless node.arguments.nil?
249
+
250
+ subject = subject_for(node.receiver)
251
+ return nil unless subject
252
+
253
+ { subject: subject, operator: "==", literal: { type: "nil", value: nil } }
254
+ end
255
+
256
+ def simple_call?(node)
257
+ node.is_a?(Prism::CallNode) && !node.safe_navigation? && node.block.nil?
258
+ end
259
+
260
+ def single_argument(node)
261
+ arguments = node.arguments&.arguments
262
+ return nil unless arguments && arguments.length == 1
263
+
264
+ argument = arguments.first
265
+ return nil if argument.is_a?(Prism::SplatNode) || argument.is_a?(Prism::KeywordHashNode) ||
266
+ argument.is_a?(Prism::BlockArgumentNode)
267
+
268
+ argument
269
+ end
270
+
271
+ # Only unambiguously identifiable storage locations become subjects.
272
+ # Method-call receivers stay unsupported: a repeated call may return a
273
+ # different value or have side effects (see the v1 constraint scope).
274
+ def subject_for(node)
275
+ case node
276
+ when Prism::LocalVariableReadNode then { kind: "local", name: node.name.to_s }
277
+ when Prism::InstanceVariableReadNode then { kind: "instance", name: node.name.to_s }
278
+ when Prism::ClassVariableReadNode then { kind: "class", name: node.name.to_s }
279
+ when Prism::GlobalVariableReadNode then { kind: "global", name: node.name.to_s }
280
+ when Prism::ConstantReadNode then { kind: "constant", name: node.name.to_s }
281
+ end
282
+ end
283
+
284
+ # String equality stays unsupported in v1 so that encoding and mutability
285
+ # questions cannot turn into an impossibility claim.
286
+ def literal_for(node)
287
+ case node
288
+ when Prism::IntegerNode then { type: "integer", value: node.value }
289
+ when Prism::FloatNode
290
+ return nil unless node.value.finite?
291
+
292
+ { type: "float", value: node.value }
293
+ when Prism::SymbolNode then { type: "symbol", value: node.unescaped.to_s }
294
+ when Prism::NilNode then { type: "nil", value: nil }
295
+ when Prism::TrueNode then { type: "true", value: true }
296
+ when Prism::FalseNode then { type: "false", value: false }
297
+ end
298
+ end
299
+
300
+ def usable?(constraint)
301
+ return false unless constraint.is_a?(Hash)
302
+
303
+ subject = symbolize(constraint[:subject])
304
+ operator = constraint[:operator].to_s
305
+ return false unless subject.is_a?(Hash) && SUBJECT_KINDS.include?(subject[:kind].to_s)
306
+ return false if subject[:name].to_s.empty?
307
+ return false unless OPERATORS.include?(operator)
308
+ return true if operator == "truthy"
309
+
310
+ literal = symbolize(constraint[:literal])
311
+ return false unless literal.is_a?(Hash) && LITERAL_TYPES.include?(literal[:type].to_s)
312
+ return false if NUMERIC_OPERATORS.include?(operator) && !NUMERIC_TYPES.include?(literal[:type].to_s)
313
+ return false unless valid_literal_value?(literal)
314
+
315
+ true
316
+ end
317
+
318
+ def valid_literal_value?(literal)
319
+ case literal[:type].to_s
320
+ when "integer" then literal[:value].is_a?(Integer)
321
+ when "float" then literal[:value].is_a?(Float) && literal[:value].finite?
322
+ when "symbol" then literal[:value].is_a?(String)
323
+ when "nil" then literal[:value].nil?
324
+ when "true" then literal[:value] == true
325
+ when "false" then literal[:value] == false
326
+ else false
327
+ end
328
+ end
329
+
330
+ def subject_key(subject)
331
+ subject = symbolize(subject)
332
+ [subject[:kind].to_s, subject[:name].to_s]
333
+ end
334
+
335
+ def numeric?(literal) = NUMERIC_TYPES.include?(literal[:type].to_s)
336
+
337
+ def same_literal?(left, right)
338
+ return left[:value] == right[:value] if left[:type].to_s == right[:type].to_s
339
+ return false unless mixed_numeric_literals?(left, right)
340
+
341
+ integer, float = if left[:type].to_s == "integer"
342
+ [left[:value], right[:value]]
343
+ else
344
+ [right[:value], left[:value]]
345
+ end
346
+ float.finite? && float.to_r == integer
347
+ end
348
+
349
+ def mixed_numeric_literals?(left, right)
350
+ numeric?(left) && numeric?(right) && left[:type].to_s != right[:type].to_s
351
+ end
352
+
353
+ def message(reason) = REASON_MESSAGES.fetch(reason.to_s, reason.to_s.tr("_", " "))
354
+
355
+ def symbolize(value)
356
+ return value unless value.is_a?(Hash)
357
+
358
+ value.each_with_object({}) do |(key, item), result|
359
+ result[key.to_sym] = item.is_a?(Hash) ? symbolize(item) : item
360
+ end
361
+ end
362
+ end
363
+ end
@@ -31,6 +31,10 @@ module Branchproof
31
31
  @tests = build_tests.freeze
32
32
  end
33
33
 
34
+ def decision_tables
35
+ @decision_tables ||= build_decision_tables.freeze
36
+ end
37
+
34
38
  private
35
39
 
36
40
  def build_conditions
@@ -120,6 +124,31 @@ module Branchproof
120
124
  end
121
125
  end
122
126
 
127
+ def build_decision_tables
128
+ rows = records(@inventory, :decisions).filter_map do |decision|
129
+ next unless boolean_decision?(decision)
130
+
131
+ table = analysis_for(decision)[:decision_table] || {}
132
+ next if table.empty?
133
+
134
+ source = @source_units_by_id[decision[:source_id].to_s] || {}
135
+ {
136
+ decision_id: decision[:id], decision_expression: decision[:expression],
137
+ context: decision[:context], kind: decision_kind(decision),
138
+ relative_path: relative_path(source[:relative_path]), line: decision[:line],
139
+ conditions: Array(decision[:conditions]).map { |condition| condition[:expression].to_s },
140
+ status: table[:status], reason: table[:reason],
141
+ schema_version: table[:schema_version], constraint_analysis_version: table[:constraint_analysis_version],
142
+ rules: Array(table[:rules]),
143
+ generated_rules: table[:generated_rules].to_i, impossible_rules: table[:impossible_rules].to_i,
144
+ required_rules: table[:required_rules].to_i, covered_rules: table[:covered_rules].to_i,
145
+ missing_rules: table[:missing_rules].to_i, percentage: table[:percentage],
146
+ coverage_status: table[:coverage_status], reachability_analyzed: table[:reachability_analyzed]
147
+ }
148
+ end
149
+ rows.sort_by { |row| [row[:relative_path].to_s, row[:line].to_i, row[:decision_id].to_s] }
150
+ end
151
+
123
152
  def build_tests
124
153
  by_id = @tests_by_id.transform_values do |test|
125
154
  { id: test[:id], name: display_name(test), relative_path: test_location(test)[0], line: test_location(test)[1],
@@ -233,7 +262,7 @@ module Branchproof
233
262
 
234
263
  def test_ids(vectors) = vectors.flat_map { |vector| Array(vector[:test_ids]) }.map(&:to_s).uniq.sort
235
264
  def vector_values(vector) = Array(vector[:values])
236
- def records(hash, key) = Array(hash[key] || hash[key.to_s]).map { |item| symbolize(item) }
265
+ def records(hash, key) = Array(hash[key] || hash[key.to_s])
237
266
 
238
267
  def display_name(test)
239
268
  return "#{test[:class_name]}##{test[:method_name]}" if test[:class_name] && test[:method_name]
@@ -1,25 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "prism"
4
+
3
5
  module Branchproof
4
6
  # Discovers control-flow expressions whose truth is not represented by an
5
7
  # ordinary Prism IfNode. The records intentionally contain byte ranges and
6
8
  # scalar metadata only; Prism nodes must not escape the source pass.
7
9
  module DecisionSyntax
8
- OR_WRITE_NODE_NAMES = %w[
9
- CallOrWriteNode ClassVariableOrWriteNode ConstantOrWriteNode
10
- ConstantPathOrWriteNode GlobalVariableOrWriteNode IndexOrWriteNode
11
- InstanceVariableOrWriteNode LocalVariableOrWriteNode
12
- ].freeze
10
+ # Some of these node classes were added in later Prism 1.x releases, so
11
+ # look them up by name and skip any that this Prism version lacks.
12
+ def self.node_classes(*names)
13
+ names.filter_map { |name| Prism.const_get(name) if Prism.const_defined?(name) }.to_set.freeze
14
+ end
15
+ private_class_method :node_classes
13
16
 
14
- AND_WRITE_NODE_NAMES = %w[
15
- CallAndWriteNode ClassVariableAndWriteNode ConstantAndWriteNode
16
- ConstantPathAndWriteNode GlobalVariableAndWriteNode IndexAndWriteNode
17
- InstanceVariableAndWriteNode LocalVariableAndWriteNode
18
- ].freeze
17
+ OR_WRITE_NODE_CLASSES = node_classes(
18
+ :CallOrWriteNode, :ClassVariableOrWriteNode, :ConstantOrWriteNode,
19
+ :ConstantPathOrWriteNode, :GlobalVariableOrWriteNode, :IndexOrWriteNode,
20
+ :InstanceVariableOrWriteNode, :LocalVariableOrWriteNode
21
+ )
19
22
 
20
- def flow_decisions_for(program, bytes, source_id, file_reasons = [], encoding = "UTF-8")
21
- nodes = []
22
- walk(program) { |node| nodes << node if flow_decision_node?(node) }
23
+ AND_WRITE_NODE_CLASSES = node_classes(
24
+ :CallAndWriteNode, :ClassVariableAndWriteNode, :ConstantAndWriteNode,
25
+ :ConstantPathAndWriteNode, :GlobalVariableAndWriteNode, :IndexAndWriteNode,
26
+ :InstanceVariableAndWriteNode, :LocalVariableAndWriteNode
27
+ )
28
+
29
+ # nodes: flow-decision nodes already collected by a caller's own AST walk
30
+ # (Source merges this discovery into one pass). Falls back to its own
31
+ # walk when nothing is passed in, so this method still works standalone.
32
+ def flow_decisions_for(program, bytes, source_id, file_reasons = [], encoding = "UTF-8", nodes: nil)
33
+ nodes ||= collect_flow_decision_nodes(program)
23
34
  nodes.sort_by { |node| [node.location.start_offset, node.location.length] }.map do |node|
24
35
  build_flow_decision(node, bytes, source_id, file_reasons, encoding)
25
36
  end
@@ -27,6 +38,12 @@ module Branchproof
27
38
 
28
39
  private
29
40
 
41
+ def collect_flow_decision_nodes(program)
42
+ nodes = []
43
+ walk(program) { |node| nodes << node if flow_decision_node?(node) }
44
+ nodes
45
+ end
46
+
30
47
  def flow_decision_node?(node)
31
48
  return true if node.is_a?(Prism::CaseNode) && node.predicate
32
49
  return true if node.is_a?(Prism::CaseMatchNode)
@@ -38,8 +55,7 @@ module Branchproof
38
55
  end
39
56
 
40
57
  def assignment_node?(node)
41
- OR_WRITE_NODE_NAMES.include?(node.class.name.split("::").last) ||
42
- AND_WRITE_NODE_NAMES.include?(node.class.name.split("::").last)
58
+ OR_WRITE_NODE_CLASSES.include?(node.class) || AND_WRITE_NODE_CLASSES.include?(node.class)
43
59
  end
44
60
 
45
61
  def build_flow_decision(node, bytes, source_id, file_reasons, encoding)