specproof 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 (119) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +10 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +283 -0
  5. data/docs/agent-skills.md +60 -0
  6. data/docs/benchmark.md +24 -0
  7. data/docs/constrained-partition-pairs-results.md +44 -0
  8. data/docs/finite-strings-results.md +75 -0
  9. data/docs/implementation-status.md +65 -0
  10. data/docs/input-selection-results.md +123 -0
  11. data/docs/lint-cleanup-plan.md +12 -0
  12. data/docs/method-targets-results.md +68 -0
  13. data/docs/minitest-compatibility.md +33 -0
  14. data/docs/optional-arguments-plan.md +25 -0
  15. data/docs/optional-arguments-results.md +45 -0
  16. data/docs/pairwise-partitions-results.md +53 -0
  17. data/docs/pairwise-results.md +77 -0
  18. data/docs/pairwise-suggestions-results.md +104 -0
  19. data/docs/rbs-implementation-status.md +115 -0
  20. data/docs/rbs-string-literals-results.md +58 -0
  21. data/docs/real-project-pilot.md +84 -0
  22. data/docs/report-format.md +244 -0
  23. data/examples/age_policy/README.md +39 -0
  24. data/examples/age_policy/faulty/lib/age_policy.rb +9 -0
  25. data/examples/age_policy/faulty/test/age_policy_examples_test.rb +17 -0
  26. data/examples/age_policy/faulty/test/age_policy_test.rb +21 -0
  27. data/examples/age_policy/lib/age_policy.rb +8 -0
  28. data/examples/age_policy/run.rb +23 -0
  29. data/examples/age_policy/specification.rb +8 -0
  30. data/examples/age_policy/test/age_policy_test.rb +18 -0
  31. data/examples/constrained_partition_pairs/README.md +40 -0
  32. data/examples/constrained_partition_pairs/lib/account_eligibility.rb +8 -0
  33. data/examples/constrained_partition_pairs/requirements.md +11 -0
  34. data/examples/constrained_partition_pairs/run.rb +22 -0
  35. data/examples/constrained_partition_pairs/specification.rb +17 -0
  36. data/examples/constrained_partition_pairs/test_account_eligibility.rb +34 -0
  37. data/examples/input_selection/README.md +22 -0
  38. data/examples/input_selection/delivery_policy.rb +11 -0
  39. data/examples/input_selection/requirements.md +9 -0
  40. data/examples/input_selection/specification.rb +8 -0
  41. data/examples/input_selection/test_delivery_policy.rb +38 -0
  42. data/examples/method_targets/README.md +30 -0
  43. data/examples/method_targets/lib/delivery_defaults.rb +8 -0
  44. data/examples/method_targets/lib/delivery_options.rb +12 -0
  45. data/examples/method_targets/requirements.md +20 -0
  46. data/examples/method_targets/specification_objects.rb +17 -0
  47. data/examples/method_targets/specification_strings.rb +15 -0
  48. data/examples/pairwise/README.md +46 -0
  49. data/examples/pairwise/constrained_requirements.md +6 -0
  50. data/examples/pairwise/constrained_specification.rb +11 -0
  51. data/examples/pairwise/corrected/lib/delivery_options.rb +11 -0
  52. data/examples/pairwise/corrected/test/delivery_options_test.rb +28 -0
  53. data/examples/pairwise/faulty/lib/delivery_options.rb +11 -0
  54. data/examples/pairwise/faulty/test/gap_delivery_options_test.rb +10 -0
  55. data/examples/pairwise/faulty/test/initial_delivery_options_test.rb +23 -0
  56. data/examples/pairwise/requirements.md +11 -0
  57. data/examples/pairwise/run.rb +36 -0
  58. data/examples/pairwise/specification.rb +9 -0
  59. data/examples/pairwise/three_boolean/lib/boolean_batch.rb +8 -0
  60. data/examples/pairwise/three_boolean/test/boolean_batch_test.rb +28 -0
  61. data/examples/pairwise/three_boolean_specification.rb +10 -0
  62. data/examples/pairwise_partitions/README.md +34 -0
  63. data/examples/pairwise_partitions/lib/age_policy.rb +8 -0
  64. data/examples/pairwise_partitions/requirements.md +8 -0
  65. data/examples/pairwise_partitions/run.rb +22 -0
  66. data/examples/pairwise_partitions/specification.rb +13 -0
  67. data/examples/pairwise_partitions/test_age_policy.rb +29 -0
  68. data/examples/pairwise_suggestions/README.md +48 -0
  69. data/examples/pairwise_suggestions/run.rb +55 -0
  70. data/examples/pairwise_suggestions/three_boolean/lib/suggested_boolean_batch.rb +8 -0
  71. data/examples/pairwise_suggestions/three_boolean/requirements.md +7 -0
  72. data/examples/pairwise_suggestions/three_boolean/specification.rb +11 -0
  73. data/examples/pairwise_suggestions/three_boolean/test/baseline_test.rb +9 -0
  74. data/examples/pairwise_suggestions/three_boolean/test/complete_test.rb +22 -0
  75. data/examples/partition_gap.rb +8 -0
  76. data/examples/rbs_registration/README.md +58 -0
  77. data/examples/rbs_registration/implementations/age_gt/registration.rb +8 -0
  78. data/examples/rbs_registration/implementations/age_gte/registration.rb +8 -0
  79. data/examples/rbs_registration/requirements.md +9 -0
  80. data/examples/rbs_registration/sig/registration.rbs +3 -0
  81. data/examples/rbs_registration/specification.rb +10 -0
  82. data/examples/rbs_registration/test_registration.rb +43 -0
  83. data/examples/rbs_string_literals/README.md +34 -0
  84. data/examples/rbs_string_literals/lib/delivery_options.rb +9 -0
  85. data/examples/rbs_string_literals/requirements.md +11 -0
  86. data/examples/rbs_string_literals/sig/delivery_options.rbs +3 -0
  87. data/examples/rbs_string_literals/specification.rb +7 -0
  88. data/examples/rbs_string_literals/test_delivery_options.rb +46 -0
  89. data/examples/shipping.rb +12 -0
  90. data/exe/specproof +7 -0
  91. data/lib/specproof/checker.rb +144 -0
  92. data/lib/specproof/cli.rb +292 -0
  93. data/lib/specproof/collector.rb +304 -0
  94. data/lib/specproof/combinations.rb +226 -0
  95. data/lib/specproof/composer.rb +97 -0
  96. data/lib/specproof/coverage.rb +229 -0
  97. data/lib/specproof/coverage_report.rb +334 -0
  98. data/lib/specproof/domains.rb +124 -0
  99. data/lib/specproof/loader.rb +285 -0
  100. data/lib/specproof/minitest.rb +443 -0
  101. data/lib/specproof/model.rb +198 -0
  102. data/lib/specproof/partition_space.rb +268 -0
  103. data/lib/specproof/planner.rb +125 -0
  104. data/lib/specproof/report.rb +248 -0
  105. data/lib/specproof/skills.rb +188 -0
  106. data/lib/specproof/sources/rbs.rb +137 -0
  107. data/lib/specproof/sources/rbs_projector.rb +268 -0
  108. data/lib/specproof/suggestion_report.rb +159 -0
  109. data/lib/specproof/suggestions.rb +352 -0
  110. data/lib/specproof/target_name.rb +100 -0
  111. data/lib/specproof/type_constraints.rb +76 -0
  112. data/lib/specproof/version.rb +7 -0
  113. data/lib/specproof.rb +100 -0
  114. data/sig/specproof.rbs +222 -0
  115. data/skills/specproof-specify/SKILL.md +282 -0
  116. data/skills/specproof-specify/references/usage.md +265 -0
  117. data/skills/specproof-test-gaps/SKILL.md +214 -0
  118. data/skills/specproof-test-gaps/references/usage.md +231 -0
  119. metadata +165 -0
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module SpecProof
6
+ # Renders deterministic machine and human reports for unexecuted suggestions.
7
+ module SuggestionReport
8
+ MAX_REPORT_BYTES = 32 * 1024 * 1024
9
+
10
+ module_function
11
+
12
+ def from_file(specifications:, path:)
13
+ SpecProof.suggest(specifications, report: read_report(path))
14
+ end
15
+
16
+ def findings(specifications:, error:, format:, criterion: Suggestions::CRITERION)
17
+ if format == "json"
18
+ JSON.generate(
19
+ Report.envelope(kind: "suggestions").merge(
20
+ criterion: criterion,
21
+ strategy: strategy_for(criterion),
22
+ findings: error.findings.map { |finding| Report.finding_hash(finding) }
23
+ )
24
+ )
25
+ else
26
+ rows = error.findings.map { |finding| Report.finding(finding) }
27
+ rows << Report.human_scope(specifications)
28
+ rows.join("\n")
29
+ end
30
+ end
31
+
32
+ def render(result, format:)
33
+ format == "json" ? JSON.generate(document(result)) : human(result)
34
+ end
35
+
36
+ def execute(specifications:, path:, format:, stdout:)
37
+ criterion = nil
38
+ report = read_report(path)
39
+ criterion = criterion_from(report)
40
+ result = SpecProof.suggest(specifications, report: report)
41
+ stdout.puts(render(result, format: format))
42
+ 0
43
+ rescue InvalidSpecificationError => e
44
+ stdout.puts(findings(specifications: specifications, error: e, format: format,
45
+ criterion: criterion || Suggestions::CRITERION))
46
+ 1
47
+ end
48
+
49
+ def parse_report_option(args:, options:, kind:)
50
+ value = args.shift
51
+ if value.nil? || value.empty? || value.start_with?("-")
52
+ args.unshift(value) if value
53
+ return { code: "missing_option_value", message: "--report requires a value" }
54
+ end
55
+ return { code: "unsupported_option", message: "--report is available only for suggest" } unless kind == "suggest"
56
+ unless options.fetch(:report_paths).empty?
57
+ return { code: "duplicate_coverage_report", message: "specify --report only once" }
58
+ end
59
+
60
+ options.fetch(:report_paths) << value
61
+ nil
62
+ end
63
+
64
+ def document(result)
65
+ covered_ids = result.suggestions.flat_map(&:covers).uniq
66
+ Report.envelope(kind: "suggestions").merge(
67
+ criterion: result.criterion,
68
+ strategy: result.strategy,
69
+ assignment_scope: "combination_group",
70
+ minimality: "not_guaranteed",
71
+ contract_digest: result.contract_digest,
72
+ source_run_id: result.source_run_id,
73
+ source_tests_passed: result.source_tests_passed,
74
+ source_scope: result.source_scope,
75
+ missing_obligation_count: result.missing_count,
76
+ covered_obligation_count: covered_ids.length,
77
+ suggestion_count: result.suggestions.length,
78
+ suggestions: result.suggestions.map { |suggestion| suggestion_hash(suggestion) }
79
+ )
80
+ end
81
+
82
+ def human(result)
83
+ lines = ["Source run: #{result.source_run_id}",
84
+ "Source tests: #{result.source_tests_passed ? "passed" : "failed"}",
85
+ "Criterion: #{result.criterion}; strategy: #{result.strategy}",
86
+ "Source arguments: #{result.source_scope.fetch("arguments").inspect}",
87
+ "Source scope: #{human_scope(result.source_scope)}",
88
+ "Missing pair obligations: #{result.missing_count}",
89
+ "Suggestions: #{result.suggestions.length} (minimum not guaranteed)"]
90
+ result.suggestions.each_with_index do |suggestion, index|
91
+ assignments = suggestion.inputs.map do |name, value|
92
+ "#{name}=#{Report.human_value(value)}"
93
+ end.join(", ")
94
+ lines << "#{index + 1}. #{suggestion.target}: #{assignments}"
95
+ lines << " Covers: #{suggestion.covers.join(", ")}"
96
+ lines << " Unassigned inputs: #{suggestion.unassigned_inputs.join(", ")}" if suggestion.unassigned_inputs.any?
97
+ end
98
+ lines << "These suggestions have not been executed."
99
+ lines << "No expected results are provided. Supply unassigned inputs before calling the target."
100
+ lines.join("\n")
101
+ end
102
+
103
+ def suggestion_hash(suggestion)
104
+ {
105
+ id: suggestion.id,
106
+ target: suggestion.target,
107
+ inputs: suggestion.inputs.to_h { |name, value| [name.to_s, Report.value(value)] },
108
+ covers: suggestion.covers,
109
+ unassigned_inputs: suggestion.unassigned_inputs.map(&:to_s),
110
+ sources: suggestion.sources.map { |source| Report.source(source) }
111
+ }
112
+ end
113
+ private_class_method :suggestion_hash
114
+
115
+ def human_scope(scope)
116
+ pieces = []
117
+ filter = scope["filter"]
118
+ exclude = scope["exclude"]
119
+ pieces << "filter #{filter.inspect}" if filter
120
+ pieces << "exclude #{exclude.inspect}" if exclude
121
+ input_scope = scope["input_scope"] || []
122
+ input_scope.each do |item|
123
+ group = item["combination_group"]
124
+ next unless group
125
+
126
+ pieces << "#{item.fetch("target")} combinations #{group.fetch("inputs").join(", ")}"
127
+ end
128
+ pieces.empty? ? "all recorded targets" : pieces.join("; ")
129
+ end
130
+ private_class_method :human_scope
131
+
132
+ def read_report(path)
133
+ content = File.open(path, "rb") { |file| file.read(MAX_REPORT_BYTES + 1) } || ""
134
+ if content.bytesize > MAX_REPORT_BYTES
135
+ raise ConfigurationError.new("coverage report exceeds the 32 MiB limit",
136
+ code: "suggestion_limit_exceeded", path: path)
137
+ end
138
+
139
+ JSON.parse(content)
140
+ rescue JSON::ParserError => e
141
+ raise ConfigurationError.new("coverage report is not valid JSON: #{e.message}",
142
+ code: "invalid_coverage_report", path: path)
143
+ end
144
+ private_class_method :read_report
145
+
146
+ def criterion_from(report)
147
+ return Suggestions::PARTITION_CRITERION if report.is_a?(Hash) &&
148
+ report["criterion"] == Suggestions::PARTITION_CRITERION
149
+
150
+ Suggestions::CRITERION
151
+ end
152
+ private_class_method :criterion_from
153
+
154
+ def strategy_for(criterion)
155
+ criterion == Suggestions::PARTITION_CRITERION ? Suggestions::PARTITION_STRATEGY : Suggestions::STRATEGY
156
+ end
157
+ private_class_method :strategy_for
158
+ end
159
+ end
@@ -0,0 +1,352 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module SpecProof
7
+ # One allowed, unexecuted assignment selected to cover missing pairs.
8
+ class Suggestion
9
+ attr_reader :id, :target, :inputs, :covers, :unassigned_inputs, :sources
10
+
11
+ def initialize(id:, target:, inputs:, covers:, unassigned_inputs:, sources:) # rubocop:disable Metrics/ParameterLists
12
+ @id = id.to_s.dup.freeze
13
+ @target = target.to_s.dup.freeze
14
+ @inputs = immutable_hash(inputs)
15
+ @covers = covers.map { |item| item.to_s.dup.freeze }.sort.freeze
16
+ @unassigned_inputs = unassigned_inputs.map(&:to_sym).sort_by(&:to_s).freeze
17
+ @sources = sources.uniq.freeze
18
+ freeze
19
+ end
20
+
21
+ private
22
+
23
+ def immutable_hash(hash)
24
+ hash.sort_by { |name, _value| name.to_s }.to_h do |name, value|
25
+ [name.to_sym, value.is_a?(String) ? Domains.canonical_string(value) : value]
26
+ end.freeze
27
+ end
28
+ end
29
+
30
+ # The suggestions and the exact evidence snapshot they were derived from.
31
+ class SuggestionResult
32
+ LEGACY_CRITERION = "pairwise-finite-v1"
33
+ LEGACY_STRATEGY = "greedy-pairwise-v1"
34
+
35
+ attr_reader :suggestions, :contract_digest, :source_run_id, :source_tests_passed, :source_scope, :missing_count,
36
+ :criterion, :strategy
37
+
38
+ def initialize(suggestions:, contract_digest:, source_run_id:, source_tests_passed:, source_scope:,
39
+ missing_count:, criterion: LEGACY_CRITERION, strategy: LEGACY_STRATEGY) # rubocop:disable Metrics/ParameterLists
40
+ @suggestions = suggestions.dup.freeze
41
+ @contract_digest = contract_digest.to_s.dup.freeze
42
+ @source_run_id = source_run_id.to_s.dup.freeze
43
+ @source_tests_passed = source_tests_passed
44
+ @source_scope = immutable_value(source_scope)
45
+ @missing_count = Integer(missing_count)
46
+ @criterion = criterion.to_s.dup.freeze
47
+ @strategy = strategy.to_s.dup.freeze
48
+ freeze
49
+ end
50
+
51
+ private
52
+
53
+ def immutable_value(value)
54
+ case value
55
+ when Hash
56
+ value.to_h { |key, item| [immutable_value(key), immutable_value(item)] }.freeze
57
+ when Array then value.map { |item| immutable_value(item) }.freeze
58
+ when String then value.dup.freeze
59
+ else value
60
+ end
61
+ end
62
+ end
63
+
64
+ # Builds a deterministic set of allowed group assignments for missing pairs.
65
+ class Suggestions
66
+ CRITERION = "pairwise-finite-v1"
67
+ STRATEGY = "greedy-pairwise-v1"
68
+ PARTITION_CRITERION = "pairwise-partitions-v1"
69
+ PARTITION_STRATEGY = "greedy-partition-pairwise-v1"
70
+ STRATEGIES = { CRITERION => STRATEGY, PARTITION_CRITERION => PARTITION_STRATEGY }.freeze
71
+ MAX_ASSIGNMENTS = 10_000
72
+ MAX_PAIR_ROWS = 10_000
73
+ MAX_MISSING_MEMBERSHIPS = 1_000_000
74
+
75
+ Candidate = Struct.new(:target, :assignment, :sources, :unassigned_inputs, :missing_ids, keyword_init: true)
76
+ private_constant :Candidate
77
+
78
+ def initialize(specifications, report:)
79
+ @specifications = specifications
80
+ @report = report
81
+ end
82
+
83
+ def result
84
+ criterion = criterion_for_report
85
+ preflight_limits(criterion)
86
+ findings = Checker.new(@specifications, criterion: criterion).findings
87
+ raise InvalidSpecificationError, findings unless findings.empty?
88
+
89
+ obligations = Planner.new(@specifications, criterion: criterion).obligations
90
+ evidence = CoverageReport.new(@report, specifications: @specifications, obligations: obligations,
91
+ criterion: criterion)
92
+ missing_ids = evidence.missing_ids
93
+ suggestions = missing_ids.empty? ? [] : select_suggestions(obligations, missing_ids, criterion)
94
+
95
+ SuggestionResult.new(suggestions: suggestions, contract_digest: evidence.contract_digest,
96
+ source_run_id: evidence.run_id, source_tests_passed: evidence.tests_passed,
97
+ source_scope: evidence.scope_snapshot, missing_count: missing_ids.length,
98
+ criterion: criterion, strategy: STRATEGIES.fetch(criterion))
99
+ end
100
+
101
+ private
102
+
103
+ def criterion_for_report
104
+ criterion = @report.is_a?(Hash) && @report["criterion"]
105
+ return CRITERION if criterion.nil? || criterion == false
106
+ return criterion if [CRITERION, PARTITION_CRITERION].include?(criterion)
107
+
108
+ raise ConfigurationError.new("coverage report criterion is unsupported", code: "unsupported_coverage_report")
109
+ end
110
+
111
+ def preflight_limits(criterion)
112
+ assignment_total = 0
113
+ pair_total = 0
114
+
115
+ groups = @specifications.sort_by(&:target).map { |specification| preflight_group(specification, criterion) }
116
+ groups.each do |group, target_assignments, target_pairs|
117
+ assignment_total += target_assignments
118
+ pair_total += target_pairs
119
+ check_cumulative_limits!(assignment_total, pair_total, group)
120
+ end
121
+ end
122
+
123
+ def preflight_group(specification, criterion)
124
+ group = specification.combination_group
125
+ unless group
126
+ raise ConfigurationError.new("target has no combinations declaration: #{specification.target}",
127
+ code: "combinations_required", source: specification.source)
128
+ end
129
+ if group.inputs.length < 2
130
+ raise ConfigurationError.new("a combinations group requires at least two inputs",
131
+ code: "invalid_combination_inputs", source: group.source)
132
+ end
133
+ sizes = group.inputs.map { |name| preflight_domain_size(specification, group, name, criterion) }
134
+ assignments = preflight_assignment_count(sizes, group)
135
+ pair_rows = preflight_pair_row_count(sizes, group)
136
+ [group, assignments, pair_rows]
137
+ end
138
+
139
+ def preflight_assignment_count(sizes, group)
140
+ return 0 if sizes.any?(&:zero?)
141
+
142
+ sizes.reduce(1) do |product, size|
143
+ next_product = product * size
144
+ if next_product > CombinationSpace::MAX_ASSIGNMENTS
145
+ raise ConfigurationError.new("combinations declaration has more than " \
146
+ "#{CombinationSpace::MAX_ASSIGNMENTS} complete assignments; " \
147
+ "limit is #{CombinationSpace::MAX_ASSIGNMENTS}",
148
+ code: "combination_limit_exceeded", source: group.source)
149
+ end
150
+ next_product
151
+ end
152
+ end
153
+
154
+ def preflight_pair_row_count(sizes, group)
155
+ pair_rows = 0
156
+ preceding_size_total = 0
157
+ sizes.each do |size|
158
+ pair_rows += preceding_size_total * size
159
+ if pair_rows > CombinationSpace::MAX_PAIR_ROWS
160
+ raise ConfigurationError.new("combinations declaration has more than " \
161
+ "#{CombinationSpace::MAX_PAIR_ROWS} possible pair rows; " \
162
+ "limit is #{CombinationSpace::MAX_PAIR_ROWS}",
163
+ code: "combination_limit_exceeded", source: group.source)
164
+ end
165
+ preceding_size_total += size
166
+ end
167
+ pair_rows
168
+ end
169
+
170
+ def preflight_domain_size(specification, group, name, criterion)
171
+ if specification.exclusions.key?(name)
172
+ raise ConfigurationError.new("excluded input #{name.inspect} cannot be in a combinations group",
173
+ code: "excluded_combination_input", source: group.source)
174
+ end
175
+ input = specification.inputs[name]
176
+ unless input
177
+ raise ConfigurationError.new("unknown combinations input #{name.inspect}",
178
+ code: "unknown_combination_input", source: group.source)
179
+ end
180
+ if criterion == PARTITION_CRITERION
181
+ if input.domain.is_a?(IntegerDomain)
182
+ unless input.partitions.any? && input.exhaustive && input.disjoint
183
+ raise ConfigurationError.new("integer inputs require allow_gaps: false and allow_overlaps: false",
184
+ code: "partition_policy_required", source: input.source)
185
+ end
186
+ return input.partitions.length
187
+ end
188
+ unless input.domain.is_a?(FiniteDomain) && input.partitions.empty?
189
+ raise ConfigurationError.new("partition combinations inputs must be integer partitions or " \
190
+ "unpartitioned finite values",
191
+ code: "partition_domain_required", source: input.source)
192
+ end
193
+ return input.domain.values.length
194
+ end
195
+ unless input.domain.is_a?(FiniteDomain)
196
+ raise ConfigurationError.new("combinations input #{name.inspect} must have finite values",
197
+ code: "finite_combinations_required", source: input.source)
198
+ end
199
+ input.domain.values.length
200
+ end
201
+
202
+ def check_cumulative_limits!(assignments, pair_rows, group)
203
+ return unless assignments > MAX_ASSIGNMENTS || pair_rows > MAX_PAIR_ROWS
204
+
205
+ raise ConfigurationError.new("pairwise suggestions exceed the cumulative combination space limit",
206
+ code: "suggestion_limit_exceeded", source: group.source)
207
+ end
208
+
209
+ def select_suggestions(obligations, missing_ids, criterion)
210
+ missing = missing_ids.to_h { |id| [id, true] }
211
+ specifications = @specifications.sort_by(&:target)
212
+ spaces = specifications.map { |specification| [specification, space_for(specification, criterion)] }
213
+ missing_pair_ids = missing_pair_id_index(obligations, missing)
214
+ candidates = []
215
+ membership_total = 0
216
+
217
+ # Keep the count pass separate so oversized membership lists are rejected before allocation.
218
+ spaces.each do |specification, space|
219
+ space.allowed_assignments.each do |assignment|
220
+ memberships = missing_pair_count(specification.target, assignment, missing_pair_ids)
221
+ membership_total += memberships
222
+ if membership_total > MAX_MISSING_MEMBERSHIPS
223
+ raise ConfigurationError.new("pairwise suggestions exceed the candidate-to-missing-pair limit",
224
+ code: "suggestion_limit_exceeded")
225
+ end
226
+ end
227
+ end
228
+
229
+ spaces.each do |specification, space| # rubocop:disable Style/CombinableLoops
230
+ group_names = specification.combination_group.inputs
231
+ unassigned = (specification.inputs.keys - group_names + specification.exclusions.keys).uniq
232
+ unassigned.sort_by!(&:to_s)
233
+ space.allowed_assignments.each do |assignment|
234
+ candidates << Candidate.new(target: specification.target, assignment: assignment,
235
+ sources: space.sources, unassigned_inputs: unassigned,
236
+ missing_ids: missing_ids_for(specification.target, assignment, missing_pair_ids))
237
+ end
238
+ end
239
+
240
+ build_greedy_result(candidates, obligations, missing_ids, criterion)
241
+ end
242
+
243
+ def space_for(specification, criterion)
244
+ return PartitionSpace.new(specification) if criterion == PARTITION_CRITERION
245
+
246
+ CombinationSpace.new(specification)
247
+ end
248
+
249
+ def missing_pair_id_index(obligations, missing)
250
+ obligations.each_with_object({}) do |obligation, index|
251
+ next unless missing.key?(obligation.id)
252
+
253
+ index[pair_index_key(obligation.target, obligation.inputs)] = obligation.id
254
+ end.freeze
255
+ end
256
+
257
+ def pair_index_key(target, inputs)
258
+ [target, *inputs.sort_by { |name, _value| name.to_s }.flat_map { |name, value| [name, value] }].freeze
259
+ end
260
+
261
+ def missing_pair_count(target, assignment, missing_pair_ids)
262
+ count = 0
263
+ each_input_pair(assignment.keys) do |left, right|
264
+ pair = [[left, assignment.fetch(left)], [right, assignment.fetch(right)]]
265
+ count += 1 if missing_pair_ids.key?(pair_index_key(target, pair))
266
+ end
267
+ count
268
+ end
269
+
270
+ def missing_ids_for(target, assignment, missing_pair_ids)
271
+ ids = []
272
+ each_input_pair(assignment.keys) do |left, right|
273
+ pair = [[left, assignment.fetch(left)], [right, assignment.fetch(right)]]
274
+ id = missing_pair_ids[pair_index_key(target, pair)]
275
+ ids << id if id
276
+ end
277
+ ids.sort.freeze
278
+ end
279
+
280
+ def build_greedy_result(candidates, obligations, missing_ids, criterion)
281
+ missing_lookup = missing_ids.to_h { |id| [id, true] }
282
+ missing_by_target = obligations.select { |obligation| missing_lookup.key?(obligation.id) }
283
+ .group_by(&:target)
284
+ .transform_values { |items| items.map(&:id) }
285
+ candidates.group_by(&:target).sort_by(&:first).flat_map do |target, target_candidates|
286
+ greedy_target(target_candidates, missing_by_target.fetch(target, []), criterion)
287
+ end.freeze
288
+ end
289
+
290
+ def greedy_target(candidates, missing_ids, criterion)
291
+ candidates_by_missing = Hash.new { |hash, id| hash[id] = [] }
292
+ scores = candidates.map.with_index do |candidate, index|
293
+ candidate.missing_ids.each { |id| candidates_by_missing[id] << index }
294
+ candidate.missing_ids.length
295
+ end
296
+ uncovered = missing_ids.to_h { |id| [id, true] }
297
+ selected = []
298
+
299
+ until uncovered.empty?
300
+ best_index = nil
301
+ best_score = 0
302
+ scores.each_with_index do |score, index|
303
+ next unless score > best_score
304
+
305
+ best_index = index
306
+ best_score = score
307
+ end
308
+ break unless best_index
309
+
310
+ candidate = candidates.fetch(best_index)
311
+ newly_covered = candidate.missing_ids.select { |id| uncovered.key?(id) }.sort
312
+ selected << Suggestion.new(
313
+ id: suggestion_id(candidate.target, candidate.assignment, criterion),
314
+ target: candidate.target,
315
+ inputs: native_assignment(candidate.assignment),
316
+ covers: newly_covered,
317
+ unassigned_inputs: candidate.unassigned_inputs,
318
+ sources: candidate.sources
319
+ )
320
+ newly_covered.each do |id|
321
+ uncovered.delete(id)
322
+ candidates_by_missing.fetch(id, []).each { |index| scores[index] -= 1 }
323
+ end
324
+ end
325
+
326
+ unless uncovered.empty?
327
+ raise ConfigurationError.new("missing pair obligations have no allowed suggestion assignment",
328
+ code: "coverage_contract_mismatch")
329
+ end
330
+
331
+ selected
332
+ end
333
+
334
+ def each_input_pair(names)
335
+ names.each_with_index do |left, index|
336
+ names.drop(index + 1).each { |right| yield left, right }
337
+ end
338
+ end
339
+
340
+ def native_assignment(assignment)
341
+ assignment.to_h do |name, value|
342
+ [name, value.is_a?(PartitionLevel) ? value.representative : value]
343
+ end
344
+ end
345
+
346
+ def suggestion_id(target, assignment, criterion)
347
+ encoded = assignment.sort_by { |name, _value| name.to_s }
348
+ .map { |name, value| [name.to_s, Report.value(value)] }
349
+ Digest::SHA256.hexdigest(JSON.generate([target, encoded, criterion, STRATEGIES.fetch(criterion)]))
350
+ end
351
+ end
352
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecProof
4
+ # Converts supported Ruby method references into stable target strings.
5
+ module TargetName
6
+ OWNER_PATTERN = /\A[A-Z]\w*(?:::[A-Z]\w*)*\z/
7
+ METHOD_PATTERN = /\A[a-z_]\w*[!?=]?\z/
8
+ TARGET_PATTERN = /\A([A-Z]\w*(?:::[A-Z]\w*)*)(#|\.)([a-z_]\w*[!?=]?)\z/
9
+
10
+ module_function
11
+
12
+ def normalize(target, source: nil)
13
+ if target.is_a?(String)
14
+ begin
15
+ parse(target)
16
+ rescue ConfigurationError => e
17
+ raise ConfigurationError.new(e.message, code: e.code, source: source)
18
+ end
19
+ return target
20
+ end
21
+ return normalize_method(target, source) if target.is_a?(Method) || target.is_a?(UnboundMethod)
22
+
23
+ invalid_target!("target must be a String, Method, or UnboundMethod", source)
24
+ end
25
+
26
+ def parse(string)
27
+ match = TARGET_PATTERN.match(string)
28
+ unless match
29
+ raise ConfigurationError.new("target must use Owner#method or Owner.method syntax", code: "invalid_target")
30
+ end
31
+
32
+ [match[1], match[3].to_sym, match[2] == "#" ? :instance : :singleton]
33
+ end
34
+
35
+ def normalize_method(method, source)
36
+ kind = method.is_a?(Method) ? :singleton : :instance
37
+ owner = method_owner(method, kind, source)
38
+ unless owner.is_a?(Class) || (kind == :singleton && owner.is_a?(Module))
39
+ invalid_target!("instance methods must be defined on a named class", source)
40
+ end
41
+
42
+ owner_path = owner.name
43
+ validate_named_owner!(owner, owner_path, source)
44
+ validate_definition!(owner, method, kind, source)
45
+ "#{owner_path}#{kind == :instance ? "#" : "."}#{method.name}"
46
+ rescue NameError
47
+ invalid_target!("method owner must be bound to its named constant", source)
48
+ end
49
+
50
+ def method_owner(method, kind, source)
51
+ return method.owner unless kind == :singleton
52
+
53
+ receiver = method.receiver
54
+ unless receiver.is_a?(Module) && method.owner.equal?(receiver.singleton_class)
55
+ invalid_target!("bound methods must belong to a named class or module", source)
56
+ end
57
+ receiver
58
+ end
59
+ private_class_method :method_owner
60
+
61
+ def validate_named_owner!(owner, owner_path, source)
62
+ return if owner_path && OWNER_PATTERN.match?(owner_path) && resolve_owner(owner_path).equal?(owner)
63
+
64
+ invalid_target!("method owner must be bound to its named constant", source)
65
+ end
66
+ private_class_method :validate_named_owner!
67
+
68
+ def validate_definition!(owner, method, kind, source)
69
+ name = method.name
70
+ unless METHOD_PATTERN.match?(name.to_s) && method.original_name == name
71
+ invalid_target!("target method must be a directly defined method, not an alias", source)
72
+ end
73
+ invalid_target!("target method must be implemented in Ruby source", source) unless method.source_location
74
+
75
+ public_methods = kind == :singleton ? owner.public_methods(false) : owner.public_instance_methods(false)
76
+ invalid_target!("target method must be public and directly defined", source) unless public_methods.include?(name)
77
+
78
+ current = kind == :singleton ? owner.method(name) : owner.instance_method(name)
79
+ return if current == method
80
+
81
+ invalid_target!("target method object is stale or no longer directly defined", source)
82
+ end
83
+ private_class_method :validate_definition!
84
+
85
+ def resolve_owner(path)
86
+ path.split("::").reduce(Object) do |namespace, name|
87
+ return nil unless namespace.is_a?(Module)
88
+ return nil unless namespace.const_defined?(name, false) && !namespace.autoload?(name)
89
+
90
+ namespace.const_get(name, false)
91
+ end
92
+ end
93
+ private_class_method :resolve_owner
94
+
95
+ def invalid_target!(message, source)
96
+ raise ConfigurationError.new(message, code: "invalid_target", source: source)
97
+ end
98
+ private_class_method :invalid_target!
99
+ end
100
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecProof
4
+ ScalarTypeConstraint = Struct.new(:integer_any, :values, keyword_init: true) do # rubocop:disable Lint/StructNewOverride
5
+ def initialize(integer_any:, values:)
6
+ unless integer_any.equal?(true) || integer_any.equal?(false)
7
+ raise ArgumentError, "integer_any must be true or false"
8
+ end
9
+ unless values.is_a?(Array) && values.all? { |value| Domains.primitive?(value) }
10
+ raise ArgumentError, "scalar type values must be integers, symbols, strings, booleans, or nil"
11
+ end
12
+
13
+ normalized = Domains.from_values(values).values
14
+ normalized = normalized.grep_v(Integer) if integer_any
15
+ super(integer_any: integer_any, values: normalized.freeze)
16
+ freeze
17
+ end
18
+ end
19
+
20
+ # Operations over the deliberately closed set of scalar type constraints.
21
+ module TypeConstraints
22
+ NO_EXCLUSION = Object.new.freeze
23
+
24
+ module_function
25
+
26
+ def include?(constraint, value)
27
+ return true if constraint.integer_any && value.is_a?(Integer)
28
+ return false unless Domains.primitive?(value)
29
+
30
+ value = Domains.canonical_string(value) if Domains.supported_string?(value)
31
+ constraint.values.any? { |member| member.instance_of?(value.class) && member.eql?(value) }
32
+ end
33
+
34
+ def finite_domain(constraint)
35
+ return nil if constraint.integer_any
36
+
37
+ FiniteDomain.new(values: constraint.values)
38
+ end
39
+
40
+ def first_excluded(constraint, domain)
41
+ return NO_EXCLUSION if Domains.empty?(domain)
42
+
43
+ if domain.is_a?(FiniteDomain)
44
+ domain_members = domain.values
45
+ domain_members.each { |value| return value unless include?(constraint, value) }
46
+ NO_EXCLUSION
47
+ elsif domain.is_a?(IntegerDomain)
48
+ return NO_EXCLUSION if constraint.integer_any
49
+
50
+ first_excluded_integer(constraint, domain)
51
+ else
52
+ NO_EXCLUSION
53
+ end
54
+ end
55
+
56
+ def union(constraints)
57
+ constraints = constraints.to_a
58
+ ScalarTypeConstraint.new(integer_any: constraints.any?(&:integer_any),
59
+ values: constraints.flat_map(&:values))
60
+ end
61
+
62
+ def first_excluded_integer(constraint, domain)
63
+ cursor = domain.lower
64
+ declared_values = constraint.values
65
+ declared_values.each do |value|
66
+ next unless value.is_a?(Integer) && value >= cursor
67
+ return cursor if value > cursor
68
+
69
+ cursor += 1
70
+ return NO_EXCLUSION if cursor > domain.upper
71
+ end
72
+ cursor <= domain.upper ? cursor : NO_EXCLUSION
73
+ end
74
+ private_class_method :first_excluded_integer
75
+ end
76
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecProof
4
+ VERSION = "0.1.0"
5
+ end
6
+
7
+ Specproof = SpecProof unless Object.const_defined?(:Specproof, false)