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
@@ -5,11 +5,19 @@
5
5
  # rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
6
6
 
7
7
  require "json"
8
+ # rubocop:disable-next Lint/RedundantRequireStatement -- supports standalone core entry
9
+ require "set"
10
+ require_relative "decision_table"
8
11
 
9
12
  module Branchproof
10
13
  # Reads and validates a persisted JSON report without loading the project.
11
14
  class SavedReport
12
- SUPPORTED_SCHEMAS = %w[1.0 1.1 1.2].freeze
15
+ SUPPORTED_SCHEMAS = %w[1.0 1.1 1.2 1.3].freeze
16
+ STRICT_FLOW_SCHEMAS = %w[1.2 1.3].freeze
17
+ DECISION_TABLE_STATUSES = %w[calculated not_calculated].freeze
18
+ RULE_CONDITION_VALUES = DecisionTable::CONDITION_VALUES
19
+ RULE_COVERAGE_STATUSES = DecisionTable::COVERAGE_STATUSES
20
+ RULE_REACHABILITY_STATUSES = DecisionTable::REACHABILITY_STATUSES
13
21
  DECISION_KINDS = %w[boolean implicit multiway pattern exception].freeze
14
22
  NONBOOLEAN_KINDS = (DECISION_KINDS - ["boolean"]).freeze
15
23
  CRITERION_VERSION = "masking_occurrence_v1"
@@ -107,7 +115,7 @@ module Branchproof
107
115
  end
108
116
  fail_with("duplicate condition id") unless @condition_ids.uniq.length == @condition_ids.length
109
117
  @conditions_by_decision = decisions.to_h do |decision|
110
- [decision["id"], Array(decision["conditions"]).map { |condition| condition["id"] }]
118
+ [decision["id"], Array(decision["conditions"]).to_set { |condition| condition["id"] }]
111
119
  end
112
120
  sources.each do |source|
113
121
  validate_string_field(source, "relative_path", nullable: true)
@@ -142,11 +150,12 @@ module Branchproof
142
150
  fail_with("tests must be an array") unless tests.is_a?(Array)
143
151
  fail_with("vectors must be an array") unless vectors.is_a?(Array)
144
152
  @test_ids = unique_ids(tests, "id", "test")
153
+ @test_id_set = @test_ids.to_set
145
154
  tests.each { |test| validate_test(test) }
146
155
  vector_ids = unique_ids(vectors, "id", "vector")
147
156
  vectors.each do |vector|
148
157
  fail_with("invalid vector") unless hash_with_string_keys?(vector)
149
- fail_with("unknown vector decision") unless @decision_ids.include?(vector["decision_id"])
158
+ fail_with("unknown vector decision") unless @decisions_by_id.key?(vector["decision_id"])
150
159
  decision = @decisions_by_id.fetch(vector["decision_id"])
151
160
  values = vector["values"]
152
161
  valid_values = values.is_a?(Array) && values.all? do |value|
@@ -162,7 +171,7 @@ module Branchproof
162
171
  fail_with("vector outcome must be boolean") unless [true, false].include?(vector["outcome"])
163
172
  validate_flow_vector(vector, decision) if strict_flow_decision?(decision)
164
173
  fail_with("unknown vector test") unless strings?(vector["test_ids"]) && vector["test_ids"].all? do |id|
165
- @test_ids.include?(id)
174
+ @test_id_set.include?(id)
166
175
  end
167
176
  validate_phases(vector)
168
177
  validate_integer_field(vector, "count")
@@ -171,6 +180,7 @@ module Branchproof
171
180
  @vector_ids = vector_ids
172
181
  @vector_decision_by_id = vectors.to_h { |vector| [vector["id"], vector["decision_id"]] }
173
182
  @vectors_by_id = vectors.to_h { |vector| [vector["id"], vector] }
183
+ @vectors_by_decision = vectors.group_by { |vector| vector["decision_id"] }
174
184
  aborts = observations["abort_counts"]
175
185
  return if aborts.nil? || aborts.is_a?(Integer) || (aborts.is_a?(Hash) && aborts.values.all?(Integer))
176
186
 
@@ -199,7 +209,7 @@ module Branchproof
199
209
  decisions.each do |decision|
200
210
  fail_with("invalid analysis decision") unless hash_with_string_keys?(decision)
201
211
  id = decision["decision_id"]
202
- fail_with("unknown analysis decision") unless @decision_ids.include?(id) && !seen.key?(id)
212
+ fail_with("unknown analysis decision") unless @decisions_by_id.key?(id) && !seen.key?(id)
203
213
  seen[id] = true
204
214
  results = decision["condition_results"]
205
215
  fail_with("condition results must be an array") unless results.is_a?(Array)
@@ -207,6 +217,9 @@ module Branchproof
207
217
  if nonboolean_kind?(decision_kind(inventory_decision))
208
218
  fail_with("nonboolean condition results must be empty") unless results.empty?
209
219
  validate_nonboolean_analysis(decision, inventory_decision)
220
+ elsif decision.key?("decision_table")
221
+ fail_with("decision tables are unsupported in legacy report schemas") unless @schema_version == "1.3"
222
+ validate_decision_table(decision["decision_table"], inventory_decision)
210
223
  end
211
224
  seen_conditions = {}
212
225
  results.each do |result|
@@ -221,13 +234,183 @@ module Branchproof
221
234
 
222
235
  pair = result["canonical_pair"]
223
236
  valid_pair = pair.is_a?(Array) && pair.length == 2 && pair.uniq.length == 2 && pair.all? do |id|
224
- @vector_ids.include?(id) && @vector_decision_by_id[id] == decision["decision_id"]
237
+ @vectors_by_id.key?(id) && @vector_decision_by_id[id] == decision["decision_id"]
225
238
  end
226
239
  fail_with("canonical pair must reference vectors") unless valid_pair
227
240
  end
228
241
  end
229
242
  end
230
243
 
244
+ # The persisted decision table must describe the same decision it was
245
+ # derived from, so a saved report can be reported on and compared offline.
246
+ def validate_decision_table(table, decision)
247
+ fail_with("decision_table must be an object") unless hash_with_string_keys?(table)
248
+ fail_with("decision_table status is invalid") unless DECISION_TABLE_STATUSES.include?(table["status"])
249
+ fail_with("decision_table decision id mismatch") unless table["decision_id"] == decision["id"]
250
+ %w[schema_version constraint_analysis_version].each do |field|
251
+ fail_with("decision_table #{field} must be an integer") unless table[field].is_a?(Integer)
252
+ end
253
+ unless table["schema_version"] == DecisionTable::SCHEMA_VERSION
254
+ fail_with("unsupported decision_table schema version")
255
+ end
256
+ solver_version = table["constraint_analysis_version"]
257
+ supported_solver = [1, DecisionTable::CONSTRAINT_ANALYSIS_VERSION].include?(solver_version) &&
258
+ solver_version <= DecisionTable::CONSTRAINT_ANALYSIS_VERSION
259
+ fail_with("unsupported decision_table constraint analysis version") unless supported_solver
260
+ validate_string_field(table, "reason", nullable: true)
261
+ unless DecisionTable::COVERAGE_SUMMARY_STATUSES.include?(table["coverage_status"])
262
+ fail_with("invalid decision_table coverage status")
263
+ end
264
+ %w[generated_rules impossible_rules required_rules covered_rules missing_rules].each do |field|
265
+ unless table[field].is_a?(Integer) && table[field] >= 0
266
+ fail_with("decision_table #{field} must be a nonnegative integer")
267
+ end
268
+ end
269
+ rules = table["rules"]
270
+ fail_with("decision_table rules must be an array") unless rules.is_a?(Array)
271
+ unique_ids(rules, "id", "decision table rule")
272
+ indexes = rules.map { |rule| rule["index"] }
273
+ fail_with("decision table rule indexes must be consecutive") unless indexes.sort == (0...rules.length).to_a
274
+ condition_count = decision["conditions"].length
275
+ rules.each { |rule| validate_decision_table_rule(rule, decision, condition_count) }
276
+ validate_decision_table_counts(table, rules)
277
+ validate_decision_table_summary(table, rules)
278
+ end
279
+
280
+ def validate_decision_table_rule(rule, decision, condition_count)
281
+ fail_with("invalid decision table rule") unless hash_with_string_keys?(rule)
282
+ validate_string_field(rule, "label")
283
+ validate_integer_field(rule, "index")
284
+ unless rule["index"].is_a?(Integer) && rule["index"] >= 0
285
+ fail_with("decision table rule index must be nonnegative")
286
+ end
287
+ fail_with("decision table rule label does not match its index") unless rule["label"] == "R#{rule["index"] + 1}"
288
+ conditions = rule["conditions"]
289
+ valid = conditions.is_a?(Array) && conditions.length == condition_count &&
290
+ conditions.all? { |value| RULE_CONDITION_VALUES.include?(value) }
291
+ fail_with("decision table rule conditions do not match the decision") unless valid
292
+ fail_with("decision table rule outcome must be boolean") unless [true, false].include?(rule["outcome"])
293
+ expected_id = DecisionTable.rule_id(decision["id"], conditions, rule["outcome"])
294
+ unless rule["id"] == expected_id
295
+ fail_with("decision table rule id does not match its decision, conditions, and outcome")
296
+ end
297
+ fail_with("invalid decision table rule coverage") unless RULE_COVERAGE_STATUSES.include?(rule["coverage"])
298
+ unless RULE_REACHABILITY_STATUSES.include?(rule["reachability"])
299
+ fail_with("invalid decision table rule reachability")
300
+ end
301
+ validate_string_field(rule, "reachability_reason", nullable: true)
302
+ if rule["reachability"] == "statically_impossible" && !rule["reachability_reason"].is_a?(String)
303
+ fail_with("statically impossible rule requires a reason code")
304
+ end
305
+ if %w[observed unknown].include?(rule["reachability"]) && !rule["reachability_reason"].nil?
306
+ fail_with("observed or unknown rule must not have a reachability reason")
307
+ end
308
+ fail_with("decision table rule tests must be strings") unless strings?(rule["tests"])
309
+ fail_with("unknown decision table rule test") unless rule["tests"].all? { |id| @test_id_set.include?(id) }
310
+ fail_with("decision table rule vector_ids must be strings") unless strings?(rule["vector_ids"])
311
+ unless rule["vector_ids"].uniq.length == rule["vector_ids"].length
312
+ fail_with("duplicate decision table rule vector")
313
+ end
314
+ fail_with("unknown decision table rule vector") unless rule["vector_ids"].all? do |id|
315
+ @vector_decision_by_id[id] == decision["id"]
316
+ end
317
+ validate_decision_table_evidence(rule, decision)
318
+ validate_integer_field(rule, "unattributed_count")
319
+ unless rule["unattributed_count"].is_a?(Integer) && rule["unattributed_count"] >= 0
320
+ fail_with("decision table rule unattributed count must be nonnegative")
321
+ end
322
+ return unless rule["coverage"] == "covered" && rule["vector_ids"].empty?
323
+
324
+ fail_with("covered decision table rule requires evidence")
325
+ end
326
+
327
+ def validate_decision_table_counts(table, rules)
328
+ if table["status"] == "not_calculated"
329
+ fail_with("not_calculated decision table must not contain rules") unless rules.empty?
330
+ %w[generated_rules impossible_rules required_rules covered_rules missing_rules].each do |field|
331
+ fail_with("not_calculated decision table #{field} must be zero") unless table[field].zero?
332
+ end
333
+ return
334
+ end
335
+
336
+ fail_with("decision_table generated_rules mismatch") unless table["generated_rules"] == rules.length
337
+ impossible = rules.count { |rule| rule["coverage"] == "excluded" }
338
+ covered = rules.count { |rule| rule["coverage"] == "covered" }
339
+ fail_with("decision_table impossible_rules mismatch") unless table["impossible_rules"] == impossible
340
+ fail_with("decision_table required_rules mismatch") unless table["required_rules"] == rules.length - impossible
341
+ fail_with("decision_table covered_rules mismatch") unless table["covered_rules"] == covered
342
+ return if table["missing_rules"] == table["required_rules"] - table["covered_rules"]
343
+
344
+ fail_with("decision_table missing_rules mismatch")
345
+ end
346
+
347
+ def validate_decision_table_summary(table, rules)
348
+ if table["status"] == "not_calculated"
349
+ allowed_reason = DecisionTable::NOT_CALCULATED_REASONS.include?(table["reason"])
350
+ fail_with("not_calculated decision table requires a supported reason") unless allowed_reason
351
+ expected_status = table["reason"] == "unsupported_decision" ? "unsupported" : "not_calculated"
352
+ unless table["coverage_status"] == expected_status
353
+ fail_with("not_calculated decision table coverage status mismatch")
354
+ end
355
+ fail_with("not_calculated decision table percentage must be null") unless table["percentage"].nil?
356
+ unless table["reachability_analyzed"] == false
357
+ fail_with("not_calculated decision table reachability must be false")
358
+ end
359
+ return
360
+ end
361
+
362
+ fail_with("calculated decision table reason must be null") unless table["reason"].nil?
363
+ reachability_analyzed = table["reachability_analyzed"]
364
+ unless [true, false].include?(reachability_analyzed)
365
+ fail_with("decision_table reachability_analyzed must be boolean")
366
+ end
367
+ required = table["required_rules"]
368
+ covered = table["covered_rules"]
369
+ generated = table["generated_rules"]
370
+ expected_status = DecisionTable.coverage_status(covered, required, generated)
371
+ fail_with("decision_table coverage status mismatch") unless table["coverage_status"] == expected_status
372
+ expected_percentage = DecisionTable.percentage(covered, required)
373
+ fail_with("decision_table percentage mismatch") unless table["percentage"] == expected_percentage
374
+ return unless table["reachability_analyzed"] == false
375
+
376
+ fail_with("un-analyzed reachability cannot exclude rules") if rules.any? do |rule|
377
+ rule["coverage"] == "excluded"
378
+ end
379
+ fail_with("un-analyzed reachability contains impossible rule") if rules.any? do |rule|
380
+ rule["reachability"] == "statically_impossible"
381
+ end
382
+ end
383
+
384
+ def validate_decision_table_evidence(rule, _decision)
385
+ rule["vector_ids"].each do |vector_id|
386
+ vector = @vectors_by_id.fetch(vector_id)
387
+ matches = vector["outcome"] == rule["outcome"] && rule["conditions"].each_with_index.all? do |required, index|
388
+ required == DecisionTable::DONT_CARE || vector["values"][index] == (required == DecisionTable::TRUE_VALUE)
389
+ end
390
+ fail_with("decision table rule evidence does not match its conditions and outcome") unless matches
391
+ end
392
+ expected_tests = rule["vector_ids"].flat_map { |id| @vectors_by_id.fetch(id)["test_ids"] }.uniq.sort
393
+ fail_with("decision table rule tests do not match its evidence") unless rule["tests"].sort == expected_tests
394
+ expected_unattributed = rule["vector_ids"].sum { |id| @vectors_by_id.fetch(id).fetch("unattributed_count", 0) }
395
+ unless rule["unattributed_count"] == expected_unattributed
396
+ fail_with("decision table rule unattributed count does not match its evidence")
397
+ end
398
+ if rule["coverage"] == "covered" && rule["reachability"] != "observed"
399
+ fail_with("covered decision table rule requires observed reachability")
400
+ end
401
+ if rule["coverage"] == "excluded" && rule["reachability"] != "statically_impossible"
402
+ fail_with("excluded decision table rule requires static impossibility")
403
+ end
404
+ if rule["coverage"] == "missing" && rule["reachability"] != "unknown"
405
+ fail_with("missing decision table rule requires unknown reachability")
406
+ end
407
+ return unless %w[missing excluded].include?(rule["coverage"])
408
+
409
+ fail_with("noncovered decision table rule must not contain evidence") unless rule["vector_ids"].empty? &&
410
+ rule["tests"].empty? &&
411
+ rule["unattributed_count"].zero?
412
+ end
413
+
231
414
  def decision_kind(decision)
232
415
  kind = decision && decision["kind"]
233
416
  kind.nil? || kind.empty? ? "boolean" : kind
@@ -359,12 +542,16 @@ module Branchproof
359
542
  unless evidence["vector_ids"].uniq.length == evidence["vector_ids"].length
360
543
  fail_with("duplicate alternative evidence vector")
361
544
  end
362
- fail_with("unknown alternative evidence vector") unless (evidence["vector_ids"] - @vector_ids).empty?
545
+ fail_with("unknown alternative evidence vector") unless evidence["vector_ids"].all? do |id|
546
+ @vectors_by_id.key?(id)
547
+ end
363
548
  fail_with("alternative evidence test_ids must be strings") unless strings?(evidence["test_ids"])
364
549
  unless evidence["test_ids"].uniq.length == evidence["test_ids"].length
365
550
  fail_with("duplicate alternative evidence test")
366
551
  end
367
- fail_with("unknown alternative evidence test") unless (evidence["test_ids"] - @test_ids).empty?
552
+ fail_with("unknown alternative evidence test") unless evidence["test_ids"].all? do |id|
553
+ @test_id_set.include?(id)
554
+ end
368
555
  validate_integer_field(evidence, "unattributed_count")
369
556
  return unless decision
370
557
 
@@ -381,9 +568,8 @@ module Branchproof
381
568
  end
382
569
  end
383
570
  fail_with("alternative evidence state mismatch") unless vectors.all?(&expected_value)
384
- expected_vectors = @vectors_by_id.values.select do |vector|
385
- vector["decision_id"] == decision["id"] && expected_value.call(vector)
386
- end
571
+ decision_vectors = @vectors_by_decision.fetch(decision["id"], [])
572
+ expected_vectors = decision_vectors.select(&expected_value)
387
573
  expected_vector_ids = expected_vectors.map { |vector| vector["id"] }.sort
388
574
  fail_with("alternative evidence is incomplete") unless evidence["vector_ids"].sort == expected_vector_ids
389
575
  expected_tests = vectors.flat_map { |vector| vector["test_ids"] }.uniq.sort
@@ -412,7 +598,7 @@ module Branchproof
412
598
  end
413
599
 
414
600
  def strict_flow_decision?(decision)
415
- @schema_version == "1.2" &&
601
+ STRICT_FLOW_SCHEMAS.include?(@schema_version) &&
416
602
  nonboolean_kind?(decision_kind(decision)) &&
417
603
  decision["support_status"].to_s.upcase != "UNSUPPORTED"
418
604
  end
@@ -446,7 +632,7 @@ module Branchproof
446
632
 
447
633
  valid = hash_with_string_keys?(phases) && phases.all? do |test_id, values|
448
634
  valid_phases = %w[setup body teardown suite unattributed]
449
- @test_ids.include?(test_id) && strings?(values) && values.all? { |phase| valid_phases.include?(phase) }
635
+ @test_id_set.include?(test_id) && strings?(values) && values.all? { |phase| valid_phases.include?(phase) }
450
636
  end
451
637
  fail_with("invalid phases_by_test") unless valid
452
638
  end
@@ -556,7 +742,7 @@ module Branchproof
556
742
  return unless locations
557
743
 
558
744
  valid = hash_with_string_keys?(locations) && locations.all? do |test_id, location|
559
- @test_ids.include?(test_id) && hash_with_string_keys?(location) &&
745
+ @test_id_set.include?(test_id) && hash_with_string_keys?(location) &&
560
746
  (!location.key?("relative_path") || location["relative_path"].nil? ||
561
747
  location["relative_path"].is_a?(String)) &&
562
748
  (!location.key?("line") || location["line"].nil? || location["line"].is_a?(Integer))
@@ -4,6 +4,7 @@ require "digest"
4
4
  require "pathname"
5
5
  require "prism"
6
6
  require_relative "decision_syntax"
7
+ require_relative "constraints"
7
8
 
8
9
  module Branchproof
9
10
  # Inventories supported condition and decision occurrences from Ruby files.
@@ -58,11 +59,13 @@ module Branchproof
58
59
  end
59
60
 
60
61
  def read_unit(path)
62
+ absolute_path = File.expand_path(path)
63
+ relative_path = relative(path)
61
64
  bytes = File.binread(path)
62
65
  parsed = Prism.parse(bytes)
63
66
  encoding = source_encoding(bytes, parsed)
64
- source_id = Records.source_id(relative_path: relative(path), digest: Digest::SHA256.hexdigest(bytes),
65
- encoding: encoding)
67
+ digest = Digest::SHA256.hexdigest(bytes)
68
+ source_id = Records.source_id(relative_path: relative_path, digest: digest, encoding: encoding)
66
69
  diagnostics = parsed.errors.map do |error|
67
70
  Records.diagnostic(code: "parse_error", severity: "error", message: error.message, source_id: source_id,
68
71
  details: { byte_start: error.location.start_offset, byte_length: error.location.length })
@@ -78,11 +81,11 @@ module Branchproof
78
81
  file_reasons << "unsupported_data_section" if parsed.respond_to?(:data_loc) && parsed.data_loc
79
82
  file_reasons << "parse_error" unless parsed.errors.empty?
80
83
  decisions = parsed.value ? decisions_for(parsed.value, bytes, source_id, file_reasons, encoding) : []
81
- Records.build(source_id: source_id, relative_path: relative(path), absolute_path: File.expand_path(path),
82
- real_path: File.realpath(path), digest: Digest::SHA256.hexdigest(bytes), encoding: encoding,
84
+ Records.build(source_id: source_id, relative_path: relative_path, absolute_path: absolute_path,
85
+ real_path: File.realpath(path), digest: digest, encoding: encoding,
83
86
  original_bytes: bytes, decisions: decisions, diagnostics: diagnostics)
84
87
  rescue SystemCallError => e
85
- Records.build(source_id: nil, relative_path: relative(path), absolute_path: File.expand_path(path),
88
+ Records.build(source_id: nil, relative_path: relative_path, absolute_path: absolute_path,
86
89
  real_path: nil, digest: nil, encoding: nil, original_bytes: nil, decisions: [],
87
90
  diagnostics: [Records.diagnostic(code: "source_unreadable", severity: "error", message: e.message)])
88
91
  end
@@ -100,25 +103,53 @@ module Branchproof
100
103
  Encoding::UTF_8.name
101
104
  end
102
105
 
106
+ # Walks the whole program AST exactly once, sorting every node into the
107
+ # buckets the two classification phases below need. Phase one (decision
108
+ # and case-when nodes) must run to completion before phase two (bare
109
+ # boolean/pattern nodes) because phase two skips nodes phase one already
110
+ # inventoried via mark_semantic_boolean_nodes.
111
+ def collect_ast_nodes(program)
112
+ defined_ranges = []
113
+ guard_patterns = {}.compare_by_identity
114
+ phase_one_nodes = []
115
+ phase_two_nodes = []
116
+ flow_nodes = []
117
+
118
+ walk(program) do |node|
119
+ if node.is_a?(Prism::DefinedNode)
120
+ defined_ranges << node.location
121
+ elsif node.is_a?(Prism::InNode)
122
+ pattern = node.pattern
123
+ guard_patterns[pattern] = true if pattern.is_a?(Prism::IfNode) || pattern.is_a?(Prism::UnlessNode)
124
+ end
125
+ phase_one_nodes << node if decision_node?(node) || subjectless_case?(node)
126
+ phase_two_nodes << node if boolean_node?(node) || node.is_a?(Prism::MatchPredicateNode)
127
+ flow_nodes << node if flow_decision_node?(node)
128
+ end
129
+
130
+ { defined_ranges: defined_ranges, guard_patterns: guard_patterns, phase_one_nodes: phase_one_nodes,
131
+ phase_two_nodes: phase_two_nodes, flow_nodes: flow_nodes }
132
+ end
133
+
103
134
  def decisions_for(program, bytes, source_id, file_reasons = [], encoding = "UTF-8")
104
135
  specs = []
105
136
  inventoried_boolean_nodes = {}.compare_by_identity
106
- defined_ranges = []
107
- pattern_guard_nodes = pattern_guard_nodes_for(program)
137
+ collected = collect_ast_nodes(program)
138
+ defined_ranges = collected[:defined_ranges]
139
+ guard_patterns = collected[:guard_patterns]
108
140
 
109
- walk(program) do |node|
110
- defined_ranges << node.location if node.is_a?(Prism::DefinedNode)
141
+ collected[:phase_one_nodes].each do |node|
111
142
  if decision_node?(node)
112
143
  predicate = node.predicate
113
144
  next unless predicate
114
145
 
115
146
  predicate = unwrap_predicate(predicate)
116
- context = pattern_guard_nodes[node] ? "pattern_guard" : decision_context(node, bytes)
147
+ context = guard_patterns[node] ? "pattern_guard" : decision_context(node, bytes)
117
148
  specs << { node: node, predicate: predicate, context: context }
118
149
  mark_semantic_boolean_nodes(predicate, inventoried_boolean_nodes)
119
- elsif subjectless_case?(node)
120
- when_nodes(node).each do |when_node|
121
- when_predicates(when_node).each do |predicate|
150
+ else
151
+ conditions_for(node).each do |when_node|
152
+ conditions_for(when_node).each do |predicate|
122
153
  predicate = unwrap_predicate(predicate)
123
154
  specs << { node: when_node, predicate: predicate, context: "case_when" }
124
155
  mark_semantic_boolean_nodes(predicate, inventoried_boolean_nodes)
@@ -129,8 +160,7 @@ module Branchproof
129
160
 
130
161
  # Boolean expressions nested in an atomic expression (for example, a call
131
162
  # argument) are separate decisions when tree_for did not decompose them.
132
- walk(program) do |node|
133
- next unless boolean_node?(node) || node.is_a?(Prism::MatchPredicateNode)
163
+ collected[:phase_two_nodes].each do |node|
134
164
  next if inventoried_boolean_nodes[node]
135
165
 
136
166
  additional_reasons = if within_defined_expression?(node, defined_ranges)
@@ -158,16 +188,15 @@ module Branchproof
158
188
  predicate: predicate, context: spec[:context],
159
189
  additional_reasons: spec[:additional_reasons] || [])
160
190
  end
161
- boolean_decisions + flow_decisions_for(program, bytes, source_id, file_reasons, encoding)
191
+ boolean_decisions + flow_decisions_for(program, bytes, source_id, file_reasons, encoding,
192
+ defined_ranges: defined_ranges, nodes: collected[:flow_nodes])
162
193
  end
163
194
 
164
- def flow_decisions_for(program, bytes, source_id, file_reasons = [], encoding = "UTF-8")
165
- defined_ranges = []
166
- walk(program) do |node|
167
- defined_ranges << node.location if node.is_a?(Prism::DefinedNode)
168
- end
195
+ def flow_decisions_for(program, bytes, source_id, file_reasons = [], encoding = "UTF-8",
196
+ defined_ranges: nil, nodes: nil)
197
+ defined_ranges ||= collect_defined_ranges(program)
169
198
 
170
- super.map do |decision|
199
+ super(program, bytes, source_id, file_reasons, encoding, nodes: nodes).map do |decision|
171
200
  next decision unless range_within_defined_expression?(decision, defined_ranges)
172
201
 
173
202
  decision.merge(
@@ -177,23 +206,15 @@ module Branchproof
177
206
  end
178
207
  end
179
208
 
180
- def pattern_guard_nodes_for(program)
181
- guards = {}.compare_by_identity
182
- walk(program) do |node|
183
- next unless node.is_a?(Prism::InNode)
184
-
185
- pattern = node.pattern
186
- guards[pattern] = true if pattern.is_a?(Prism::IfNode) || pattern.is_a?(Prism::UnlessNode)
187
- end
188
- guards
209
+ def collect_defined_ranges(program)
210
+ defined_ranges = []
211
+ walk(program) { |node| defined_ranges << node.location if node.is_a?(Prism::DefinedNode) }
212
+ defined_ranges
189
213
  end
190
214
 
191
215
  def range_within_defined_expression?(decision, defined_ranges)
192
216
  start_offset = decision[:byte_start]
193
- end_offset = start_offset + decision[:byte_length]
194
- defined_ranges.any? do |defined_location|
195
- defined_location.start_offset <= start_offset && defined_location.end_offset >= end_offset
196
- end
217
+ offsets_within_defined_expression?(start_offset, start_offset + decision[:byte_length], defined_ranges)
197
218
  end
198
219
 
199
220
  def walk(node, &block)
@@ -214,16 +235,7 @@ module Branchproof
214
235
  node.is_a?(Prism::CaseNode) && node.predicate.nil?
215
236
  end
216
237
 
217
- def when_nodes(node)
218
- conditions = node.conditions
219
- if conditions.is_a?(Array)
220
- conditions
221
- else
222
- (conditions.respond_to?(:body) ? conditions.body : [])
223
- end
224
- end
225
-
226
- def when_predicates(node)
238
+ def conditions_for(node)
227
239
  conditions = node.conditions
228
240
  if conditions.is_a?(Array)
229
241
  conditions
@@ -249,6 +261,7 @@ module Branchproof
249
261
  context ||= decision_context(node, bytes)
250
262
  leaves = []
251
263
  tree = tree_for(predicate, bytes, leaves)
264
+ decision_constraint_safe = constraint_safe_expression?(predicate)
252
265
  start_offset = predicate.location.start_offset
253
266
  length = predicate.location.length
254
267
  opaque_ranges = leaves.filter_map { |leaf| leaf.delete(:_opaque_range) }
@@ -256,9 +269,12 @@ module Branchproof
256
269
  expression = text_value(leaf.delete(:_expression), "UTF-8")
257
270
  location = leaf.delete(:_location)
258
271
  literal_truth = leaf.delete(:_literal_truth)
272
+ constraint = leaf.delete(:_constraint)
273
+ constraint_safe = leaf.delete(:_constraint_safe)
259
274
  Records.build(id: nil, index: index, byte_start: location.start_offset, byte_length: location.length,
260
275
  line: location.start_line, column: location.start_column,
261
- expression: expression, literal_truth: literal_truth, coupling: "unknown")
276
+ expression: expression, literal_truth: literal_truth, coupling: "unknown",
277
+ constraint: constraint, constraint_safe: decision_constraint_safe && constraint_safe == true)
262
278
  end
263
279
  decision_id = Records.decision_id(source_id: source_id, context: context, byte_start: start_offset,
264
280
  byte_length: length, tree: tree)
@@ -287,9 +303,12 @@ module Branchproof
287
303
 
288
304
  def within_defined_expression?(node, defined_ranges)
289
305
  location = node.location
306
+ offsets_within_defined_expression?(location.start_offset, location.end_offset, defined_ranges)
307
+ end
308
+
309
+ def offsets_within_defined_expression?(start_offset, end_offset, defined_ranges)
290
310
  defined_ranges.any? do |defined_location|
291
- defined_location.start_offset <= location.start_offset &&
292
- defined_location.end_offset >= location.end_offset
311
+ defined_location.start_offset <= start_offset && defined_location.end_offset >= end_offset
293
312
  end
294
313
  end
295
314
 
@@ -330,6 +349,8 @@ module Branchproof
330
349
  leaf = {
331
350
  _expression: bytes.byteslice(location.start_offset, location.length), _location: location,
332
351
  _literal_truth: literal_truth(node),
352
+ _constraint: (constraint = Constraints.for_node(node)),
353
+ _constraint_safe: safe_constraint_node?(constraint),
333
354
  _opaque_range: if node.is_a?(Prism::CallNode) && node.name == :!
334
355
  { start: location.start_offset, length: location.length }
335
356
  end
@@ -338,6 +359,27 @@ module Branchproof
338
359
  Records.build(type: :atom, index: leaves.length - 1)
339
360
  end
340
361
 
362
+ # Source-derived comparisons are deliberately untrusted. Ruby permits
363
+ # mutation between leaves and user-defined operators, so only repeated
364
+ # truthiness reads of local variables in an entirely side-effect-free
365
+ # decision may be used by the table solver.
366
+ def constraint_safe_expression?(node)
367
+ node = unwrap_predicate(node)
368
+ case node
369
+ when Prism::AndNode, Prism::OrNode
370
+ constraint_safe_expression?(node.left) && constraint_safe_expression?(node.right)
371
+ when Prism::LocalVariableReadNode, Prism::TrueNode, Prism::FalseNode, Prism::NilNode,
372
+ Prism::IntegerNode, Prism::FloatNode, Prism::SymbolNode, Prism::StringNode
373
+ true
374
+ else
375
+ false
376
+ end
377
+ end
378
+
379
+ def safe_constraint_node?(constraint)
380
+ constraint && constraint[:operator] == "truthy" && constraint[:subject][:kind] == "local"
381
+ end
382
+
341
383
  def unary_not?(node)
342
384
  node.is_a?(Prism::CallNode) && node.name == :! && node.receiver && node.call_operator_loc.nil?
343
385
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Branchproof
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.0"
5
5
  end
@@ -14,12 +14,9 @@ module Branchproof
14
14
 
15
15
  def child_process(config_path)
16
16
  payload = JSON.parse(File.binread(config_path))
17
- project = symbolize(payload.fetch("project", legacy_project))
17
+ project = symbolize(payload.fetch("project"))
18
18
  prepend_load_paths(project)
19
19
  inventory = symbolize(payload.fetch("inventory"))
20
- Array(inventory[:source_units]).each do |unit|
21
- unit[:original_bytes] = File.binread(unit[:absolute_path]) if unit[:absolute_path]
22
- end
23
20
  limits = symbolize(payload.fetch("limits"))
24
21
  require "minitest"
25
22
  require "minitest/test"
data/lib/branchproof.rb CHANGED
@@ -16,6 +16,8 @@ module Branchproof
16
16
  autoload :MinitestAdapter, "branchproof/minitest_adapter"
17
17
  autoload :Evidence, "branchproof/evidence"
18
18
  autoload :Analyzer, "branchproof/analyzer"
19
+ autoload :Constraints, "branchproof/constraints"
20
+ autoload :DecisionTable, "branchproof/decision_table"
19
21
  autoload :Minimizer, "branchproof/minimizer"
20
22
  autoload :Report, "branchproof/report"
21
23
  autoload :CoverageIndex, "branchproof/coverage_index"
data/llms.txt CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  | | |
4
4
  | --- | --- |
5
- | **Defined in** | lib/branchproof.rb, lib/branchproof/cli.rb, lib/branchproof/limits.rb, lib/branchproof/loader.rb, lib/branchproof/report.rb, lib/branchproof/source.rb, lib/branchproof/worker.rb, lib/branchproof/project.rb, lib/branchproof/records.rb, lib/branchproof/runtime.rb, lib/branchproof/version.rb, lib/branchproof/analyzer.rb, lib/branchproof/evidence.rb, lib/branchproof/minimizer.rb, lib/branchproof/comparison.rb, lib/branchproof/instrumenter.rb, lib/branchproof/runtime_flow.rb, lib/branchproof/saved_report.rb, lib/branchproof/rails_support.rb, lib/branchproof/coverage_index.rb, lib/branchproof/focused_report.rb, lib/branchproof/decision_syntax.rb, lib/branchproof/minitest_adapter.rb, lib/branchproof/comparison_report.rb, lib/branchproof/flow_instrumentation.rb |
5
+ | **Defined in** | lib/branchproof.rb, lib/branchproof/cli.rb, lib/branchproof/limits.rb, lib/branchproof/loader.rb, lib/branchproof/report.rb, lib/branchproof/source.rb, lib/branchproof/worker.rb, lib/branchproof/project.rb, lib/branchproof/records.rb, lib/branchproof/runtime.rb, lib/branchproof/version.rb, lib/branchproof/analyzer.rb, lib/branchproof/evidence.rb, lib/branchproof/minimizer.rb, lib/branchproof/comparison.rb, lib/branchproof/constraints.rb, lib/branchproof/instrumenter.rb, lib/branchproof/runtime_flow.rb, lib/branchproof/saved_report.rb, lib/branchproof/rails_support.rb, lib/branchproof/coverage_index.rb, lib/branchproof/decision_table.rb, lib/branchproof/focused_report.rb, lib/branchproof/decision_syntax.rb, lib/branchproof/minitest_adapter.rb, lib/branchproof/comparison_report.rb, lib/branchproof/flow_instrumentation.rb |
6
6
 
7
7
  Keep each bounded source rewrite together so its evaluation order can be
8
8
  audited. rubocop:disable Metrics/AbcSize, Metrics/MethodLength
@@ -17,8 +17,11 @@ Not documented.
17
17
  - [Branchproof/CLI.md](doc/Branchproof/CLI.md)
18
18
  - [Branchproof/Comparison.md](doc/Branchproof/Comparison.md)
19
19
  - [Branchproof/ComparisonReport.md](doc/Branchproof/ComparisonReport.md)
20
+ - [Branchproof/Constraints/Solver.md](doc/Branchproof/Constraints/Solver.md)
21
+ - [Branchproof/Constraints.md](doc/Branchproof/Constraints.md)
20
22
  - [Branchproof/CoverageIndex.md](doc/Branchproof/CoverageIndex.md)
21
23
  - [Branchproof/DecisionSyntax.md](doc/Branchproof/DecisionSyntax.md)
24
+ - [Branchproof/DecisionTable.md](doc/Branchproof/DecisionTable.md)
22
25
  - [Branchproof/Error.md](doc/Branchproof/Error.md)
23
26
  - [Branchproof/Evidence.md](doc/Branchproof/Evidence.md)
24
27
  - [Branchproof/FlowInstrumentation.md](doc/Branchproof/FlowInstrumentation.md)