branchproof 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +36 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE.txt +202 -0
  5. data/NOTICE +3 -0
  6. data/README.md +242 -0
  7. data/doc/Branchproof/Analyzer.md +26 -0
  8. data/doc/Branchproof/CLI.md +15 -0
  9. data/doc/Branchproof/Error.md +6 -0
  10. data/doc/Branchproof/Evidence.md +46 -0
  11. data/doc/Branchproof/Instrumenter.md +18 -0
  12. data/doc/Branchproof/Limits.md +21 -0
  13. data/doc/Branchproof/Loader.md +25 -0
  14. data/doc/Branchproof/Minimizer.md +15 -0
  15. data/doc/Branchproof/MinitestAdapter.md +28 -0
  16. data/doc/Branchproof/Project.md +23 -0
  17. data/doc/Branchproof/RailsSupport/Error.md +6 -0
  18. data/doc/Branchproof/RailsSupport.md +32 -0
  19. data/doc/Branchproof/Records.md +38 -0
  20. data/doc/Branchproof/Report.md +26 -0
  21. data/doc/Branchproof/Runtime.md +37 -0
  22. data/doc/Branchproof/Source.md +23 -0
  23. data/doc/Branchproof/Worker.md +45 -0
  24. data/doc/Branchproof.md +33 -0
  25. data/doc/CHANGELOG.md +36 -0
  26. data/doc/README.md +242 -0
  27. data/exe/mcdc +6 -0
  28. data/lib/branchproof/analyzer.rb +454 -0
  29. data/lib/branchproof/cli.rb +266 -0
  30. data/lib/branchproof/evidence.rb +484 -0
  31. data/lib/branchproof/instrumenter.rb +150 -0
  32. data/lib/branchproof/limits.rb +44 -0
  33. data/lib/branchproof/loader.rb +140 -0
  34. data/lib/branchproof/minimizer.rb +198 -0
  35. data/lib/branchproof/minitest_adapter.rb +245 -0
  36. data/lib/branchproof/project.rb +53 -0
  37. data/lib/branchproof/rails_support.rb +74 -0
  38. data/lib/branchproof/records.rb +76 -0
  39. data/lib/branchproof/report.rb +412 -0
  40. data/lib/branchproof/runtime.rb +171 -0
  41. data/lib/branchproof/source.rb +238 -0
  42. data/lib/branchproof/version.rb +5 -0
  43. data/lib/branchproof/worker.rb +145 -0
  44. data/lib/branchproof.rb +24 -0
  45. data/lib/mcdc.rb +3 -0
  46. data/llms.txt +33 -0
  47. data/sig/branchproof.rbs +116 -0
  48. metadata +132 -0
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Branchproof
4
+ # Boots the selected Rails application after Branchproof's load hook is active.
5
+ module RailsSupport
6
+ class Error < StandardError; end
7
+
8
+ module_function
9
+
10
+ def boot(project:)
11
+ environment = environment_path(project)
12
+ raise Error, "Rails project is missing #{environment}" unless File.file?(environment)
13
+
14
+ boot_environment(environment)
15
+ metadata
16
+ rescue Error
17
+ raise
18
+ rescue LoadError => e
19
+ raise Error, "Rails boot could not load #{e.path || e.message}: #{e.message}"
20
+ rescue StandardError => e
21
+ raise Error, "Rails boot failed: #{e.class}: #{e.message}"
22
+ end
23
+
24
+ def environment_path(project)
25
+ root = File.expand_path(project.fetch(:root).to_s)
26
+ File.join(root, "config", "environment.rb")
27
+ end
28
+
29
+ def boot_environment(environment)
30
+ require environment
31
+ validate_application!
32
+ require "rails/test_help"
33
+ validate_application!
34
+ end
35
+
36
+ def validate_application!
37
+ application = rails_application
38
+ validate_test_environment
39
+
40
+ reloading = reloading_enabled?(application.config)
41
+ raise Error, "Rails reloading is unsupported; set config.enable_reloading = false" if reloading
42
+
43
+ nil
44
+ end
45
+
46
+ def rails_application
47
+ if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
48
+ application = Rails.application
49
+ return application unless application.respond_to?(:initialized?) && !application.initialized?
50
+ end
51
+
52
+ raise Error, "Rails boot did not initialize Rails.application"
53
+ end
54
+
55
+ def validate_test_environment
56
+ return if Rails.respond_to?(:env) && Rails.env.to_s == "test"
57
+
58
+ actual = Rails.respond_to?(:env) ? Rails.env : "unknown"
59
+ raise Error, "Rails boot must use the test environment (got #{actual})"
60
+ end
61
+
62
+ def reloading_enabled?(config)
63
+ return config.enable_reloading if config.respond_to?(:enable_reloading)
64
+ return !config.cache_classes if config.respond_to?(:cache_classes)
65
+
66
+ false
67
+ end
68
+
69
+ def metadata
70
+ { rails_version: Rails::VERSION::STRING,
71
+ serial_policy: { mode: "single_process", workers: 1, parallel_workers: 1 } }
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module Branchproof
7
+ # Builds immutable records and stable identifiers for analysis artifacts.
8
+ module Records
9
+ module_function
10
+
11
+ def build(value)
12
+ deep_freeze(value)
13
+ end
14
+
15
+ def id(value)
16
+ Digest::SHA256.hexdigest(canonical(value))
17
+ end
18
+
19
+ def canonical(value)
20
+ JSON.generate(normalize(value))
21
+ rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError, JSON::GeneratorError => e
22
+ raise ArgumentError, "record contains invalid text encoding: #{e.message}"
23
+ end
24
+
25
+ def diagnostic(code:, message:, severity: "warning", source_id: nil, decision_id: nil,
26
+ execution_id: nil, test_id: nil, details: {})
27
+ build(code: code.to_s, severity: severity.to_s, message: message.to_s,
28
+ source_id: source_id, decision_id: decision_id, execution_id: execution_id,
29
+ test_id: test_id, details: { items: [], **details })
30
+ end
31
+
32
+ def source_id(relative_path:, digest:, encoding: "UTF-8")
33
+ id(relative_path: relative_path, digest: digest, encoding: encoding)
34
+ end
35
+
36
+ def condition_id(decision_id, index)
37
+ id(decision_id: decision_id, index: index)
38
+ end
39
+
40
+ def decision_id(source_id:, context:, byte_start:, byte_length:, tree:)
41
+ id(source_id: source_id, context: context, byte_start: byte_start,
42
+ byte_length: byte_length, tree: tree)
43
+ end
44
+
45
+ def deep_freeze(value)
46
+ case value
47
+ when Hash
48
+ value.transform_values { |item| deep_freeze(item) }.freeze
49
+ when Array
50
+ value.map { |item| deep_freeze(item) }.freeze
51
+ when String
52
+ value.dup.freeze
53
+ else
54
+ value.freeze
55
+ end
56
+ end
57
+
58
+ def normalize(value)
59
+ case value
60
+ when Hash
61
+ normalized = value.keys.map(&:to_s)
62
+ raise ArgumentError, "record contains colliding hash keys" unless normalized.uniq.length == normalized.length
63
+
64
+ value.keys.sort_by(&:to_s).to_h { |key| [key.to_s, normalize(value[key])] }
65
+ when Array then value.map { |item| normalize(item) }
66
+ when Symbol then value.to_s
67
+ when TrueClass, FalseClass, NilClass, Numeric, String then value
68
+ else normalize_unknown(value)
69
+ end
70
+ end
71
+
72
+ def normalize_unknown(value)
73
+ value.to_s
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,412 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Branchproof
6
+ # Renders versioned terminal and JSON analysis reports.
7
+ class Report
8
+ SCHEMA_VERSION = "1.0"
9
+ CRITERION_VERSION = "masking_occurrence_v1"
10
+
11
+ def initialize(inventory:, evidence:, analysis:, minima:, baseline:, diagnostics:, level: 3)
12
+ raise ArgumentError, "level must be 1, 2, or 3" unless [1, 2, 3].include?(level.to_i)
13
+
14
+ @inventory = inventory || {}
15
+ @evidence = evidence || {}
16
+ @analysis = analysis
17
+ @minima = Array(minima)
18
+ @baseline = baseline || {}
19
+ @diagnostics = Array(diagnostics)
20
+ @level = level.to_i
21
+ end
22
+
23
+ def write(io:, format:)
24
+ format = format.to_sym
25
+ raise ArgumentError, "format must be :terminal or :json" unless %i[terminal json].include?(format)
26
+
27
+ io.write(format == :json ? JSON.generate(json_document) : terminal_document)
28
+ nil
29
+ end
30
+
31
+ def exit_code
32
+ return 2 unless usage_valid?
33
+
34
+ status = value(@baseline, :status).to_s.upcase
35
+ return 2 if %w[ERROR INCOMPLETE].include?(status)
36
+ return 1 if status == "FAILED"
37
+ return 2 unless status == "PASSED" && value(@baseline, :finalized) == true
38
+ return 2 unless metrics[:eligible_conditions].positive?
39
+ return 2 unless valid_for_requested_level?
40
+
41
+ 0
42
+ end
43
+
44
+ private
45
+
46
+ def json_document
47
+ normalize(schema_version: SCHEMA_VERSION,
48
+ tool_version: (defined?(Branchproof::VERSION) ? Branchproof::VERSION : "unknown"),
49
+ criterion_version: CRITERION_VERSION, runtime: RUBY_DESCRIPTION,
50
+ run_ids: Array(value(@evidence, :run_ids)),
51
+ source_inventory: @inventory, baseline: @baseline, observations: @evidence,
52
+ analysis: @level == 1 ? nil : @analysis, minima: @minima, metrics: metrics,
53
+ diagnostics: @diagnostics, completeness: completeness)
54
+ end
55
+
56
+ def terminal_document
57
+ @terminal_ids = terminal_ids
58
+ lines = ["Branchproof #{defined?(Branchproof::VERSION) ? Branchproof::VERSION : "unknown"}",
59
+ "Tests: #{baseline_status} (#{baseline_test_counts})",
60
+ terminal_coverage_line,
61
+ "Analysis: #{terminal_analysis_status}",
62
+ "Decisions: #{metrics[:supported]} supported, #{metrics[:unsupported]} excluded, " \
63
+ "#{metrics[:unexecuted]} unexecuted (#{metrics[:discovered]} discovered)",
64
+ "Observations: #{metrics[:completed]} completed, #{metrics[:aborted]} aborted, " \
65
+ "#{metrics[:unattributed]} unattributed",
66
+ "Values: T=true, F=false, -=short-circuited"]
67
+ lines << "Scope: supported decisions and conditions"
68
+ lines << ""
69
+ inventory_decisions.each { |decision| render_decision(lines, decision) }
70
+ render_minima(lines)
71
+ unless @diagnostics.empty?
72
+ lines << "Diagnostics:"
73
+ @diagnostics.each { |diagnostic| lines << " - #{value(diagnostic, :message) || value(diagnostic, :code)}" }
74
+ end
75
+ lines.join("\n") << "\n"
76
+ end
77
+
78
+ def render_decision(lines, decision)
79
+ source = source_for(decision)
80
+ filename = value(source, :relative_path) || value(source, :absolute_path) || value(decision, :source_id)
81
+ decision_label = "Decision #{short_id(value(decision, :id))} #{filename}:#{value(decision, :line)}"
82
+ lines << decision_label
83
+ lines << " Decision: #{value(decision, :expression)}"
84
+ lines << " Status: #{value(decision, :support_status) || "SUPPORTED"}"
85
+ Array(value(decision, :conditions)).each do |condition|
86
+ result = condition_result(decision, condition)
87
+ detail = condition_detail(result)
88
+ status = if @analysis.nil? || @level == 1
89
+ "NOT CALCULATED"
90
+ else
91
+ value(result, :status) || "NOT_PROVEN"
92
+ end
93
+ lines << " Condition #{value(condition, :index)}: #{value(condition, :expression)}"
94
+ lines << " #{status}#{detail}"
95
+ end
96
+ vectors_for(decision).each do |vector|
97
+ values = Array(value(vector, :values)).map do |item|
98
+ if item.nil?
99
+ "-"
100
+ else
101
+ item ? "T" : "F"
102
+ end
103
+ end.join
104
+ owners = Array(value(vector, :test_ids)).map { |test_id| test_label(test_id) }
105
+ owners << "unattributed" if value(vector, :unattributed_count).to_i.positive?
106
+ vector_label = " Vector #{short_id(value(vector, :id))} [#{values}] => " \
107
+ "#{value(vector, :outcome) ? "T" : "F"}"
108
+ lines << "#{vector_label} owners=#{owners.join(", ")}"
109
+ end
110
+ lines << ""
111
+ end
112
+
113
+ def render_minima(lines)
114
+ rows = Array(@minima).map do |minimum|
115
+ objective = value(minimum, :objective)
116
+ scope = Array(value(minimum, :scope_decision_ids)).map { |id| short_id(id) }.join(", ")
117
+ selected = Array(value(minimum, :selected_ids)).map { |id| minimum_member_label(objective, id) }.join(", ")
118
+ label = objective.to_s == "tests" ? "Tests" : "Vectors"
119
+ " #{label} (#{value(minimum, :status)}, decisions: [#{scope}]): #{selected}"
120
+ end.uniq
121
+ unless rows.empty?
122
+ lines << "Supporting sets:"
123
+ lines.concat(rows)
124
+ end
125
+ lines << "Additional tests outside this MC/DC evidence set may improve coverage." unless @minima.empty?
126
+ end
127
+
128
+ def baseline_status
129
+ value(@baseline, :status).to_s.upcase.then { |status| status.empty? ? "INCOMPLETE" : status }
130
+ end
131
+
132
+ def baseline_test_counts
133
+ executed = value(@baseline, :executed_tests)
134
+ failed = value(@baseline, :failed_tests) || value(@baseline, :failures) || 0
135
+ skipped = value(@baseline, :skipped_tests) || value(@baseline, :skips) || 0
136
+ "#{executed || 0} tests, #{failed} failed, #{skipped} skipped"
137
+ end
138
+
139
+ def terminal_coverage_label
140
+ return "not calculated" if @analysis.nil? || @level == 1
141
+
142
+ coverage_label
143
+ end
144
+
145
+ def terminal_coverage_line
146
+ return "MC/DC: not calculated" if @analysis.nil? || @level == 1
147
+
148
+ "MC/DC: #{terminal_coverage_label} (#{metrics[:proven]}/#{metrics[:eligible_conditions]} conditions proven)"
149
+ end
150
+
151
+ def terminal_analysis_status
152
+ return "NOT CALCULATED" if @analysis.nil? || @level == 1
153
+
154
+ analysis_status
155
+ end
156
+
157
+ def terminal_ids
158
+ ids = []
159
+ ids.concat(inventory_decisions.flat_map { |decision| [value(decision, :id), value(decision, :source_id)] })
160
+ ids.concat(vectors.map { |vector| value(vector, :id) })
161
+ ids.concat(vectors.flat_map { |vector| Array(value(vector, :test_ids)) })
162
+ ids.concat(Array(value(@evidence, :tests)).map { |test| value(test, :id) })
163
+ ids.concat(Array(value(@baseline, :tests)).map { |test| value(test, :id) })
164
+ ids.concat(Array(@minima).flat_map do |minimum|
165
+ Array(value(minimum, :scope_decision_ids)) + Array(value(minimum, :selected_ids))
166
+ end)
167
+ ids.concat(Array(value(@analysis, :decisions)).flat_map do |decision|
168
+ Array(value(decision, :condition_results)).flat_map do |result|
169
+ [value(result, :condition_id), *Array(value(result, :canonical_pair))]
170
+ end
171
+ end)
172
+ ids = ids.compact.map(&:to_s).reject(&:empty?).uniq
173
+ by_prefix = ids.group_by { |id| id[0, 8] }
174
+ ids.to_h do |id|
175
+ prefix_length = 8
176
+ group = by_prefix.fetch(id[0, 8])
177
+ while group.length > 1 && group.map { |item| item[0, prefix_length] }.uniq.length < group.length
178
+ prefix_length += 1
179
+ end
180
+ [id, id.length <= prefix_length ? id : id[0, prefix_length]]
181
+ end
182
+ end
183
+
184
+ def short_id(id)
185
+ text = id.to_s
186
+ return text if text.empty? || !@terminal_ids
187
+
188
+ @terminal_ids.fetch(text, text)
189
+ end
190
+
191
+ def test_label(test_id)
192
+ id = test_id.to_s
193
+ test = test_records_by_id[id]
194
+ return short_id(id) unless test
195
+
196
+ class_name = value(test, :class_name)
197
+ method_name = value(test, :method_name)
198
+ name = if class_name && method_name
199
+ "#{class_name}##{method_name}"
200
+ else
201
+ value(test, :name).to_s
202
+ end
203
+ return short_id(id) if name.empty? || name == id
204
+
205
+ duplicates = test_records_by_name[name]
206
+ return name if duplicates.length == 1
207
+
208
+ location, line = test_location(test)
209
+ suffix = [location, line].compact.join(":")
210
+ same_location = duplicates.count do |item|
211
+ item_location, item_line = test_location(item)
212
+ [item_location, item_line].compact.join(":") == suffix
213
+ end
214
+ return "#{name} (#{suffix})" if !suffix.empty? && same_location == 1
215
+
216
+ "#{name} (#{suffix.empty? ? short_id(id) : "#{suffix}, #{short_id(id)}"})"
217
+ end
218
+
219
+ def test_location(test)
220
+ location = value(test, :source)
221
+ source_line = nil
222
+ if location.respond_to?(:key?)
223
+ source_line = value(location, :line)
224
+ location = value(location, :path) || value(location, :relative_path)
225
+ end
226
+ location ||= value(test, :source_path)
227
+ line = value(test, :line) || value(test, :source_line) || source_line
228
+ [location, line]
229
+ end
230
+
231
+ def minimum_member_label(objective, id)
232
+ objective.to_s == "tests" ? test_label(id) : short_id(id)
233
+ end
234
+
235
+ def test_display_name(test)
236
+ class_name = value(test, :class_name)
237
+ method_name = value(test, :method_name)
238
+ class_name && method_name ? "#{class_name}##{method_name}" : value(test, :name).to_s
239
+ end
240
+
241
+ def test_records
242
+ @test_records ||= begin
243
+ records = {}
244
+ Array(value(@baseline, :tests)).each { |test| records[value(test, :id).to_s] = test }
245
+ Array(value(@evidence, :tests)).each do |test|
246
+ id = value(test, :id).to_s
247
+ records[id] = records.fetch(id, {}).merge(test)
248
+ end
249
+ records.values
250
+ end
251
+ end
252
+
253
+ def test_records_by_id
254
+ @test_records_by_id ||= test_records.to_h { |test| [value(test, :id).to_s, test] }
255
+ end
256
+
257
+ def test_records_by_name
258
+ @test_records_by_name ||= test_records.group_by { |test| test_display_name(test) }
259
+ end
260
+
261
+ def condition_result(decision, condition)
262
+ Array(value(analysis_for(decision), :condition_results)).find do |item|
263
+ value(item, :condition_id).to_s == value(condition, :id).to_s
264
+ end
265
+ end
266
+
267
+ def condition_detail(result)
268
+ return "" unless result && @level == 3
269
+
270
+ pair = value(result, :canonical_pair)
271
+ return " (witness #{Array(pair).map { |id| short_id(id) }.join(" + ")})" if pair
272
+
273
+ constraint = value(result, :constraint_result)
274
+ if constraint
275
+ constraints = Array(value(constraint, :constraints)).map { |item| item.is_a?(Hash) ? item.inspect : item.to_s }
276
+ detail = constraints.empty? ? value(constraint, :status) : constraints.join(", ")
277
+ statement = value(constraint, :feasibility_statement)
278
+ detail = [detail, statement].compact.reject(&:empty?).join("; ")
279
+ return " (missing counterpart: #{detail})" unless detail.empty?
280
+ end
281
+
282
+ ""
283
+ end
284
+
285
+ def metrics
286
+ decisions = inventory_decisions
287
+ unsupported, supported = decisions.partition { |decision| unsupported?(decision) }
288
+ eligible = supported.sum { |decision| Array(value(decision, :conditions)).length }
289
+ observed = vectors.map { |vector| value(vector, :decision_id).to_s }.uniq
290
+ proven = @analysis ? value(@analysis, :proven_count).to_i : 0
291
+ { discovered: decisions.length, supported: supported.length, unsupported: unsupported.length,
292
+ unsupported_conditions: unsupported.sum do |decision|
293
+ discovered_conditions(decision)
294
+ end, eligible_conditions: eligible,
295
+ opaque: decisions.sum { |decision| Array(value(decision, :opaque_ranges)).length },
296
+ unexecuted: supported.count { |decision| !observed.include?(value(decision, :id).to_s) },
297
+ completed: vectors.sum do |vector|
298
+ value(vector, :count).to_i
299
+ end, aborted: numeric_hash_value(@evidence, :abort_counts),
300
+ unattributed: vectors.sum { |vector| value(vector, :unattributed_count).to_i }, limited: incomplete? ? 1 : 0,
301
+ proven: proven, percentage: percentage(eligible, proven) }
302
+ end
303
+
304
+ def completeness
305
+ evidence = value(@evidence, :completeness) || {}
306
+ analysis = value(@analysis, :completeness) || {}
307
+ { observation: completeness_value?(evidence, analysis, :observation),
308
+ attribution: completeness_value?(evidence, analysis, :attribution),
309
+ analysis: @analysis ? value(analysis, :analysis) == true : value(evidence, :analysis) == true }
310
+ end
311
+
312
+ def completeness_value?(evidence, analysis, key)
313
+ if (evidence.key?(key) && evidence[key] == false) || (evidence.key?(key.to_s) && evidence[key.to_s] == false)
314
+ return false
315
+ end
316
+ if (analysis.key?(key) && analysis[key] == false) || (analysis.key?(key.to_s) && analysis[key.to_s] == false)
317
+ return false
318
+ end
319
+
320
+ true
321
+ end
322
+
323
+ def valid_for_requested_level?
324
+ return completeness[:observation] && completeness[:attribution] if @level == 1
325
+
326
+ completeness.values.all? { |item| item == true }
327
+ end
328
+
329
+ def coverage_label
330
+ percentage = metrics[:percentage]
331
+ return "N/A" if percentage.nil?
332
+
333
+ incomplete? ? "#{percentage}% (lower-bound; incomplete evidence)" : "#{percentage}%"
334
+ end
335
+
336
+ def analysis_status
337
+ return "NOT_REQUESTED" if @analysis.nil? || @level == 1
338
+
339
+ valid_for_requested_level? ? "COMPLETE" : "PARTIAL"
340
+ end
341
+
342
+ def incomplete?
343
+ !completeness[:observation] || !completeness[:attribution] || (@level > 1 && !completeness[:analysis])
344
+ end
345
+
346
+ def vectors_for(decision)
347
+ vectors.select do |vector|
348
+ value(vector, :decision_id).to_s == value(decision, :id).to_s
349
+ end
350
+ end
351
+
352
+ def analysis_for(decision)
353
+ Array(value(@analysis, :decisions)).find do |item|
354
+ value(item, :decision_id).to_s == value(decision, :id).to_s
355
+ end
356
+ end
357
+
358
+ def source_for(decision)
359
+ Array(value(@inventory, :source_units)).find do |source|
360
+ value(source, :source_id).to_s == value(decision, :source_id).to_s
361
+ end || decision
362
+ end
363
+
364
+ def inventory_decisions = Array(value(@inventory, :decisions))
365
+ def vectors = Array(value(@evidence, :vectors))
366
+ def unsupported?(decision) = value(decision, :support_status).to_s.upcase == "UNSUPPORTED"
367
+
368
+ def discovered_conditions(decision)
369
+ value(decision,
370
+ :discovered_condition_count) || Array(value(decision,
371
+ :conditions)).length
372
+ end
373
+
374
+ def percentage(denominator, numerator) = denominator.zero? ? nil : (numerator.to_f * 100 / denominator).round(2)
375
+
376
+ def numeric_hash_value(hash, key)
377
+ item = value(hash, key)
378
+ item.is_a?(Hash) ? item.values.sum(&:to_i) : item.to_i
379
+ end
380
+
381
+ def usage_valid?
382
+ @diagnostics.none? do |diagnostic|
383
+ %w[usage invalid_option].include?(value(diagnostic, :code).to_s)
384
+ end
385
+ end
386
+
387
+ def value(hash, key)
388
+ return nil unless hash.respond_to?(:key?)
389
+ return hash[key] if hash.key?(key)
390
+ return hash[key.to_s] if hash.key?(key.to_s)
391
+
392
+ nil
393
+ end
394
+
395
+ def normalize(object)
396
+ case object
397
+ when Hash
398
+ object.each_with_object({}) do |(key, item), result|
399
+ result[key.to_s] = normalize(item) unless key.to_s == "original_bytes"
400
+ end
401
+ when Array then object.map { |item| normalize(item) }
402
+ when Symbol then object.to_s
403
+ when Numeric, String, TrueClass, FalseClass, NilClass then object
404
+ else normalize_unknown(object)
405
+ end
406
+ end
407
+
408
+ def normalize_unknown(object)
409
+ object.to_s
410
+ end
411
+ end
412
+ end