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