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,443 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "tempfile"
|
|
7
|
+
require "minitest"
|
|
8
|
+
require "specproof"
|
|
9
|
+
require_relative "coverage"
|
|
10
|
+
require_relative "collector"
|
|
11
|
+
|
|
12
|
+
# Records Minitest run coverage against the declared input obligations.
|
|
13
|
+
module SpecProof
|
|
14
|
+
# Integrates contract coverage with a Minitest run lifecycle.
|
|
15
|
+
module Minitest
|
|
16
|
+
MIN_SUPPORTED_MINITEST_VERSION = Gem::Version.new("5.26.2")
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
def install!(specifications:, rbs: [], targets: [], output: "tmp/specproof.json", fail_on_missing: false,
|
|
20
|
+
criterion: "input-basics-v1") # rubocop:disable Metrics/ParameterLists
|
|
21
|
+
ensure_supported_version!
|
|
22
|
+
ensure_not_installed!
|
|
23
|
+
@options = normalized_options(specifications: specifications, rbs: rbs, targets: targets,
|
|
24
|
+
output: output, fail_on_missing: fail_on_missing, criterion: criterion)
|
|
25
|
+
@installed = true
|
|
26
|
+
::Minitest.singleton_class.prepend(RunWrapper)
|
|
27
|
+
self
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def ensure_not_installed!
|
|
33
|
+
return unless @installed
|
|
34
|
+
|
|
35
|
+
raise ConfigurationError.new("Minitest adapter is already installed", code: "duplicate_installation")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def normalized_options(options)
|
|
39
|
+
specifications = options.fetch(:specifications)
|
|
40
|
+
rbs = options.fetch(:rbs)
|
|
41
|
+
targets = options.fetch(:targets)
|
|
42
|
+
output = options.fetch(:output)
|
|
43
|
+
fail_on_missing = options.fetch(:fail_on_missing)
|
|
44
|
+
criterion = options.fetch(:criterion)
|
|
45
|
+
validate_path_options(specifications, rbs, targets)
|
|
46
|
+
validate_output(output)
|
|
47
|
+
validate_criterion(criterion)
|
|
48
|
+
{
|
|
49
|
+
specifications: specifications.map { |path| File.expand_path(path).freeze }.freeze,
|
|
50
|
+
rbs: rbs.map { |path| File.expand_path(path).freeze }.freeze,
|
|
51
|
+
targets: targets.map { |target| target.dup.freeze }.freeze,
|
|
52
|
+
output: File.expand_path(output).freeze,
|
|
53
|
+
criterion: criterion.dup.freeze,
|
|
54
|
+
# Normalize caller truthiness into the boolean report option.
|
|
55
|
+
fail_on_missing: !!fail_on_missing
|
|
56
|
+
}.freeze
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def validate_path_options(specifications, rbs, targets)
|
|
60
|
+
unless specifications.is_a?(Array) && specifications.all?(String)
|
|
61
|
+
raise ConfigurationError.new("specifications must be an array of paths", code: "invalid_specifications")
|
|
62
|
+
end
|
|
63
|
+
unless rbs.is_a?(Array) && rbs.all?(String)
|
|
64
|
+
raise ConfigurationError.new("rbs must be an array of paths", code: "invalid_rbs")
|
|
65
|
+
end
|
|
66
|
+
unless targets.is_a?(Array) && targets.all?(String)
|
|
67
|
+
raise ConfigurationError.new("targets must be an array of target names", code: "invalid_targets")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
if rbs.empty? && targets.empty?
|
|
71
|
+
unless specifications.any?
|
|
72
|
+
raise ConfigurationError.new("specifications must be a non-empty array of paths",
|
|
73
|
+
code: "invalid_specifications")
|
|
74
|
+
end
|
|
75
|
+
elsif rbs.empty?
|
|
76
|
+
raise ConfigurationError.new("RBS targets require at least one RBS file", code: "rbs_without_paths")
|
|
77
|
+
elsif targets.empty?
|
|
78
|
+
raise ConfigurationError.new("RBS files require at least one selected target", code: "targets_required")
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def validate_output(output)
|
|
83
|
+
return if output.is_a?(String) && !output.empty?
|
|
84
|
+
|
|
85
|
+
raise ConfigurationError.new("output must be a non-empty path", code: "invalid_output")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def validate_criterion(criterion)
|
|
89
|
+
return if criterion.is_a?(String) && !criterion.empty?
|
|
90
|
+
|
|
91
|
+
raise ConfigurationError.new("criterion must be a non-empty string", code: "invalid_criterion")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
public
|
|
95
|
+
|
|
96
|
+
def ensure_supported_version!
|
|
97
|
+
package_version = Gem.loaded_specs.fetch("minitest", nil)&.version
|
|
98
|
+
runtime_version = Gem::Version.new(::Minitest::VERSION)
|
|
99
|
+
max_version = Gem::Version.new("5.28.0")
|
|
100
|
+
supported_package = !package_version ||
|
|
101
|
+
(package_version >= MIN_SUPPORTED_MINITEST_VERSION && package_version < max_version)
|
|
102
|
+
supported_runtime = runtime_version >= MIN_SUPPORTED_MINITEST_VERSION && runtime_version < max_version
|
|
103
|
+
versions_match = !package_version || package_version.segments.take(2) == runtime_version.segments.take(2)
|
|
104
|
+
return if supported_package && supported_runtime && versions_match
|
|
105
|
+
|
|
106
|
+
message = "Minitest adapter supports Minitest >= 5.26.2 and < 5.28.0 " \
|
|
107
|
+
"(loaded package #{package_version || "unknown"}, runtime #{runtime_version})"
|
|
108
|
+
raise ConfigurationError.new(
|
|
109
|
+
message,
|
|
110
|
+
code: "unsupported_minitest_version"
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def run(args, &)
|
|
115
|
+
run = Run.new(@options, args)
|
|
116
|
+
run.execute(&)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Wraps Minitest's run entry point only while the adapter is installed.
|
|
121
|
+
module RunWrapper
|
|
122
|
+
def run(*args, **kwargs, &block)
|
|
123
|
+
return super unless SpecProof::Minitest.instance_variable_get(:@installed)
|
|
124
|
+
|
|
125
|
+
SpecProof::Minitest.run(args) { super(*args, **kwargs, &block) }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Builds honest source-input metadata for the current invocation.
|
|
130
|
+
module ReportScope
|
|
131
|
+
def self.declaration_sources(options)
|
|
132
|
+
canonical_paths = lambda do |paths|
|
|
133
|
+
paths.map do |path|
|
|
134
|
+
File.realpath(path)
|
|
135
|
+
rescue SystemCallError
|
|
136
|
+
File.expand_path(path)
|
|
137
|
+
end.uniq.sort.freeze
|
|
138
|
+
end
|
|
139
|
+
{
|
|
140
|
+
specifications: canonical_paths.call(options.fetch(:specifications)),
|
|
141
|
+
rbs: canonical_paths.call(options.fetch(:rbs)),
|
|
142
|
+
targets: options.fetch(:targets).uniq.sort.freeze
|
|
143
|
+
}.freeze
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def self.pattern_value(arguments, short, long)
|
|
147
|
+
pattern = nil
|
|
148
|
+
arguments.each_with_index do |argument, index|
|
|
149
|
+
if [short, long].include?(argument)
|
|
150
|
+
pattern = arguments[index + 1]
|
|
151
|
+
elsif argument.start_with?("#{long}=")
|
|
152
|
+
pattern = argument.delete_prefix("#{long}=")
|
|
153
|
+
elsif argument.start_with?(short) && argument.length > short.length
|
|
154
|
+
pattern = argument.delete_prefix(short)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
pattern
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Owns the preparation, collection, and finalization phases for one run.
|
|
162
|
+
class Run
|
|
163
|
+
ERROR_LIMIT = 20
|
|
164
|
+
|
|
165
|
+
def initialize(options, args)
|
|
166
|
+
@options = options
|
|
167
|
+
@args = args.flatten.grep(String).map { |item| safe_string(item) }
|
|
168
|
+
@run_id = SecureRandom.uuid
|
|
169
|
+
@errors = []
|
|
170
|
+
@specifications = []
|
|
171
|
+
@declaration_sources = ReportScope.declaration_sources(@options)
|
|
172
|
+
@criterion = @options.fetch(:criterion)
|
|
173
|
+
@obligations = []
|
|
174
|
+
@coverage = nil
|
|
175
|
+
@collector = nil
|
|
176
|
+
@integrity_ok = true
|
|
177
|
+
@marker_written = false
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def execute
|
|
181
|
+
prepare
|
|
182
|
+
result = nil
|
|
183
|
+
original_exception = nil
|
|
184
|
+
begin
|
|
185
|
+
result = yield
|
|
186
|
+
# Re-raise SystemExit and Interrupt after writing the final report.
|
|
187
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
188
|
+
original_exception = e
|
|
189
|
+
@integrity_ok = false
|
|
190
|
+
mark_all_incomplete("Minitest runner raised before completing")
|
|
191
|
+
add_error("runner_failed", safe_error(e))
|
|
192
|
+
ensure
|
|
193
|
+
if result.nil?
|
|
194
|
+
@integrity_ok = false
|
|
195
|
+
mark_all_incomplete("Minitest runner did not complete")
|
|
196
|
+
add_error("runner_incomplete", "Minitest returned without a completed result") unless original_exception
|
|
197
|
+
end
|
|
198
|
+
finalize(result)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
raise original_exception if original_exception
|
|
202
|
+
|
|
203
|
+
no_missing = @coverage.nil? || @coverage.to_report.fetch(:missing_count).zero?
|
|
204
|
+
return false unless @integrity_ok
|
|
205
|
+
return false if @options.fetch(:fail_on_missing) && !no_missing
|
|
206
|
+
|
|
207
|
+
result
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
private
|
|
211
|
+
|
|
212
|
+
def prepare
|
|
213
|
+
@specifications = SpecProof.load(@options.fetch(:specifications), rbs: @options.fetch(:rbs),
|
|
214
|
+
targets: @options.fetch(:targets))
|
|
215
|
+
@obligations = SpecProof.plan(@specifications, criterion: @criterion)
|
|
216
|
+
@coverage = Coverage.new(obligations: @obligations, specifications: @specifications)
|
|
217
|
+
projection = @coverage.to_report
|
|
218
|
+
@contract_digest = Report.digest(@specifications)
|
|
219
|
+
|
|
220
|
+
if parallel_suites?
|
|
221
|
+
@integrity_ok = false
|
|
222
|
+
add_error("parallel_suite", "coverage collection does not support parallel Minitest suites")
|
|
223
|
+
mark_all_incomplete("parallel suite could not be collected")
|
|
224
|
+
else
|
|
225
|
+
@collector = Collector.new(specifications: @specifications, observer: @coverage)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
@marker_written = atomic_write(running_report(projection))
|
|
229
|
+
if @marker_written && @collector
|
|
230
|
+
begin
|
|
231
|
+
@collector.start
|
|
232
|
+
rescue StandardError => e
|
|
233
|
+
@integrity_ok = false
|
|
234
|
+
add_error(error_code(e), safe_error(e))
|
|
235
|
+
mark_all_incomplete("collector could not start")
|
|
236
|
+
end
|
|
237
|
+
elsif !@marker_written
|
|
238
|
+
@integrity_ok = false
|
|
239
|
+
add_error("report_write_failed", "could not write running coverage marker")
|
|
240
|
+
mark_all_incomplete("coverage report could not be written")
|
|
241
|
+
end
|
|
242
|
+
rescue StandardError => e
|
|
243
|
+
@integrity_ok = false
|
|
244
|
+
finding = e.respond_to?(:findings) ? e.findings.first : nil
|
|
245
|
+
source = e.respond_to?(:source) ? e.source : finding&.sources&.first
|
|
246
|
+
add_error(error_code(e), safe_error(e), target: finding&.target,
|
|
247
|
+
path: e.respond_to?(:path) ? e.path : nil,
|
|
248
|
+
source: source)
|
|
249
|
+
mark_all_incomplete("coverage analysis could not start")
|
|
250
|
+
@contract_digest ||= ""
|
|
251
|
+
@marker_written ||= atomic_write(running_report(@coverage&.to_report || empty_projection))
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def finalize(result)
|
|
255
|
+
begin
|
|
256
|
+
@collector&.stop
|
|
257
|
+
rescue StandardError => e
|
|
258
|
+
@integrity_ok = false
|
|
259
|
+
add_error("collector_stop_failed", safe_error(e))
|
|
260
|
+
mark_all_incomplete("collector could not stop cleanly")
|
|
261
|
+
end
|
|
262
|
+
collect_issues
|
|
263
|
+
|
|
264
|
+
tests_passed = result.nil? ? nil : result != false
|
|
265
|
+
projection = @coverage&.to_report || empty_projection
|
|
266
|
+
completion = if !@integrity_ok || projection.fetch(:completeness) == "incomplete"
|
|
267
|
+
"incomplete"
|
|
268
|
+
else
|
|
269
|
+
"completed"
|
|
270
|
+
end
|
|
271
|
+
final_report = report(projection).merge(
|
|
272
|
+
completion: completion,
|
|
273
|
+
integrity: @integrity_ok,
|
|
274
|
+
tests_passed: tests_passed
|
|
275
|
+
)
|
|
276
|
+
unless @marker_written && atomic_write(final_report)
|
|
277
|
+
@integrity_ok = false
|
|
278
|
+
add_error("report_write_failed", "could not write final coverage report")
|
|
279
|
+
warn("SpecProof coverage report failed (run #{@run_id}): final report could not be written")
|
|
280
|
+
return
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
print_summary(projection, completion)
|
|
284
|
+
rescue StandardError => e
|
|
285
|
+
@integrity_ok = false
|
|
286
|
+
add_error("reporting_failed", safe_error(e))
|
|
287
|
+
warn("SpecProof coverage report failed (run #{@run_id}): #{safe_error(e)}")
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def collect_issues
|
|
291
|
+
Array(@collector&.issues).each do |issue|
|
|
292
|
+
@coverage&.mark_incomplete(target: issue[:target], reason: issue[:message])
|
|
293
|
+
@integrity_ok = false
|
|
294
|
+
add_error(issue[:code], issue[:message], target: issue[:target])
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def mark_all_incomplete(reason)
|
|
299
|
+
@specifications.each do |specification|
|
|
300
|
+
@coverage&.mark_incomplete(target: specification.target, reason: reason)
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def parallel_suites?
|
|
305
|
+
::Minitest::Runnable.runnables.any? do |suite|
|
|
306
|
+
suite.respond_to?(:test_order) && suite.test_order == :parallel
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def running_report(projection)
|
|
311
|
+
report(projection).merge(completion: "running", integrity: false, tests_passed: nil)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def report(projection)
|
|
315
|
+
Report.envelope(kind: "coverage", errors: @errors.dup).merge(
|
|
316
|
+
contract_digest: @contract_digest || "",
|
|
317
|
+
criterion: @criterion || "input-basics-v1",
|
|
318
|
+
run_id: @run_id,
|
|
319
|
+
scope: {
|
|
320
|
+
ruby_engine: RUBY_ENGINE,
|
|
321
|
+
pid: Process.pid,
|
|
322
|
+
thread: Thread.current.object_id,
|
|
323
|
+
window: "minitest_run",
|
|
324
|
+
method_identity: "direct_owner_definition",
|
|
325
|
+
input_semantics: "effective_bound_values",
|
|
326
|
+
argument_presence: "not_observed",
|
|
327
|
+
targets: @specifications.map(&:target).sort,
|
|
328
|
+
declaration_sources: @declaration_sources,
|
|
329
|
+
arguments: @args,
|
|
330
|
+
filter: ReportScope.pattern_value(@args, "-n", "--name"),
|
|
331
|
+
exclude: ReportScope.pattern_value(@args, "-e", "--exclude")
|
|
332
|
+
}.merge(Report.input_scope(@specifications)),
|
|
333
|
+
evidence_mode: "obligation_hits",
|
|
334
|
+
replay: false
|
|
335
|
+
).merge(projection.except(:status, :completeness))
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def empty_projection
|
|
339
|
+
{ obligations: [], missing: [], exercised: [], exercised_count: 0, obligation_count: 0,
|
|
340
|
+
missing_count: 0, unknown_count: 0, not_observed_count: 0, completion_percent: nil,
|
|
341
|
+
outside_domain: {}, incomplete_reasons: [], evidence_mode: "obligation_hits", replay: false }
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def atomic_write(document)
|
|
345
|
+
output = @options.fetch(:output)
|
|
346
|
+
directory = File.dirname(output)
|
|
347
|
+
FileUtils.mkdir_p(directory)
|
|
348
|
+
Tempfile.create([".specproof-", ".json"], directory) do |file|
|
|
349
|
+
file.write(JSON.pretty_generate(document))
|
|
350
|
+
file.write("\n")
|
|
351
|
+
file.flush
|
|
352
|
+
file.fsync
|
|
353
|
+
File.rename(file.path, output)
|
|
354
|
+
end
|
|
355
|
+
true
|
|
356
|
+
rescue StandardError => e
|
|
357
|
+
add_error("report_write_failed", safe_error(e))
|
|
358
|
+
false
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def print_summary(projection, completion)
|
|
362
|
+
text = "SpecProof coverage run #{@run_id}: " \
|
|
363
|
+
"#{projection.fetch(:exercised_count)}/#{projection.fetch(:obligation_count)} " \
|
|
364
|
+
"#{@criterion == "pairwise-partitions-v1" ? "pair obligations" : "obligations"} exercised"
|
|
365
|
+
text += ", #{projection.fetch(:missing_count)} missing" if projection.fetch(:missing_count).positive?
|
|
366
|
+
text += ", #{projection.fetch(:unknown_count)} unknown" if projection.fetch(:unknown_count).positive?
|
|
367
|
+
missing_values = projection.fetch(:missing).first(10).map do |item|
|
|
368
|
+
if item.key?(:inputs)
|
|
369
|
+
values = item.fetch(:inputs).map { |name, value| "#{name}=#{human_value(value)}" }.join(",")
|
|
370
|
+
"#{item.fetch(:target)}/#{values}"
|
|
371
|
+
else
|
|
372
|
+
"#{item.fetch(:target)}/#{item.fetch(:input)}=#{human_value(item.fetch(:value))}"
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
if missing_values.any?
|
|
376
|
+
remaining = projection.fetch(:missing_count) - missing_values.length
|
|
377
|
+
missing_values << "… +#{remaining} more" if remaining.positive?
|
|
378
|
+
text += "; missing values: #{missing_values.join(", ")}"
|
|
379
|
+
end
|
|
380
|
+
text += "; completeness scope: analyzed inputs only"
|
|
381
|
+
text += "; #{projection.fetch(:excluded_pairs).length} excluded pairs" if projection.key?(:excluded_pairs)
|
|
382
|
+
text += pairwise_summary
|
|
383
|
+
exclusions = @specifications.flat_map do |specification|
|
|
384
|
+
specification.exclusions.values.map do |item|
|
|
385
|
+
"#{specification.target}/#{item.name} (#{item.reason})"
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
text += "; excluded: #{exclusions.join(", ")}" unless exclusions.empty?
|
|
389
|
+
text += " (#{completion})"
|
|
390
|
+
$stdout.puts(text)
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def pairwise_summary
|
|
394
|
+
return "" unless %w[pairwise-finite-v1 pairwise-partitions-v1].include?(@criterion)
|
|
395
|
+
|
|
396
|
+
groups = @specifications.filter_map do |specification|
|
|
397
|
+
group = specification.respond_to?(:combination_group) && specification.combination_group
|
|
398
|
+
"#{specification.target}(#{group.inputs.join(", ")})" if group
|
|
399
|
+
end
|
|
400
|
+
"; criterion: #{@criterion}; group: #{groups.join("; ")}"
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def human_value(value)
|
|
404
|
+
case value[:kind]
|
|
405
|
+
when "nil" then "nil"
|
|
406
|
+
when "boolean" then value[:value] ? "true" : "false"
|
|
407
|
+
when "integer" then value[:value]
|
|
408
|
+
when "partition" then "#{value[:name]}[#{value[:lower]}..#{value[:upper]}]"
|
|
409
|
+
when "symbol" then ":#{value[:value]}"
|
|
410
|
+
when "string" then value[:value].inspect
|
|
411
|
+
else "?"
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def add_error(code, message, target: nil, path: nil, source: nil)
|
|
416
|
+
return if @errors.length >= ERROR_LIMIT
|
|
417
|
+
|
|
418
|
+
error = { code: code.to_s, message: safe_string(message) }
|
|
419
|
+
error[:target] = safe_string(target) if target
|
|
420
|
+
error[:path] = safe_string(path) if path
|
|
421
|
+
error[:source] = source.to_h if source.respond_to?(:to_h)
|
|
422
|
+
@errors << error unless @errors.include?(error)
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def error_code(error)
|
|
426
|
+
if error.respond_to?(:code)
|
|
427
|
+
error.code.to_s
|
|
428
|
+
elsif error.respond_to?(:findings) && (finding = error.findings.first)
|
|
429
|
+
finding.code.to_s
|
|
430
|
+
else
|
|
431
|
+
error.class.name.split("::").last.gsub(/([a-z])([A-Z])/,
|
|
432
|
+
"\\1_\\2").downcase
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def safe_error(error) = safe_string(error.message.to_s.empty? ? error.class.name : error.message)
|
|
437
|
+
|
|
438
|
+
def safe_string(value)
|
|
439
|
+
value.to_s.encode("UTF-8", invalid: :replace, undef: :replace, replace: "?").byteslice(0, 240).to_s
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpecProof
|
|
4
|
+
Source = Struct.new(:path, :line, :kind, keyword_init: true) do
|
|
5
|
+
def initialize(path:, line:, kind: "dsl")
|
|
6
|
+
super(path: path.to_s.dup.freeze, line: Integer(line), kind: kind.to_s.dup.freeze)
|
|
7
|
+
freeze
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
IntegerDomain = Struct.new(:lower, :upper, keyword_init: true) do
|
|
12
|
+
def initialize(lower:, upper:)
|
|
13
|
+
super(lower: Integer(lower), upper: Integer(upper))
|
|
14
|
+
freeze
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# A normalized inclusive category for an integer input.
|
|
19
|
+
class PartitionLevel
|
|
20
|
+
attr_reader :name, :lower, :upper
|
|
21
|
+
|
|
22
|
+
def initialize(name:, lower:, upper:)
|
|
23
|
+
@name = name.to_sym
|
|
24
|
+
@lower = Integer(lower)
|
|
25
|
+
@upper = Integer(upper)
|
|
26
|
+
freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def include?(value)
|
|
30
|
+
value.instance_of?(Integer) && value.between?(lower, upper)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def representative
|
|
34
|
+
lower
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def ==(other)
|
|
38
|
+
other.instance_of?(self.class) && [name, lower, upper] == [other.name, other.lower, other.upper]
|
|
39
|
+
end
|
|
40
|
+
alias eql? ==
|
|
41
|
+
|
|
42
|
+
def hash
|
|
43
|
+
[self.class, name, lower, upper].hash
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def sort_key
|
|
47
|
+
[2, lower, upper, name.to_s]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Names one whole integer partition in a combinations restriction.
|
|
52
|
+
class PartitionReference
|
|
53
|
+
attr_reader :name
|
|
54
|
+
|
|
55
|
+
def initialize(name)
|
|
56
|
+
unless name.is_a?(Symbol) && !name.to_s.empty?
|
|
57
|
+
raise ArgumentError, "partition reference name must be a non-empty symbol"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
@name = name.to_sym
|
|
61
|
+
freeze
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def ==(other)
|
|
65
|
+
other.instance_of?(self.class) && name == other.name
|
|
66
|
+
end
|
|
67
|
+
alias eql? ==
|
|
68
|
+
|
|
69
|
+
def hash
|
|
70
|
+
[self.class, name].hash
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# `values` is the stable domain record field and intentionally shadows Struct#values.
|
|
75
|
+
FiniteDomain = Struct.new(:values, keyword_init: true) do # rubocop:disable Lint/StructNewOverride
|
|
76
|
+
def initialize(positional_values = nil, values: positional_values)
|
|
77
|
+
unless values.is_a?(Array) && values.all? do |value|
|
|
78
|
+
Domains.primitive?(value)
|
|
79
|
+
end
|
|
80
|
+
raise ArgumentError, "finite domain values must be integers, symbols, strings, booleans, or nil"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
copied = values.map { |value| Domains.supported_string?(value) ? Domains.canonical_string(value) : value }
|
|
84
|
+
.uniq.sort_by { |value| Domains.sort_key(value) }.freeze
|
|
85
|
+
super(values: copied)
|
|
86
|
+
freeze
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
Partition = Struct.new(:name, :domain, :source, keyword_init: true) do
|
|
91
|
+
def initialize(name:, domain:, source:)
|
|
92
|
+
super(name: name.to_sym, domain: domain, source: source)
|
|
93
|
+
freeze
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
Input = Struct.new(:name, :domain, :partitions, :exhaustive, :disjoint, :source,
|
|
98
|
+
:type_constraint, :type_sources, keyword_init: true) do
|
|
99
|
+
def initialize(name:, domain:, partitions:, exhaustive:, disjoint:, source:, type_constraint: nil, type_sources: []) # rubocop:disable Metrics/ParameterLists
|
|
100
|
+
super(name: name.to_sym, domain: domain, partitions: partitions.dup.freeze,
|
|
101
|
+
exhaustive: exhaustive, disjoint: disjoint, source: source, type_constraint: type_constraint,
|
|
102
|
+
type_sources: type_sources.dup.freeze)
|
|
103
|
+
freeze
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def sources
|
|
107
|
+
([source] + type_sources).compact.uniq.sort_by { |item| [item.path, item.line, item.kind] }.freeze
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
Exclusion = Struct.new(:name, :reason, :source, keyword_init: true) do
|
|
112
|
+
def initialize(name:, reason:, source:)
|
|
113
|
+
super(name: name.to_sym, reason: reason.dup.freeze, source: source)
|
|
114
|
+
freeze
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
InputTypeFact = Struct.new(:target, :input, :constraint, :source, keyword_init: true) do
|
|
119
|
+
def initialize(target:, input:, constraint:, source:)
|
|
120
|
+
super(target: target.to_s.dup.freeze, input: input.to_sym, constraint: constraint, source: source)
|
|
121
|
+
freeze
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
CombinationGroup = Struct.new(:inputs, :forbidden, :source, keyword_init: true) do
|
|
126
|
+
def initialize(inputs:, forbidden:, source:)
|
|
127
|
+
super(inputs: inputs.map(&:to_sym).sort_by(&:to_s).freeze, forbidden: forbidden.dup.freeze, source: source)
|
|
128
|
+
freeze
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
ForbiddenAssignment = Struct.new(:assignments, :source, keyword_init: true) do
|
|
133
|
+
def initialize(assignments:, source:)
|
|
134
|
+
copied = assignments.to_h do |name, value|
|
|
135
|
+
[name.to_sym, value.is_a?(String) ? Domains.canonical_string(value) : value]
|
|
136
|
+
end
|
|
137
|
+
super(assignments: copied.sort_by { |name, _value| name.to_s }.to_h.freeze, source: source)
|
|
138
|
+
freeze
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
Specification = Struct.new(:target, :inputs, :source, :exclusions, :combination_group, keyword_init: true) do
|
|
143
|
+
def initialize(target:, inputs:, source:, exclusions: {}, combination_group: nil)
|
|
144
|
+
super(target: target.to_s.dup.freeze, inputs: inputs.dup.freeze, source: source,
|
|
145
|
+
exclusions: exclusions.dup.freeze, combination_group: combination_group)
|
|
146
|
+
freeze
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
CombinationFinding = Struct.new(:code, :target, :inputs, :sources, keyword_init: true) do
|
|
151
|
+
def initialize(code:, target:, inputs:, sources:)
|
|
152
|
+
super(code: code.to_sym, target: target.to_s.dup.freeze,
|
|
153
|
+
inputs: inputs.map(&:to_sym).sort_by(&:to_s).freeze, sources: sources.uniq.freeze)
|
|
154
|
+
freeze
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
Finding = Struct.new(:code, :target, :input, :witness, :sources, keyword_init: true) do
|
|
159
|
+
def initialize(code:, target:, input:, witness:, sources:)
|
|
160
|
+
super(code: code.to_sym, target: target.to_s.dup.freeze, input: input.to_sym,
|
|
161
|
+
witness: freeze_value(witness), sources: sources.uniq.freeze)
|
|
162
|
+
freeze
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
def freeze_value(value)
|
|
168
|
+
case value
|
|
169
|
+
when Array then value.map { |item| freeze_value(item) }.freeze
|
|
170
|
+
when String then value.dup.freeze
|
|
171
|
+
else value
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
Obligation = Struct.new(:id, :target, :input, :value, :reasons, :sources, keyword_init: true) do
|
|
177
|
+
def initialize(id:, target:, input:, value:, reasons:, sources:)
|
|
178
|
+
copy = value.is_a?(String) ? value.dup.freeze : value
|
|
179
|
+
super(id: id.to_s.dup.freeze, target: target.to_s.dup.freeze, input: input.to_sym,
|
|
180
|
+
value: copy, reasons: reasons.map { |reason| reason.to_s.dup.freeze }.uniq.freeze,
|
|
181
|
+
sources: sources.uniq.freeze)
|
|
182
|
+
freeze
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
PairObligation = Struct.new(:id, :target, :inputs, :reasons, :sources, keyword_init: true) do
|
|
187
|
+
def initialize(id:, target:, inputs:, reasons:, sources:)
|
|
188
|
+
copied = inputs.to_h do |name, value|
|
|
189
|
+
[name.to_sym, value.is_a?(String) ? Domains.canonical_string(value) : value]
|
|
190
|
+
end
|
|
191
|
+
super(id: id.to_s.dup.freeze, target: target.to_s.dup.freeze,
|
|
192
|
+
inputs: copied.sort_by { |name, _value| name.to_s }.to_h.freeze,
|
|
193
|
+
reasons: reasons.map { |reason| reason.to_s.dup.freeze }.uniq.freeze,
|
|
194
|
+
sources: sources.uniq.freeze)
|
|
195
|
+
freeze
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|