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,285 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Loads specification files without resolving application constants.
|
|
4
|
+
module SpecProof
|
|
5
|
+
# Builds and validates declarations while a specification file runs.
|
|
6
|
+
module Loader
|
|
7
|
+
# Collects the inputs declared for one target.
|
|
8
|
+
class Builder
|
|
9
|
+
attr_reader :target, :source, :inputs, :exclusions, :combination_group
|
|
10
|
+
|
|
11
|
+
def initialize(target, source)
|
|
12
|
+
@target = target
|
|
13
|
+
@source = source
|
|
14
|
+
@inputs = {}
|
|
15
|
+
@exclusions = {}
|
|
16
|
+
@combination_group = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def input(name, **options, &block)
|
|
20
|
+
location = Loader.call_source
|
|
21
|
+
name = valid_name(name, location, "input")
|
|
22
|
+
duplicate!(inputs, name, "duplicate_input", location)
|
|
23
|
+
overlap!(name, location)
|
|
24
|
+
domain, form = parse_domain(options, location)
|
|
25
|
+
exhaustive = partition_policy(options, :exhaustive, :allow_gaps, location)
|
|
26
|
+
disjoint = partition_policy(options, :disjoint, :allow_overlaps, location)
|
|
27
|
+
unknown = options.keys - %i[range values exhaustive disjoint allow_gaps allow_overlaps]
|
|
28
|
+
unknown_option!(unknown.first, location) unless unknown.empty?
|
|
29
|
+
partitions = []
|
|
30
|
+
if block
|
|
31
|
+
partition_builder = PartitionBuilder.new(domain, form)
|
|
32
|
+
partition_builder.instance_eval(&block)
|
|
33
|
+
partitions = partition_builder.partitions
|
|
34
|
+
end
|
|
35
|
+
if (exhaustive || disjoint) && partitions.empty?
|
|
36
|
+
raise ConfigurationError.new("a promised partition family must not be empty", code: "empty_partition_family",
|
|
37
|
+
source: location)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
inputs[name] = Input.new(name: name, domain: domain, partitions: partitions,
|
|
41
|
+
exhaustive: exhaustive, disjoint: disjoint, source: location)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def exclude_input(name, reason:)
|
|
45
|
+
location = Loader.call_source
|
|
46
|
+
name = valid_name(name, location, "input")
|
|
47
|
+
duplicate!(exclusions, name, "duplicate_exclusion", location)
|
|
48
|
+
overlap!(name, location)
|
|
49
|
+
unless reason.is_a?(String) && !reason.strip.empty?
|
|
50
|
+
raise ConfigurationError.new("exclusion reason must be a non-blank String",
|
|
51
|
+
code: "invalid_exclusion_reason", source: location)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
exclusions[name] = Exclusion.new(name: name, reason: reason, source: location)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def combinations(*names, **options, &block)
|
|
58
|
+
location = Loader.call_source
|
|
59
|
+
if combination_group
|
|
60
|
+
raise ConfigurationError.new("a target may declare only one combinations group",
|
|
61
|
+
code: "duplicate_combinations", source: location)
|
|
62
|
+
end
|
|
63
|
+
unless names.length >= 2 && names.all? { |name| name.is_a?(Symbol) && !name.to_s.empty? } && names.uniq == names
|
|
64
|
+
raise ConfigurationError.new("combinations requires at least two unique non-empty input symbols",
|
|
65
|
+
code: "invalid_combination_inputs", source: location)
|
|
66
|
+
end
|
|
67
|
+
unknown = options.keys - [:all_allowed]
|
|
68
|
+
unknown_option!(unknown.first, location) unless unknown.empty?
|
|
69
|
+
|
|
70
|
+
forbidden = if block
|
|
71
|
+
unless options.empty?
|
|
72
|
+
raise ConfigurationError.new("forbid block cannot be combined with options",
|
|
73
|
+
code: "invalid_combinations_mode", source: location)
|
|
74
|
+
end
|
|
75
|
+
builder = CombinationBuilder.new(names, location)
|
|
76
|
+
builder.instance_eval(&block)
|
|
77
|
+
if builder.forbidden.empty?
|
|
78
|
+
raise ConfigurationError.new("combinations block must declare at least one forbid",
|
|
79
|
+
code: "empty_combinations_block", source: location)
|
|
80
|
+
end
|
|
81
|
+
builder.forbidden
|
|
82
|
+
else
|
|
83
|
+
unless options.keys == [:all_allowed] && options[:all_allowed].equal?(true)
|
|
84
|
+
raise ConfigurationError.new("declare all_allowed: true or provide a non-empty forbid block",
|
|
85
|
+
code: "invalid_combinations_mode", source: location)
|
|
86
|
+
end
|
|
87
|
+
[]
|
|
88
|
+
end
|
|
89
|
+
@combination_group = CombinationGroup.new(inputs: names, forbidden: forbidden, source: location)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def parse_domain(options, source)
|
|
95
|
+
has_range = options.key?(:range)
|
|
96
|
+
has_values = options.key?(:values)
|
|
97
|
+
if has_range == has_values
|
|
98
|
+
raise ConfigurationError.new("declare exactly one of range: or values:", code: "domain_required",
|
|
99
|
+
source: source)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
if has_range
|
|
103
|
+
[Domains.from_range(options[:range], source: source),
|
|
104
|
+
:range]
|
|
105
|
+
else
|
|
106
|
+
[Domains.from_values(options[:values], source: source), :values]
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def flag(options, key, source)
|
|
111
|
+
value = options.fetch(key, false)
|
|
112
|
+
return value if value.equal?(true) || value.equal?(false)
|
|
113
|
+
|
|
114
|
+
raise ConfigurationError.new("#{key} must be true or false", code: "invalid_flag", source: source)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def partition_policy(options, legacy_key, readable_key, source)
|
|
118
|
+
if options.key?(legacy_key) && options.key?(readable_key)
|
|
119
|
+
raise ConfigurationError.new("declare only one of #{legacy_key}: or #{readable_key}:",
|
|
120
|
+
code: "duplicate_partition_policy", source: source)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
return flag(options, legacy_key, source) if options.key?(legacy_key)
|
|
124
|
+
return !flag(options, readable_key, source) if options.key?(readable_key)
|
|
125
|
+
|
|
126
|
+
false
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def valid_name(name, source, type)
|
|
130
|
+
unless name.is_a?(Symbol) && !name.to_s.empty?
|
|
131
|
+
raise ConfigurationError.new("#{type} name must be a non-empty symbol", code: "invalid_name", source: source)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
name
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def duplicate!(collection, name, code, source)
|
|
138
|
+
return unless collection.key?(name)
|
|
139
|
+
|
|
140
|
+
raise ConfigurationError.new("duplicate declaration for #{name.inspect}", code: code, source: source)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def overlap!(name, source)
|
|
144
|
+
return unless inputs.key?(name) || exclusions.key?(name)
|
|
145
|
+
|
|
146
|
+
raise ConfigurationError.new("input #{name.inspect} cannot be both analyzed and excluded",
|
|
147
|
+
code: "input_exclusion_overlap", source: source)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def unknown_option!(option, source)
|
|
151
|
+
raise ConfigurationError.new("unknown declaration option #{option.inspect}", code: "unknown_option",
|
|
152
|
+
source: source)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Collects partitions for one input declaration.
|
|
157
|
+
class PartitionBuilder
|
|
158
|
+
attr_reader :partitions
|
|
159
|
+
|
|
160
|
+
def initialize(parent_domain, parent_form)
|
|
161
|
+
@parent_domain = parent_domain
|
|
162
|
+
@parent_form = parent_form
|
|
163
|
+
@partitions = []
|
|
164
|
+
@names = {}
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def partition(name, **options)
|
|
168
|
+
source = Loader.call_source
|
|
169
|
+
unless name.is_a?(Symbol) && !name.to_s.empty?
|
|
170
|
+
raise ConfigurationError.new("partition name must be a non-empty symbol", code: "invalid_name",
|
|
171
|
+
source: source)
|
|
172
|
+
end
|
|
173
|
+
if @names.key?(name)
|
|
174
|
+
raise ConfigurationError.new("duplicate partition #{name.inspect}", code: "duplicate_partition",
|
|
175
|
+
source: source)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
domain_keys = options.keys & %i[range values]
|
|
179
|
+
unless domain_keys.length == 1 && options.keys.length == 1
|
|
180
|
+
raise ConfigurationError.new("partition must declare exactly one of range: or values:",
|
|
181
|
+
code: "invalid_partition_domain", source: source)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
form = domain_keys.first == :range ? :range : :values
|
|
185
|
+
unless form == @parent_form
|
|
186
|
+
raise ConfigurationError.new("partition domain form must match its input", code: "partition_domain_kind",
|
|
187
|
+
source: source)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
domain = if form == :range
|
|
191
|
+
Domains.from_range(options[:range],
|
|
192
|
+
source: source)
|
|
193
|
+
else
|
|
194
|
+
Domains.from_values(options[:values],
|
|
195
|
+
source: source)
|
|
196
|
+
end
|
|
197
|
+
@names[name] = true
|
|
198
|
+
partitions << Partition.new(name: name, domain: domain, source: source)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
class << self
|
|
203
|
+
def load(paths, allow_exclusion_only: false)
|
|
204
|
+
paths = Array(paths)
|
|
205
|
+
raise ConfigurationError.new("provide at least one specification file", code: "paths_required") if paths.empty?
|
|
206
|
+
raise ConfigurationError.new("nested specification loading is not supported", code: "nested_load") if @active
|
|
207
|
+
|
|
208
|
+
declarations = {}
|
|
209
|
+
owns_context = false
|
|
210
|
+
@active = declarations
|
|
211
|
+
@allow_exclusion_only = allow_exclusion_only
|
|
212
|
+
owns_context = true
|
|
213
|
+
paths.map { |path| File.expand_path(File.path(path)) }.sort.each do |path|
|
|
214
|
+
unless File.file?(path)
|
|
215
|
+
raise ConfigurationError.new("specification file does not exist: #{path}", code: "file_not_found",
|
|
216
|
+
path: path)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
@current_path = path
|
|
220
|
+
TOPLEVEL_BINDING.eval(File.read(path), path, 1)
|
|
221
|
+
end
|
|
222
|
+
if declarations.empty?
|
|
223
|
+
raise ConfigurationError.new("no specifications were declared", code: "empty_specifications")
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
unless allow_exclusion_only
|
|
227
|
+
declarations.each_value do |specification|
|
|
228
|
+
next unless specification.combination_group
|
|
229
|
+
|
|
230
|
+
PartitionSpace.for_specification(specification, validate_geometry: false)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
declarations.values.freeze
|
|
235
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
236
|
+
raise ConfigurationError.new(e.message, code: "file_error",
|
|
237
|
+
path: e.respond_to?(:path) ? e.path : nil)
|
|
238
|
+
ensure
|
|
239
|
+
if owns_context
|
|
240
|
+
@active = nil
|
|
241
|
+
@allow_exclusion_only = nil
|
|
242
|
+
@current_path = nil
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def define(target, &block)
|
|
247
|
+
source = call_source
|
|
248
|
+
unless @active
|
|
249
|
+
raise ConfigurationError.new("SpecProof.define may only be called while loading a specification",
|
|
250
|
+
code: "outside_load", source: source)
|
|
251
|
+
end
|
|
252
|
+
target = TargetName.normalize(target, source: source)
|
|
253
|
+
if @active.key?(target)
|
|
254
|
+
raise ConfigurationError.new("duplicate target #{target}", code: "duplicate_target", source: source)
|
|
255
|
+
end
|
|
256
|
+
unless block
|
|
257
|
+
raise ConfigurationError.new("target declaration requires a block", code: "missing_block", source: source)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
builder = Builder.new(target, source)
|
|
261
|
+
builder.instance_eval(&block)
|
|
262
|
+
allowed_empty = @allow_exclusion_only && (!builder.exclusions.empty? || builder.combination_group)
|
|
263
|
+
if builder.inputs.empty? && !allowed_empty
|
|
264
|
+
raise ConfigurationError.new("target must declare at least one input", code: "inputs_required",
|
|
265
|
+
source: source)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
@active[target] = Specification.new(target: target, inputs: builder.inputs, exclusions: builder.exclusions,
|
|
269
|
+
combination_group: builder.combination_group, source: source)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def call_source
|
|
273
|
+
location = caller_locations.find do |entry|
|
|
274
|
+
!entry.path.end_with?("/specproof/loader.rb") && !entry.path.end_with?("/specproof/combinations.rb")
|
|
275
|
+
end
|
|
276
|
+
path = location&.absolute_path || location&.path || @current_path || "(unknown)"
|
|
277
|
+
Source.new(path: path, line: location&.lineno || 1)
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def self.define(target, &)
|
|
283
|
+
Loader.define(target, &)
|
|
284
|
+
end
|
|
285
|
+
end
|