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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +283 -0
- data/docs/agent-skills.md +60 -0
- data/docs/benchmark.md +24 -0
- data/docs/constrained-partition-pairs-results.md +44 -0
- data/docs/finite-strings-results.md +75 -0
- data/docs/implementation-status.md +65 -0
- data/docs/input-selection-results.md +123 -0
- data/docs/lint-cleanup-plan.md +12 -0
- data/docs/method-targets-results.md +68 -0
- data/docs/minitest-compatibility.md +33 -0
- data/docs/optional-arguments-plan.md +25 -0
- data/docs/optional-arguments-results.md +45 -0
- data/docs/pairwise-partitions-results.md +53 -0
- data/docs/pairwise-results.md +77 -0
- data/docs/pairwise-suggestions-results.md +104 -0
- data/docs/rbs-implementation-status.md +115 -0
- data/docs/rbs-string-literals-results.md +58 -0
- data/docs/real-project-pilot.md +84 -0
- data/docs/report-format.md +244 -0
- data/examples/age_policy/README.md +39 -0
- data/examples/age_policy/faulty/lib/age_policy.rb +9 -0
- data/examples/age_policy/faulty/test/age_policy_examples_test.rb +17 -0
- data/examples/age_policy/faulty/test/age_policy_test.rb +21 -0
- data/examples/age_policy/lib/age_policy.rb +8 -0
- data/examples/age_policy/run.rb +23 -0
- data/examples/age_policy/specification.rb +8 -0
- data/examples/age_policy/test/age_policy_test.rb +18 -0
- data/examples/constrained_partition_pairs/README.md +40 -0
- data/examples/constrained_partition_pairs/lib/account_eligibility.rb +8 -0
- data/examples/constrained_partition_pairs/requirements.md +11 -0
- data/examples/constrained_partition_pairs/run.rb +22 -0
- data/examples/constrained_partition_pairs/specification.rb +17 -0
- data/examples/constrained_partition_pairs/test_account_eligibility.rb +34 -0
- data/examples/input_selection/README.md +22 -0
- data/examples/input_selection/delivery_policy.rb +11 -0
- data/examples/input_selection/requirements.md +9 -0
- data/examples/input_selection/specification.rb +8 -0
- data/examples/input_selection/test_delivery_policy.rb +38 -0
- data/examples/method_targets/README.md +30 -0
- data/examples/method_targets/lib/delivery_defaults.rb +8 -0
- data/examples/method_targets/lib/delivery_options.rb +12 -0
- data/examples/method_targets/requirements.md +20 -0
- data/examples/method_targets/specification_objects.rb +17 -0
- data/examples/method_targets/specification_strings.rb +15 -0
- data/examples/pairwise/README.md +46 -0
- data/examples/pairwise/constrained_requirements.md +6 -0
- data/examples/pairwise/constrained_specification.rb +11 -0
- data/examples/pairwise/corrected/lib/delivery_options.rb +11 -0
- data/examples/pairwise/corrected/test/delivery_options_test.rb +28 -0
- data/examples/pairwise/faulty/lib/delivery_options.rb +11 -0
- data/examples/pairwise/faulty/test/gap_delivery_options_test.rb +10 -0
- data/examples/pairwise/faulty/test/initial_delivery_options_test.rb +23 -0
- data/examples/pairwise/requirements.md +11 -0
- data/examples/pairwise/run.rb +36 -0
- data/examples/pairwise/specification.rb +9 -0
- data/examples/pairwise/three_boolean/lib/boolean_batch.rb +8 -0
- data/examples/pairwise/three_boolean/test/boolean_batch_test.rb +28 -0
- data/examples/pairwise/three_boolean_specification.rb +10 -0
- data/examples/pairwise_partitions/README.md +34 -0
- data/examples/pairwise_partitions/lib/age_policy.rb +8 -0
- data/examples/pairwise_partitions/requirements.md +8 -0
- data/examples/pairwise_partitions/run.rb +22 -0
- data/examples/pairwise_partitions/specification.rb +13 -0
- data/examples/pairwise_partitions/test_age_policy.rb +29 -0
- data/examples/pairwise_suggestions/README.md +48 -0
- data/examples/pairwise_suggestions/run.rb +55 -0
- data/examples/pairwise_suggestions/three_boolean/lib/suggested_boolean_batch.rb +8 -0
- data/examples/pairwise_suggestions/three_boolean/requirements.md +7 -0
- data/examples/pairwise_suggestions/three_boolean/specification.rb +11 -0
- data/examples/pairwise_suggestions/three_boolean/test/baseline_test.rb +9 -0
- data/examples/pairwise_suggestions/three_boolean/test/complete_test.rb +22 -0
- data/examples/partition_gap.rb +8 -0
- data/examples/rbs_registration/README.md +58 -0
- data/examples/rbs_registration/implementations/age_gt/registration.rb +8 -0
- data/examples/rbs_registration/implementations/age_gte/registration.rb +8 -0
- data/examples/rbs_registration/requirements.md +9 -0
- data/examples/rbs_registration/sig/registration.rbs +3 -0
- data/examples/rbs_registration/specification.rb +10 -0
- data/examples/rbs_registration/test_registration.rb +43 -0
- data/examples/rbs_string_literals/README.md +34 -0
- data/examples/rbs_string_literals/lib/delivery_options.rb +9 -0
- data/examples/rbs_string_literals/requirements.md +11 -0
- data/examples/rbs_string_literals/sig/delivery_options.rbs +3 -0
- data/examples/rbs_string_literals/specification.rb +7 -0
- data/examples/rbs_string_literals/test_delivery_options.rb +46 -0
- data/examples/shipping.rb +12 -0
- data/exe/specproof +7 -0
- data/lib/specproof/checker.rb +144 -0
- data/lib/specproof/cli.rb +292 -0
- data/lib/specproof/collector.rb +304 -0
- data/lib/specproof/combinations.rb +226 -0
- data/lib/specproof/composer.rb +97 -0
- data/lib/specproof/coverage.rb +229 -0
- data/lib/specproof/coverage_report.rb +334 -0
- data/lib/specproof/domains.rb +124 -0
- data/lib/specproof/loader.rb +285 -0
- data/lib/specproof/minitest.rb +443 -0
- data/lib/specproof/model.rb +198 -0
- data/lib/specproof/partition_space.rb +268 -0
- data/lib/specproof/planner.rb +125 -0
- data/lib/specproof/report.rb +248 -0
- data/lib/specproof/skills.rb +188 -0
- data/lib/specproof/sources/rbs.rb +137 -0
- data/lib/specproof/sources/rbs_projector.rb +268 -0
- data/lib/specproof/suggestion_report.rb +159 -0
- data/lib/specproof/suggestions.rb +352 -0
- data/lib/specproof/target_name.rb +100 -0
- data/lib/specproof/type_constraints.rb +76 -0
- data/lib/specproof/version.rb +7 -0
- data/lib/specproof.rb +100 -0
- data/sig/specproof.rbs +222 -0
- data/skills/specproof-specify/SKILL.md +282 -0
- data/skills/specproof-specify/references/usage.md +265 -0
- data/skills/specproof-test-gaps/SKILL.md +214 -0
- data/skills/specproof-test-gaps/references/usage.md +231 -0
- 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
|