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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +35 -0
- data/README.md +212 -11
- data/doc/Branchproof/Analyzer.md +2 -2
- data/doc/Branchproof/CLI.md +4 -0
- data/doc/Branchproof/ComparisonReport.md +4 -0
- data/doc/Branchproof/Constraints/Solver.md +21 -0
- data/doc/Branchproof/Constraints.md +98 -0
- data/doc/Branchproof/CoverageIndex.md +3 -0
- data/doc/Branchproof/DecisionSyntax.md +6 -4
- data/doc/Branchproof/DecisionTable.md +153 -0
- data/doc/Branchproof/FocusedReport.md +5 -2
- data/doc/Branchproof/Loader.md +0 -4
- data/doc/Branchproof/MinitestAdapter.md +0 -3
- data/doc/Branchproof/Report.md +18 -0
- data/doc/Branchproof/Runtime.md +4 -0
- data/doc/Branchproof/SavedReport.md +15 -0
- data/doc/Branchproof.md +4 -1
- data/doc/CHANGELOG.md +35 -0
- data/doc/README.md +212 -11
- data/lib/branchproof/analyzer.rb +80 -44
- data/lib/branchproof/cli.rb +28 -19
- data/lib/branchproof/comparison.rb +207 -19
- data/lib/branchproof/comparison_report.rb +49 -1
- data/lib/branchproof/constraints.rb +363 -0
- data/lib/branchproof/coverage_index.rb +30 -1
- data/lib/branchproof/decision_syntax.rb +31 -15
- data/lib/branchproof/decision_table.rb +377 -0
- data/lib/branchproof/evidence.rb +51 -31
- data/lib/branchproof/flow_instrumentation.rb +17 -17
- data/lib/branchproof/focused_report.rb +77 -9
- data/lib/branchproof/instrumenter.rb +45 -34
- data/lib/branchproof/limits.rb +4 -1
- data/lib/branchproof/loader.rb +18 -6
- data/lib/branchproof/minimizer.rb +18 -13
- data/lib/branchproof/minitest_adapter.rb +11 -16
- data/lib/branchproof/records.rb +2 -0
- data/lib/branchproof/report.rb +301 -68
- data/lib/branchproof/runtime.rb +22 -30
- data/lib/branchproof/saved_report.rb +200 -14
- data/lib/branchproof/source.rb +90 -48
- data/lib/branchproof/version.rb +1 -1
- data/lib/branchproof/worker.rb +1 -4
- data/lib/branchproof.rb +2 -0
- data/llms.txt +4 -1
- data/sig/branchproof.rbs +47 -1
- 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"]).
|
|
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 @
|
|
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
|
-
@
|
|
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 @
|
|
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
|
-
@
|
|
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
|
|
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
|
|
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
|
-
|
|
385
|
-
|
|
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
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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))
|
data/lib/branchproof/source.rb
CHANGED
|
@@ -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
|
-
|
|
65
|
-
|
|
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:
|
|
82
|
-
real_path: File.realpath(path), digest:
|
|
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:
|
|
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
|
-
|
|
107
|
-
|
|
137
|
+
collected = collect_ast_nodes(program)
|
|
138
|
+
defined_ranges = collected[:defined_ranges]
|
|
139
|
+
guard_patterns = collected[:guard_patterns]
|
|
108
140
|
|
|
109
|
-
|
|
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 =
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
|
181
|
-
|
|
182
|
-
walk(program)
|
|
183
|
-
|
|
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
|
-
|
|
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
|
|
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 <=
|
|
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
|
data/lib/branchproof/version.rb
CHANGED
data/lib/branchproof/worker.rb
CHANGED
|
@@ -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"
|
|
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)
|