bparity 0.1.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 (68) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +61 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +146 -0
  5. data/Rakefile +36 -0
  6. data/docs/application_example.md +25 -0
  7. data/docs/formal_assurance_limits.md +21 -0
  8. data/exe/bparity +7 -0
  9. data/fixtures/scenarios/01_pure_function/adapter.rb +13 -0
  10. data/fixtures/scenarios/01_pure_function/boundary.rb +17 -0
  11. data/fixtures/scenarios/01_pure_function/legacy/dead_gem.rb +9 -0
  12. data/fixtures/scenarios/01_pure_function/legacy/slugifier.rb +17 -0
  13. data/fixtures/scenarios/01_pure_function/replacement/broken.rb +15 -0
  14. data/fixtures/scenarios/01_pure_function/replacement/good.rb +19 -0
  15. data/fixtures/scenarios/01_pure_function/spec/slugifier_spec.rb +20 -0
  16. data/fixtures/scenarios/01_pure_function/test/slugifier_test.rb +10 -0
  17. data/fixtures/scenarios/02_stateful_client/adapter.rb +19 -0
  18. data/fixtures/scenarios/02_stateful_client/boundary.rb +10 -0
  19. data/fixtures/scenarios/02_stateful_client/legacy/client.rb +29 -0
  20. data/fixtures/scenarios/02_stateful_client/replacement/broken.rb +14 -0
  21. data/fixtures/scenarios/02_stateful_client/replacement/good.rb +23 -0
  22. data/fixtures/scenarios/02_stateful_client/spec/client_spec.rb +31 -0
  23. data/fixtures/scenarios/03_external_boundary/adapter.rb +13 -0
  24. data/fixtures/scenarios/03_external_boundary/boundary.rb +11 -0
  25. data/fixtures/scenarios/03_external_boundary/legacy/dead_formatter.rb +7 -0
  26. data/fixtures/scenarios/03_external_boundary/legacy/receipt.rb +11 -0
  27. data/fixtures/scenarios/03_external_boundary/replacement/broken.rb +11 -0
  28. data/fixtures/scenarios/03_external_boundary/replacement/good.rb +11 -0
  29. data/fixtures/scenarios/03_external_boundary/spec/receipt_spec.rb +10 -0
  30. data/fixtures/scenarios/04_intentional_divergence/adapter.rb +12 -0
  31. data/fixtures/scenarios/04_intentional_divergence/adapter_unwaived.rb +10 -0
  32. data/fixtures/scenarios/04_intentional_divergence/boundary.rb +8 -0
  33. data/fixtures/scenarios/04_intentional_divergence/legacy/dead_identity.rb +7 -0
  34. data/fixtures/scenarios/04_intentional_divergence/legacy/identity.rb +9 -0
  35. data/fixtures/scenarios/04_intentional_divergence/replacement/broken.rb +9 -0
  36. data/fixtures/scenarios/04_intentional_divergence/replacement/good.rb +9 -0
  37. data/fixtures/scenarios/04_intentional_divergence/spec/identity_spec.rb +10 -0
  38. data/fixtures/scenarios/05_formal_negative/adapter.rb +15 -0
  39. data/fixtures/scenarios/05_formal_negative/boundary.rb +16 -0
  40. data/fixtures/scenarios/05_formal_negative/legacy/dead_lock.rb +5 -0
  41. data/fixtures/scenarios/05_formal_negative/legacy/turnstile.rb +24 -0
  42. data/fixtures/scenarios/05_formal_negative/replacement/broken.rb +18 -0
  43. data/fixtures/scenarios/05_formal_negative/replacement/formal_broken.rb +24 -0
  44. data/fixtures/scenarios/05_formal_negative/replacement/good.rb +24 -0
  45. data/fixtures/scenarios/05_formal_negative/spec/turnstile_spec.rb +21 -0
  46. data/lib/bparity/adapter.rb +115 -0
  47. data/lib/bparity/adequacy.rb +78 -0
  48. data/lib/bparity/boundary.rb +97 -0
  49. data/lib/bparity/cli/formal_commands.rb +428 -0
  50. data/lib/bparity/cli/verification_commands.rb +242 -0
  51. data/lib/bparity/cli.rb +262 -0
  52. data/lib/bparity/corpus.rb +45 -0
  53. data/lib/bparity/errors.rb +12 -0
  54. data/lib/bparity/formal/assumptions.rb +131 -0
  55. data/lib/bparity/formal/bounded.rb +543 -0
  56. data/lib/bparity/formal/contract.rb +81 -0
  57. data/lib/bparity/formal/deductive.rb +481 -0
  58. data/lib/bparity/formal/lts.rb +344 -0
  59. data/lib/bparity/formal/result.rb +50 -0
  60. data/lib/bparity/formal.rb +8 -0
  61. data/lib/bparity/recording.rb +412 -0
  62. data/lib/bparity/reporting.rb +128 -0
  63. data/lib/bparity/spec_bundle.rb +208 -0
  64. data/lib/bparity/synthesis.rb +487 -0
  65. data/lib/bparity/verification.rb +310 -0
  66. data/lib/bparity/version.rb +5 -0
  67. data/lib/bparity.rb +42 -0
  68. metadata +125 -0
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec"
4
+ require_relative "../legacy/receipt"
5
+
6
+ RSpec.describe Legacy::Receipt do
7
+ it "renders money through the retired formatter" do
8
+ expect(described_class.new.print(1_200)).to eq("Total: $12")
9
+ end
10
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ Bparity.adapter do
4
+ subject "Identity" do
5
+ construct { Replacement::Identity }
6
+ operation "#label" do
7
+ invoke { |identity, args, _kwargs| identity.display(value: args.fetch(0)) }
8
+ end
9
+ end
10
+ waive "bc-000001", reason: "The replacement uses inclusive terminology",
11
+ approved_by: "migration-owner", approved_at: "2026-08-28"
12
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ Bparity.adapter do
4
+ subject "Identity" do
5
+ construct { Replacement::Identity }
6
+ operation "#label" do
7
+ invoke { |identity, args, _kwargs| identity.display(value: args.fetch(0)) }
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ Bparity.boundary do
4
+ observe "Legacy::Identity" do
5
+ methods :label
6
+ end
7
+ driver :rspec, files: "fixtures/scenarios/04_intentional_divergence/spec/**/*_spec.rb"
8
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RetiredIdentity
4
+ module_function
5
+
6
+ def guest_name = "guest"
7
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "dead_identity"
4
+
5
+ module Legacy
6
+ class Identity
7
+ def label(name) = name || RetiredIdentity.guest_name
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Replacement
4
+ module Identity
5
+ module_function
6
+
7
+ def display(value:) = value || "anonymous"
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Replacement
4
+ module Identity
5
+ module_function
6
+
7
+ def display(value:) = value || "guest"
8
+ end
9
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec"
4
+ require_relative "../legacy/identity"
5
+
6
+ RSpec.describe Legacy::Identity do
7
+ it "uses the legacy guest label" do
8
+ expect(described_class.new.label(nil)).to eq("guest")
9
+ end
10
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ Bparity.adapter do
4
+ subject "PureToken" do
5
+ construct { Replacement::PureTokenService }
6
+ operation("#token") { invoke { |service, args, _kwargs| service.shift(*args) } }
7
+ end
8
+ subject "Turnstile" do
9
+ construct { Replacement::Gate.new }
10
+ state { |gate| { locked: gate.phase == :secured } }
11
+ operation("#unlock") { invoke { |gate, _args, _kwargs| gate.release } }
12
+ operation("#lock") { invoke { |gate, _args, _kwargs| gate.secure } }
13
+ operation("#enter") { invoke { |gate, _args, _kwargs| gate.pass } }
14
+ end
15
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ Bparity.boundary do
4
+ observe "Legacy::PureToken" do
5
+ methods :token
6
+ end
7
+ observe "Legacy::Turnstile" do
8
+ methods :unlock, :lock, :enter
9
+ state { |turnstile| { locked: turnstile.locked } }
10
+ end
11
+ driver :rspec, files: "fixtures/scenarios/05_formal_negative/spec/**/*_spec.rb"
12
+ formal do
13
+ pure_fragment "Legacy::PureToken#token"
14
+ lts_learning :active
15
+ end
16
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RetiredLock
4
+ DEFAULT_LOCKED = true
5
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "dead_lock"
4
+
5
+ module Legacy
6
+ class PureToken
7
+ def token(value) = value + 1
8
+ end
9
+
10
+ class Turnstile
11
+ attr_reader :locked
12
+
13
+ def initialize = @locked = RetiredLock::DEFAULT_LOCKED
14
+ def unlock = (@locked = false; :ok)
15
+ def lock = (@locked = true; :ok)
16
+
17
+ def enter
18
+ return :denied if locked
19
+
20
+ @locked = true
21
+ :entered
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Replacement
4
+ module PureTokenService
5
+ module_function
6
+
7
+ def shift(value) = value + 1
8
+ end
9
+
10
+ class Gate
11
+ attr_reader :phase
12
+
13
+ def initialize = @phase = :secured
14
+ def release = (@phase = :open; :ok)
15
+ def secure = (@phase = :secured; :ok)
16
+ def pass = phase == :secured ? :denied : :entered
17
+ end
18
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Replacement
4
+ module PureTokenService
5
+ module_function
6
+
7
+ def shift(value) = value + 2
8
+ end
9
+
10
+ class Gate
11
+ attr_reader :phase
12
+
13
+ def initialize = @phase = :secured
14
+ def release = (@phase = :open; :ok)
15
+ def secure = (@phase = :secured; :ok)
16
+
17
+ def pass
18
+ return :denied if phase == :secured
19
+
20
+ @phase = :secured
21
+ :entered
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Replacement
4
+ module PureTokenService
5
+ module_function
6
+
7
+ def shift(value) = value + 1
8
+ end
9
+
10
+ class Gate
11
+ attr_reader :phase
12
+
13
+ def initialize = @phase = :secured
14
+ def release = (@phase = :open; :ok)
15
+ def secure = (@phase = :secured; :ok)
16
+
17
+ def pass
18
+ return :denied if phase == :secured
19
+
20
+ @phase = :secured
21
+ :entered
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec"
4
+ require_relative "../legacy/turnstile"
5
+
6
+ RSpec.describe Legacy::Turnstile do
7
+ it "exposes a pure fragment for F2 and F4 negative checks" do
8
+ expect(Legacy::PureToken.new.token(1)).to eq(2)
9
+ end
10
+
11
+ it "covers every operation from locked and unlocked states" do
12
+ turnstile = described_class.new
13
+ expect(turnstile.lock).to eq(:ok)
14
+ expect(turnstile.enter).to eq(:denied)
15
+ expect(turnstile.unlock).to eq(:ok)
16
+ expect(turnstile.unlock).to eq(:ok)
17
+ expect(turnstile.lock).to eq(:ok)
18
+ expect(turnstile.unlock).to eq(:ok)
19
+ expect(turnstile.enter).to eq(:entered)
20
+ end
21
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Bparity
6
+ module Adapter
7
+ Operation = Struct.new(:name, :invoker, :return_mapper, :error_mapper, keyword_init: true) do
8
+ def invoke(subject, args, kwargs, &block)
9
+ return invoker.call(subject, args, kwargs, &block) if invoker
10
+
11
+ subject.public_send(name.delete_prefix("#"), *args, **kwargs, &block)
12
+ end
13
+
14
+ def map_return(value) = return_mapper ? return_mapper.call(value) : value
15
+
16
+ def map_error(error)
17
+ error_mapper ? error_mapper.call(error) : { class: error.class.name, message: Bparity.exception_message(error) }
18
+ end
19
+ end
20
+
21
+ class OperationDsl
22
+ def initialize(operation)
23
+ @operation = operation
24
+ end
25
+
26
+ def invoke(&block) = @operation.invoker = block
27
+ def map_return(&block) = @operation.return_mapper = block
28
+ def map_error(&block) = @operation.error_mapper = block
29
+ end
30
+
31
+ class Subject
32
+ attr_reader :name, :operations
33
+ attr_accessor :constructor, :state_projection
34
+
35
+ def initialize(name)
36
+ @name = name
37
+ @operations = {}
38
+ end
39
+
40
+ def construct(&block) = self.constructor = block
41
+ def state(&block) = self.state_projection = block
42
+
43
+ def operation(name, &block)
44
+ item = Operation.new(name: name)
45
+ OperationDsl.new(item).instance_eval(&block) if block
46
+ operations[name] = item
47
+ end
48
+
49
+ def build(context = {})
50
+ unless constructor
51
+ raise ConfigurationError, "Adapter subject #{name} has no constructor. Add a construct block to the adapter."
52
+ end
53
+
54
+ constructor.call(context)
55
+ end
56
+ end
57
+
58
+ Waiver = Struct.new(:id, :reason, :approved_by, :approved_at, keyword_init: true)
59
+
60
+ class External
61
+ attr_reader :source, :target, :call_mappers
62
+
63
+ def initialize(source, target)
64
+ @source = source
65
+ @target = target
66
+ @call_mappers = {}
67
+ end
68
+
69
+ def map_call(name, &block)
70
+ call_mappers[name.to_s] = block
71
+ end
72
+ end
73
+
74
+ class Definition
75
+ attr_reader :spec, :subjects, :waivers, :externals
76
+
77
+ def initialize(spec: nil)
78
+ @spec = spec
79
+ @subjects = {}
80
+ @waivers = {}
81
+ @externals = []
82
+ end
83
+
84
+ def subject(name, &block)
85
+ item = Subject.new(name)
86
+ item.instance_eval(&block) if block
87
+ subjects[name] = item
88
+ end
89
+
90
+ def waive(id, reason:, approved_by:, approved_at:)
91
+ values = { "id" => id, "reason" => reason, "approved_by" => approved_by, "approved_at" => approved_at }
92
+ missing = values.filter_map { |name, value| name if value.to_s.strip.empty? }
93
+ unless missing.empty?
94
+ raise ConfigurationError,
95
+ "Waiver metadata is missing #{missing.join(', ')}. Add an ID, reason, approver, and approval date."
96
+ end
97
+ begin
98
+ Date.iso8601(approved_at.to_s)
99
+ rescue Date::Error
100
+ raise ConfigurationError,
101
+ "Waiver #{id} has an invalid approval date. Use YYYY-MM-DD."
102
+ end
103
+
104
+ waivers[id] = Waiver.new(id:, reason:, approved_by:, approved_at:)
105
+ end
106
+
107
+ def external(mapping, &block)
108
+ source, target = mapping.first
109
+ item = External.new(source, target)
110
+ item.instance_eval(&block) if block
111
+ externals << item
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Bparity
6
+ module Adequacy
7
+ class MutantBridge
8
+ SCORE = /Mutation coverage:\s*([\d.]+)%/
9
+
10
+ def available?
11
+ _output, _error, status = Open3.capture3("bundle", "exec", "mutant", "--version")
12
+ status.success?
13
+ rescue Errno::ENOENT
14
+ false
15
+ end
16
+
17
+ def run
18
+ return { "status" => "skipped", "reason" => "mutant is not installed" } unless available?
19
+
20
+ output, error, status = Open3.capture3("bundle", "exec", "mutant", "run")
21
+ match = output.match(SCORE)
22
+ score = match[1].to_f if match
23
+ { "status" => status.success? ? "completed" : "failed", "score" => score,
24
+ "output" => output, "error" => error }
25
+ end
26
+ end
27
+
28
+ module CoverageReconciler
29
+ module_function
30
+
31
+ def call(gaps:, surviving_mutants: [])
32
+ risks = gaps.map { |gap| "Legacy gap: #{gap['location'] || gap['note'] || gap['kind']}" }
33
+ risks + surviving_mutants.map { |mutant| "Unconstrained replacement behavior: #{mutant}" }
34
+ end
35
+ end
36
+
37
+ class Analyzer
38
+ def initialize(bundle:, results:, mutation: nil)
39
+ @bundle = bundle
40
+ @results = results
41
+ @mutation = mutation || { "status" => "skipped", "reason" => "not requested" }
42
+ end
43
+
44
+ def call
45
+ examples = @bundle.fetch("subjects").flat_map { |subject| subject.fetch("operations") }
46
+ .flat_map { |operation| operation.fetch("examples") }
47
+ evaluated = @results.length
48
+ {
49
+ "specification_coverage" => ratio(evaluated, examples.length),
50
+ "recorded_examples" => examples.length,
51
+ "generated_checks" => generated_checks,
52
+ "formal_assurance" => Reporting::AssuranceMatrix.new(@bundle).to_h,
53
+ "constraint_strength" => @mutation,
54
+ "residual_risks" => CoverageReconciler.call(gaps: @bundle.fetch("gaps", []),
55
+ surviving_mutants: mutation_risks),
56
+ "waiver_count" => @results.count { |result| result.status == :waived }
57
+ }
58
+ end
59
+
60
+ private
61
+
62
+ def generated_checks
63
+ @bundle.fetch("subjects").sum do |subject|
64
+ subject.fetch("operations").sum { |operation| operation.fetch("invariants", []).length }
65
+ end
66
+ end
67
+
68
+ def ratio(numerator, denominator) = denominator.zero? ? 1.0 : (numerator.to_f / denominator).round(4)
69
+
70
+ def mutation_risks
71
+ score = @mutation["score"]
72
+ return [] unless score && score < 100
73
+
74
+ ["mutation score #{score}% (inspect the mutant output for survivors)"]
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bparity
4
+ module Boundary
5
+ Subject = Struct.new(:name, :method_names, :excluded, :state_projection, :return_projections, keyword_init: true) do
6
+ def initialize(name:, **)
7
+ super(name:, method_names: [], excluded: [], return_projections: {})
8
+ end
9
+
10
+ def methods(*names)
11
+ self.method_names = names.map(&:to_sym)
12
+ end
13
+
14
+ def exclude(*names)
15
+ self.excluded |= names.map(&:to_sym)
16
+ end
17
+
18
+ def state(&block)
19
+ self.state_projection = block
20
+ end
21
+
22
+ def project_return(class_name, &block)
23
+ return_projections[class_name] = block
24
+ end
25
+
26
+ def observed_methods(target)
27
+ (method_names.empty? ? target.public_instance_methods(false) : method_names) - excluded
28
+ end
29
+ end
30
+
31
+ External = Struct.new(:name, :method_names, keyword_init: true) do
32
+ def initialize(name:, **)
33
+ super(name:, method_names: [])
34
+ end
35
+
36
+ def methods(*names)
37
+ self.method_names = names.map(&:to_sym)
38
+ end
39
+ end
40
+
41
+ class Definition
42
+ attr_reader :subjects, :externals, :canonicalization, :driver_config, :formal_config
43
+
44
+ def initialize
45
+ @subjects = {}
46
+ @externals = {}
47
+ @canonicalization = {}
48
+ @formal_config = {}
49
+ end
50
+
51
+ def observe(name, &block)
52
+ subject = Subject.new(name: name)
53
+ subject.instance_eval(&block) if block
54
+ subjects[name] = subject
55
+ end
56
+
57
+ def external(name, &block)
58
+ item = External.new(name: name)
59
+ item.instance_eval(&block) if block
60
+ externals[name] = item
61
+ end
62
+
63
+ def canonicalize(&)
64
+ CanonicalizationDsl.new(canonicalization).instance_eval(&)
65
+ end
66
+
67
+ def driver(name, **options)
68
+ @driver_config = { name: name.to_sym, **options }
69
+ end
70
+
71
+ def formal(&)
72
+ FormalDsl.new(formal_config).instance_eval(&)
73
+ end
74
+ end
75
+
76
+ class CanonicalizationDsl
77
+ def initialize(config)
78
+ @config = config
79
+ end
80
+
81
+ def freeze_time(value) = @config[:freeze_time] = value
82
+ def random_seed(value) = @config[:random_seed] = value
83
+ def uuid_placeholder(value) = @config[:uuid_placeholder] = value
84
+ def float_tolerance(value) = @config[:float_tolerance] = value
85
+ end
86
+
87
+ class FormalDsl
88
+ def initialize(config)
89
+ @config = config
90
+ end
91
+
92
+ def pure_fragment(name) = (@config[:pure_fragments] ||= []) << name
93
+ def bounded_scope(**scope) = @config[:bounded_scope] = scope
94
+ def lts_learning(mode) = @config[:lts_learning] = mode
95
+ end
96
+ end
97
+ end