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,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