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,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
|
|
7
|
+
module SpecProof
|
|
8
|
+
# Exports the packaged workflow skills into a client directory.
|
|
9
|
+
module Skills
|
|
10
|
+
SKILL_NAMES = %w[specproof-specify specproof-test-gaps].freeze
|
|
11
|
+
SOURCE_ROOT = File.expand_path("../../skills", __dir__).freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# Copies the packaged skills into an explicit client skills directory.
|
|
16
|
+
# `source_root` is an internal seam for tests; production callers use the
|
|
17
|
+
# packaged assets rooted next to this library.
|
|
18
|
+
def export(to:, source_root: SOURCE_ROOT)
|
|
19
|
+
destination = normalized_destination(to)
|
|
20
|
+
assets = packaged_assets(source_root)
|
|
21
|
+
planned = preflight(destination, assets)
|
|
22
|
+
|
|
23
|
+
exported = []
|
|
24
|
+
unchanged = []
|
|
25
|
+
|
|
26
|
+
planned.each do |asset|
|
|
27
|
+
target = asset.fetch(:target)
|
|
28
|
+
asset.fetch(:files).each do |file|
|
|
29
|
+
output = File.join(target, file.fetch(:relative))
|
|
30
|
+
if file.fetch(:unchanged)
|
|
31
|
+
unchanged << output
|
|
32
|
+
next
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
FileUtils.mkdir_p(File.dirname(output))
|
|
36
|
+
write_file(output, file.fetch(:content))
|
|
37
|
+
exported << output
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
{
|
|
42
|
+
destination: destination,
|
|
43
|
+
exported: exported.sort,
|
|
44
|
+
unchanged: unchanged.sort
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def normalized_destination(to)
|
|
49
|
+
unless to.is_a?(String) && !to.strip.empty?
|
|
50
|
+
raise configuration_error("An explicit non-empty skill destination is required.", "missing_skills_destination")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
File.expand_path(to)
|
|
54
|
+
end
|
|
55
|
+
private_class_method :normalized_destination
|
|
56
|
+
|
|
57
|
+
def packaged_assets(source_root)
|
|
58
|
+
root = File.expand_path(source_root)
|
|
59
|
+
unless File.directory?(root)
|
|
60
|
+
raise configuration_error("Packaged skills directory is missing: #{root}", "invalid_skills_package")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
entries = Dir.children(root).sort
|
|
64
|
+
unless entries == SKILL_NAMES.sort
|
|
65
|
+
raise configuration_error("Packaged skills must contain exactly #{SKILL_NAMES.join(" and ")}.",
|
|
66
|
+
"invalid_skills_package")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
SKILL_NAMES.map do |name|
|
|
70
|
+
skill_root = File.join(root, name)
|
|
71
|
+
reject_symlink_path!(skill_root, "skill asset")
|
|
72
|
+
unless File.directory?(skill_root)
|
|
73
|
+
raise configuration_error("Skill asset must be a directory: #{skill_root}", "invalid_skills_package")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
files = Dir.glob(File.join(skill_root, "**", "*"), File::FNM_DOTMATCH).sort.filter_map do |path|
|
|
77
|
+
next if %w[. ..].include?(File.basename(path))
|
|
78
|
+
|
|
79
|
+
reject_symlink_path!(path, "skill asset")
|
|
80
|
+
stat = File.lstat(path)
|
|
81
|
+
next if stat.directory?
|
|
82
|
+
unless stat.file?
|
|
83
|
+
raise configuration_error("Skill asset must contain only regular files and directories: #{path}",
|
|
84
|
+
"invalid_skills_package")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
relative = path.delete_prefix("#{skill_root}/")
|
|
88
|
+
{ relative: relative, content: File.binread(path) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
unless files.any? { |file| file.fetch(:relative) == "SKILL.md" }
|
|
92
|
+
raise configuration_error("Skill asset is missing SKILL.md: #{skill_root}", "invalid_skills_package")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
{ name: name, files: files }
|
|
96
|
+
end
|
|
97
|
+
rescue Errno::ENOENT => e
|
|
98
|
+
raise configuration_error("Packaged skill asset is incomplete: #{e.message}", "invalid_skills_package")
|
|
99
|
+
end
|
|
100
|
+
private_class_method :packaged_assets
|
|
101
|
+
|
|
102
|
+
def preflight(destination, assets)
|
|
103
|
+
reject_symlink_path!(destination, "destination")
|
|
104
|
+
if File.exist?(destination) && !File.directory?(destination)
|
|
105
|
+
raise configuration_error("Skill destination must be a directory: #{destination}", "unsafe_skills_destination")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
assets.map do |asset|
|
|
109
|
+
target = File.join(destination, asset.fetch(:name))
|
|
110
|
+
reject_symlink_path!(target, "destination")
|
|
111
|
+
if File.exist?(target) && !File.directory?(target)
|
|
112
|
+
raise configuration_error("Skill destination entry must be a directory: #{target}",
|
|
113
|
+
"unsafe_skills_destination")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
unchanged = true
|
|
117
|
+
asset.fetch(:files).each do |file|
|
|
118
|
+
output = File.join(target, file.fetch(:relative))
|
|
119
|
+
reject_symlink_path!(output, "destination")
|
|
120
|
+
next unless File.exist?(output)
|
|
121
|
+
|
|
122
|
+
unless File.file?(output)
|
|
123
|
+
raise configuration_error("Skill destination file has an incompatible type: #{output}",
|
|
124
|
+
"unsafe_skills_destination")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
unless File.binread(output) == file.fetch(:content)
|
|
128
|
+
raise configuration_error("Skill destination contains different content: #{output}",
|
|
129
|
+
"skills_destination_conflict")
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
if File.directory?(target)
|
|
134
|
+
Dir.glob(File.join(target, "**", "*"), File::FNM_DOTMATCH).each do |path|
|
|
135
|
+
next if %w[. ..].include?(File.basename(path))
|
|
136
|
+
|
|
137
|
+
reject_symlink_path!(path, "destination")
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
files = asset.fetch(:files).map do |file|
|
|
142
|
+
output = File.join(target, file.fetch(:relative))
|
|
143
|
+
unchanged = File.file?(output)
|
|
144
|
+
file.merge(unchanged: unchanged)
|
|
145
|
+
end
|
|
146
|
+
{ target: target, files: files }
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
private_class_method :preflight
|
|
150
|
+
|
|
151
|
+
def reject_symlink_path!(path, description)
|
|
152
|
+
expanded = File.expand_path(path)
|
|
153
|
+
current = File::SEPARATOR
|
|
154
|
+
expanded.split(File::SEPARATOR).reject(&:empty?).each do |part|
|
|
155
|
+
current = File.join(current, part)
|
|
156
|
+
next unless File.exist?(current) || File.symlink?(current)
|
|
157
|
+
|
|
158
|
+
stat = File.lstat(current)
|
|
159
|
+
if stat.symlink?
|
|
160
|
+
raise configuration_error("Skill #{description} cannot include symlinks: #{current}",
|
|
161
|
+
"unsafe_skills_destination")
|
|
162
|
+
end
|
|
163
|
+
if current != expanded && !stat.directory?
|
|
164
|
+
raise configuration_error("Skill #{description} path has a non-directory parent: #{current}",
|
|
165
|
+
"unsafe_skills_destination")
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
rescue Errno::ENOENT
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
171
|
+
private_class_method :reject_symlink_path!
|
|
172
|
+
|
|
173
|
+
def write_file(path, content)
|
|
174
|
+
directory = File.dirname(path)
|
|
175
|
+
Tempfile.create([".specproof-skill-", ".tmp"], directory, binmode: true) do |temporary|
|
|
176
|
+
temporary.write(content)
|
|
177
|
+
temporary.flush
|
|
178
|
+
File.rename(temporary.path, path)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
private_class_method :write_file
|
|
182
|
+
|
|
183
|
+
def configuration_error(message, code)
|
|
184
|
+
ConfigurationError.new(message, code: code, path: nil, source: nil)
|
|
185
|
+
end
|
|
186
|
+
private_class_method :configuration_error
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require_relative "../../specproof"
|
|
5
|
+
require_relative "rbs_projector"
|
|
6
|
+
|
|
7
|
+
module SpecProof
|
|
8
|
+
module Sources
|
|
9
|
+
# Reads the deliberately small, explicit RBS subset used for scalar input facts.
|
|
10
|
+
module RBS
|
|
11
|
+
VERSION = "4.2.0"
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def load(paths:, targets:, declarations: {})
|
|
15
|
+
canonical_paths = normalize_paths(paths)
|
|
16
|
+
targets = normalize_targets(targets)
|
|
17
|
+
rbs = load_dependency
|
|
18
|
+
verify_version!(rbs)
|
|
19
|
+
|
|
20
|
+
buffers = canonical_paths.map { |path| parse(path, rbs) }
|
|
21
|
+
environment = resolve_environment(buffers, rbs)
|
|
22
|
+
facts = targets.flat_map do |target|
|
|
23
|
+
RBSProjector.new(target, environment, canonical_paths, rbs,
|
|
24
|
+
declaration: declarations[target]).facts
|
|
25
|
+
end
|
|
26
|
+
facts.sort_by { |fact| [fact.target, fact.input.to_s] }.freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def normalize_paths(paths)
|
|
32
|
+
values = Array(paths)
|
|
33
|
+
if values.empty?
|
|
34
|
+
raise ConfigurationError.new("provide at least one RBS signature file", code: "paths_required")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
canonical = values.map do |entry|
|
|
38
|
+
input_path = nil
|
|
39
|
+
input_path = File.path(entry)
|
|
40
|
+
path = File.realpath(input_path)
|
|
41
|
+
unless File.file?(path)
|
|
42
|
+
raise ConfigurationError.new(
|
|
43
|
+
"RBS signature path is not a regular file: #{path}", code: "file_error", path: path
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
path
|
|
47
|
+
rescue Errno::ENOENT => e
|
|
48
|
+
raise ConfigurationError.new(e.message, code: "file_not_found",
|
|
49
|
+
path: e.respond_to?(:path) ? e.path : input_path)
|
|
50
|
+
rescue SystemCallError => e
|
|
51
|
+
raise ConfigurationError.new(e.message, code: "file_error",
|
|
52
|
+
path: e.respond_to?(:path) ? e.path : input_path)
|
|
53
|
+
rescue TypeError, ArgumentError => e
|
|
54
|
+
raise ConfigurationError.new(e.message, code: "file_error", path: input_path)
|
|
55
|
+
end
|
|
56
|
+
if canonical.uniq.length != canonical.length
|
|
57
|
+
raise ConfigurationError.new("RBS signature paths must be unique", code: "duplicate_path")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
canonical.sort.freeze
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def normalize_targets(targets)
|
|
64
|
+
values = Array(targets)
|
|
65
|
+
raise ConfigurationError.new("provide at least one RBS target", code: "targets_required") if values.empty?
|
|
66
|
+
|
|
67
|
+
values.each do |target|
|
|
68
|
+
next if valid_target?(target)
|
|
69
|
+
|
|
70
|
+
message = "target must use ClassName#instance_method or " \
|
|
71
|
+
"ClassName.singleton_method syntax: #{target.inspect}"
|
|
72
|
+
raise ConfigurationError.new(message, code: "invalid_target")
|
|
73
|
+
end
|
|
74
|
+
if values.uniq.length != values.length
|
|
75
|
+
raise ConfigurationError.new("RBS targets must be unique", code: "duplicate_target")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
values.sort.freeze
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def valid_target?(target)
|
|
82
|
+
return false unless target.is_a?(String)
|
|
83
|
+
|
|
84
|
+
TargetName.parse(target)
|
|
85
|
+
true
|
|
86
|
+
rescue ConfigurationError
|
|
87
|
+
false
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def load_dependency
|
|
91
|
+
require "rbs"
|
|
92
|
+
::RBS
|
|
93
|
+
rescue LoadError => e
|
|
94
|
+
raise ConfigurationError.new("RBS support requires the optional rbs gem (4.2.0): #{e.message}",
|
|
95
|
+
code: "rbs_dependency_missing")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def verify_version!(rbs)
|
|
99
|
+
return if rbs::VERSION == VERSION
|
|
100
|
+
|
|
101
|
+
raise ConfigurationError.new("RBS #{VERSION} is required; found #{rbs::VERSION}",
|
|
102
|
+
code: "unsupported_rbs_version")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def parse(path, rbs)
|
|
106
|
+
content = File.read(path, encoding: "UTF-8")
|
|
107
|
+
unless content.valid_encoding?
|
|
108
|
+
raise ConfigurationError.new("RBS signature file must be valid UTF-8", code: "rbs_parse_error", path: path)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
buffer = rbs::Buffer.new(name: Pathname(path), content: content)
|
|
112
|
+
buffer, directives, declarations = rbs::Parser.parse_signature(buffer)
|
|
113
|
+
[buffer, directives, declarations].freeze
|
|
114
|
+
rescue rbs::ParsingError => e
|
|
115
|
+
source = Source.new(path: path, line: e.location.start_line, kind: "rbs") if e.location
|
|
116
|
+
raise ConfigurationError.new(e.message, code: "rbs_parse_error", path: path, source: source)
|
|
117
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
118
|
+
raise ConfigurationError.new(e.message, code: "file_error", path: path)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def resolve_environment(buffers, rbs)
|
|
122
|
+
environment = rbs::Environment.from_loader(rbs::EnvironmentLoader.new)
|
|
123
|
+
buffers.each do |buffer, directives, declarations|
|
|
124
|
+
environment.add_source(rbs::Source::RBS.new(buffer, directives, declarations))
|
|
125
|
+
end
|
|
126
|
+
environment.resolve_type_names
|
|
127
|
+
rescue rbs::BaseError => e
|
|
128
|
+
location = e.respond_to?(:location) ? e.location : nil
|
|
129
|
+
buffer = location&.buffer
|
|
130
|
+
path = buffer.name.to_s if buffer
|
|
131
|
+
source = Source.new(path: path, line: location.start_line, kind: "rbs") if location && path
|
|
132
|
+
raise ConfigurationError.new(e.message, code: "rbs_parse_error", path: path, source: source)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpecProof
|
|
4
|
+
module Sources
|
|
5
|
+
# Projects selected RBS methods into SpecProof's deliberately small scalar model.
|
|
6
|
+
class RBSProjector
|
|
7
|
+
def initialize(target, environment, paths, rbs, declaration: nil)
|
|
8
|
+
@target = target
|
|
9
|
+
@environment = environment
|
|
10
|
+
@paths = paths
|
|
11
|
+
@rbs = rbs
|
|
12
|
+
@declaration = declaration
|
|
13
|
+
@exclusions = declaration&.exclusions || {}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def facts
|
|
17
|
+
owner, method_name, kind = TargetName.parse(@target)
|
|
18
|
+
declarations = owner_declarations(owner, @environment, @paths)
|
|
19
|
+
method, visibility = select_method(@target, method_name.to_s, kind, declarations, @rbs)
|
|
20
|
+
validate_method!(@target, method, visibility, generic_owner?(owner, @environment), kind)
|
|
21
|
+
input_facts(@target, method, @rbs, @exclusions)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
attr_reader :rbs
|
|
27
|
+
|
|
28
|
+
def owner_declarations(owner, environment, paths)
|
|
29
|
+
entries = environment.class_decls.select { |name, _entry| normalize_type_name(name) == owner }
|
|
30
|
+
entries.flat_map do |_name, entry|
|
|
31
|
+
owner_declarations = entry.each_decl.select do |declaration|
|
|
32
|
+
declaration.is_a?(rbs::AST::Declarations::Class) || declaration.is_a?(rbs::AST::Declarations::Module)
|
|
33
|
+
end
|
|
34
|
+
owner_declarations.select do |declaration|
|
|
35
|
+
supplied_declaration?(declaration, paths)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def generic_owner?(owner, environment)
|
|
41
|
+
environment.class_decls.any? do |name, entry|
|
|
42
|
+
normalize_type_name(name) == owner && entry.each_decl.any? do |declaration|
|
|
43
|
+
(declaration.is_a?(rbs::AST::Declarations::Class) ||
|
|
44
|
+
declaration.is_a?(rbs::AST::Declarations::Module)) && !declaration.type_params.empty?
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def select_method(target, method_name, kind, declarations, rbs)
|
|
50
|
+
matches = matching_members(method_name, kind, declarations, rbs)
|
|
51
|
+
module_method = matching_module_method(method_name, declarations, rbs) if kind == :instance
|
|
52
|
+
unsupported_signature!(target, module_method, "module owners are not supported") if module_method
|
|
53
|
+
if matches[:unsupported_kind]
|
|
54
|
+
unsupported_signature!(target, matches[:unsupported_kind],
|
|
55
|
+
"singleton_instance method signatures are not supported")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if matches[:attribute]
|
|
59
|
+
unsupported_signature!(target, matches[:attribute], "generated attribute methods are not supported")
|
|
60
|
+
end
|
|
61
|
+
alias_member = matches[:"#{kind}_alias"]
|
|
62
|
+
unsupported_signature!(target, alias_member, "method aliases are not supported") if alias_member
|
|
63
|
+
methods = matches[kind]
|
|
64
|
+
if methods.empty?
|
|
65
|
+
if kind == :instance && !matches[:singleton].empty?
|
|
66
|
+
unsupported_signature!(target, matches[:singleton].first.first,
|
|
67
|
+
"singleton methods are not supported for instance targets")
|
|
68
|
+
end
|
|
69
|
+
raise ConfigurationError.new("RBS target was not found: #{target}", code: "rbs_target_not_found")
|
|
70
|
+
end
|
|
71
|
+
if methods.length > 1
|
|
72
|
+
unsupported_signature!(target, methods.first.first,
|
|
73
|
+
"method is declared more than once across class reopenings")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
methods.first
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def matching_members(method_name, kind, declarations, rbs)
|
|
80
|
+
matches = { instance: [], singleton: [], instance_alias: nil, singleton_alias: nil, attribute: nil,
|
|
81
|
+
unsupported_kind: nil }
|
|
82
|
+
declarations.each do |declaration|
|
|
83
|
+
visibility = :public
|
|
84
|
+
declaration.members.each do |member|
|
|
85
|
+
case member
|
|
86
|
+
when rbs::AST::Members::Public then visibility = :public
|
|
87
|
+
when rbs::AST::Members::Private then visibility = :private
|
|
88
|
+
when rbs::AST::Members::MethodDefinition
|
|
89
|
+
add_matching_method(matches, member, visibility, method_name)
|
|
90
|
+
when rbs::AST::Members::Alias
|
|
91
|
+
if member.new_name.to_s == method_name
|
|
92
|
+
key = member.instance? ? :instance_alias : :singleton_alias
|
|
93
|
+
matches[key] = member
|
|
94
|
+
end
|
|
95
|
+
when rbs::AST::Members::Attribute
|
|
96
|
+
if member.kind == :singleton_instance && generated_attribute?(member, method_name, rbs)
|
|
97
|
+
matches[:unsupported_kind] = member
|
|
98
|
+
elsif member.kind == kind && generated_attribute?(member, method_name, rbs)
|
|
99
|
+
matches[:attribute] = member
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
matches
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def add_matching_method(matches, member, visibility, method_name)
|
|
108
|
+
return unless member.name.to_s == method_name
|
|
109
|
+
|
|
110
|
+
case member.kind
|
|
111
|
+
when :instance then matches[:instance] << [member, visibility]
|
|
112
|
+
when :singleton then matches[:singleton] << [member, visibility]
|
|
113
|
+
when :singleton_instance then matches[:unsupported_kind] = member
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def generated_attribute?(member, method_name, rbs)
|
|
118
|
+
reader = member.name.to_s == method_name && !member.is_a?(rbs::AST::Members::AttrWriter)
|
|
119
|
+
writer = method_name == "#{member.name}=" && !member.is_a?(rbs::AST::Members::AttrReader)
|
|
120
|
+
%i[instance singleton].include?(member.kind) && (reader || writer)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def matching_module_method(method_name, declarations, rbs)
|
|
124
|
+
declarations.grep(rbs::AST::Declarations::Module).lazy.flat_map(&:members).find do |member|
|
|
125
|
+
member.respond_to?(:name) && member.name.to_s == method_name
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def validate_method!(target, method, visibility, generic_owner, kind)
|
|
130
|
+
method_kind = kind == :singleton ? "singleton" : "instance"
|
|
131
|
+
if (method.visibility || visibility) != :public
|
|
132
|
+
unsupported_signature!(target, method, "only directly declared public #{method_kind} methods are supported")
|
|
133
|
+
end
|
|
134
|
+
expected_kind = kind == :singleton ? :singleton : :instance
|
|
135
|
+
unsupported_signature!(target, method, "unsupported method kind") unless method.kind == expected_kind
|
|
136
|
+
if method.overloading? || method.overloads.length != 1
|
|
137
|
+
unsupported_signature!(target, method, "overloaded method signatures are not supported")
|
|
138
|
+
end
|
|
139
|
+
if method.overloads.any? { |overload| !overload.annotations.empty? }
|
|
140
|
+
unsupported_signature!(target, method, "method type extension annotations are not supported")
|
|
141
|
+
end
|
|
142
|
+
unsupported_signature!(target, method, "generic owner classes are not supported") if generic_owner
|
|
143
|
+
|
|
144
|
+
method_type = method.overloads.fetch(0).method_type
|
|
145
|
+
unsupported_signature!(target, method, "generic methods are not supported") if method_type.type_params.any?
|
|
146
|
+
function = method_type.type
|
|
147
|
+
return unless method_type.block || function.forwarding? || function.rest_positionals || function.rest_keywords
|
|
148
|
+
|
|
149
|
+
unsupported_signature!(target, method, "blocks, rest arguments, and forwarding are not supported")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def input_facts(target, method, rbs, exclusions)
|
|
153
|
+
function = method.overloads.fetch(0).method_type.type
|
|
154
|
+
params = function.required_positionals + function.optional_positionals + function.trailing_positionals
|
|
155
|
+
params.each do |param|
|
|
156
|
+
unless param.name && !param.name.to_s.empty?
|
|
157
|
+
unsupported_signature!(target, method, "every positional parameter must have an explicit name")
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
keyword_params = function.required_keywords.merge(function.optional_keywords)
|
|
161
|
+
duplicate_keywords = function.required_keywords.keys & function.optional_keywords.keys
|
|
162
|
+
unless duplicate_keywords.empty?
|
|
163
|
+
unsupported_signature!(target, method, "keyword parameter names must be unique")
|
|
164
|
+
end
|
|
165
|
+
names = params.map { |param| param.name.to_sym } + keyword_params.keys.map(&:to_sym)
|
|
166
|
+
unsupported_signature!(target, method, "target method must have at least one input") if names.empty?
|
|
167
|
+
unsupported_signature!(target, method, "parameter names must be unique") if names.uniq.length != names.length
|
|
168
|
+
|
|
169
|
+
named_params = params.map { |param| [param.name.to_sym, param] } +
|
|
170
|
+
keyword_params.map { |name, param| [name.to_sym, param] }
|
|
171
|
+
validate_exclusions!(target, exclusions, names)
|
|
172
|
+
validate_declared_names!(target, method, @declaration, names)
|
|
173
|
+
named_params.map do |name, param|
|
|
174
|
+
next if exclusions.key?(name)
|
|
175
|
+
|
|
176
|
+
constraint = map_type(param.type, target, name, rbs)
|
|
177
|
+
InputTypeFact.new(target: target, input: name, constraint: constraint,
|
|
178
|
+
source: source_for(param))
|
|
179
|
+
end.compact
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def validate_exclusions!(target, exclusions, names)
|
|
183
|
+
unknown = exclusions.keys - names
|
|
184
|
+
return if unknown.empty?
|
|
185
|
+
|
|
186
|
+
name = unknown.min_by(&:to_s)
|
|
187
|
+
exclusion = exclusions.fetch(name)
|
|
188
|
+
source = exclusion.respond_to?(:source) ? exclusion.source : nil
|
|
189
|
+
raise ConfigurationError.new("excluded RBS input #{name.inspect} is not declared by RBS for #{target}",
|
|
190
|
+
code: "rbs_input_mismatch", source: source, path: source&.path)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def validate_declared_names!(target, method, declaration, names)
|
|
194
|
+
return unless declaration
|
|
195
|
+
|
|
196
|
+
unknown = declaration.inputs.keys - names
|
|
197
|
+
return if unknown.empty?
|
|
198
|
+
|
|
199
|
+
name = unknown.min_by(&:to_s)
|
|
200
|
+
input = declaration.inputs.fetch(name)
|
|
201
|
+
source = input.source
|
|
202
|
+
rbs_source = source_for(method)
|
|
203
|
+
location = rbs_source ? " at #{rbs_source.path}:#{rbs_source.line}" : ""
|
|
204
|
+
raise ConfigurationError.new("DSL input #{name.inspect} is not declared by RBS for #{target}#{location}",
|
|
205
|
+
code: "rbs_input_mismatch", source: source, path: source&.path)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def map_type(type, target, input, rbs)
|
|
209
|
+
case type
|
|
210
|
+
when rbs::Types::Union
|
|
211
|
+
TypeConstraints.union(type.types.map { |alternative| map_type(alternative, target, input, rbs) })
|
|
212
|
+
when rbs::Types::Optional
|
|
213
|
+
TypeConstraints.union([
|
|
214
|
+
map_type(type.type, target, input, rbs),
|
|
215
|
+
ScalarTypeConstraint.new(integer_any: false, values: [nil])
|
|
216
|
+
])
|
|
217
|
+
when rbs::Types::Bases::Bool
|
|
218
|
+
ScalarTypeConstraint.new(integer_any: false, values: [true, false])
|
|
219
|
+
when rbs::Types::Bases::Nil
|
|
220
|
+
ScalarTypeConstraint.new(integer_any: false, values: [nil])
|
|
221
|
+
when rbs::Types::Literal
|
|
222
|
+
value = type.literal
|
|
223
|
+
supported = value.nil? || value.equal?(true) || value.equal?(false) ||
|
|
224
|
+
value.is_a?(Integer) || value.is_a?(Symbol) || Domains.supported_string?(value)
|
|
225
|
+
unsupported_type!(target, input, type) unless supported
|
|
226
|
+
ScalarTypeConstraint.new(integer_any: false, values: [value])
|
|
227
|
+
when rbs::Types::ClassInstance
|
|
228
|
+
if builtin_integer?(type) && type.args.empty?
|
|
229
|
+
ScalarTypeConstraint.new(integer_any: true, values: [])
|
|
230
|
+
else
|
|
231
|
+
unsupported_type!(target, input, type)
|
|
232
|
+
end
|
|
233
|
+
else
|
|
234
|
+
unsupported_type!(target, input, type)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def builtin_integer?(type)
|
|
239
|
+
name = type.name
|
|
240
|
+
name.name == :Integer && name.namespace.absolute? && name.namespace.path.empty?
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def unsupported_type!(target, input, type)
|
|
244
|
+
raise ConfigurationError.new("unsupported RBS input type #{type}: #{target} / input #{input}",
|
|
245
|
+
code: "unsupported_rbs_type", source: source_for(type))
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def unsupported_signature!(target, method, reason)
|
|
249
|
+
raise ConfigurationError.new("#{reason}: #{target}", code: "unsupported_rbs_signature",
|
|
250
|
+
source: source_for(method))
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def source_for(node)
|
|
254
|
+
location = node.respond_to?(:location) ? node.location : node
|
|
255
|
+
Source.new(path: location.buffer.name.to_s, line: location.start_line, kind: "rbs")
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def supplied_declaration?(declaration, paths)
|
|
259
|
+
paths.include?(File.expand_path(declaration.location.buffer.name.to_s))
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def normalize_type_name(name)
|
|
263
|
+
name.to_s.sub(/\A::/, "")
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
private_constant :RBSProjector
|
|
267
|
+
end
|
|
268
|
+
end
|