rails_proof 1.0.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.
@@ -0,0 +1,51 @@
1
+ module RailsProof
2
+ class AiTestWriter
3
+ class InvalidConcern < StandardError; end
4
+
5
+ attr_reader :test_class_name, :superclass, :concerns
6
+
7
+ def initialize(test_class_name:, superclass:, concerns:)
8
+ @test_class_name = test_class_name
9
+ @superclass = superclass
10
+ @concerns = concerns
11
+ end
12
+
13
+ def render
14
+ concerns.map do |concern|
15
+ indent(test_code_for(concern), 2)
16
+ end.join("\n\n")
17
+ end
18
+
19
+ def render_test_file
20
+ [
21
+ 'require "test_helper"',
22
+ "",
23
+ "class #{test_class_name} < #{superclass}",
24
+ render,
25
+ "end",
26
+ ""
27
+ ].join("\n")
28
+ end
29
+
30
+ private
31
+
32
+ def test_code_for(concern)
33
+ code = concern[:test_code] || concern["test_code"]
34
+
35
+ unless code.is_a?(String) && code.strip.present?
36
+ raise InvalidConcern,
37
+ "AI test concern must include test_code"
38
+ end
39
+
40
+ code.strip
41
+ end
42
+
43
+ def indent(source, spaces)
44
+ prefix = " " * spaces
45
+
46
+ source.lines.map do |line|
47
+ line.strip.empty? ? line : "#{prefix}#{line}"
48
+ end.join
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,44 @@
1
+ module RailsProof
2
+ class ControllerInspector
3
+ attr_reader :controller_class, :route_set
4
+
5
+ def initialize(controller_class, route_set: Rails.application.routes)
6
+ @controller_class = controller_class
7
+ @route_set = route_set
8
+ end
9
+
10
+ def controller_name
11
+ controller_class.name
12
+ end
13
+
14
+ def controller_path
15
+ controller_class.controller_path
16
+ end
17
+
18
+ def actions
19
+ @actions ||= controller_class.action_methods.sort
20
+ end
21
+
22
+ def routes
23
+ @routes ||= route_set.routes.filter_map do |route|
24
+ defaults = route.defaults
25
+
26
+ next unless defaults[:controller] == controller_path
27
+ next unless actions.include?(defaults[:action])
28
+
29
+ {
30
+ name: route.name,
31
+ verb: route.verb.to_s,
32
+ path: normalize_path(route.path.spec.to_s),
33
+ action: defaults[:action]
34
+ }
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def normalize_path(path)
41
+ path.delete_suffix("(.:format)")
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,79 @@
1
+ module RailsProof
2
+ class ControllerTestCoveragePlan
3
+ attr_reader :controller_test_plan, :test_inspector
4
+
5
+ def initialize(controller_test_plan, test_inspector)
6
+ @controller_test_plan = controller_test_plan
7
+ @test_inspector = test_inspector
8
+ end
9
+
10
+ def covered_concerns
11
+ @covered_concerns ||= controller_test_plan.concerns.select do |concern|
12
+ covered?(concern)
13
+ end
14
+ end
15
+
16
+ def missing_concerns
17
+ @missing_concerns ||= controller_test_plan.concerns.reject do |concern|
18
+ covered?(concern)
19
+ end
20
+ end
21
+
22
+ def covered_count
23
+ covered_concerns.count
24
+ end
25
+
26
+ def missing_count
27
+ missing_concerns.count
28
+ end
29
+
30
+ private
31
+
32
+ def covered?(concern)
33
+ test_names.any? do |test_name|
34
+ matches_concern?(test_name, concern)
35
+ end
36
+ end
37
+
38
+ def test_names
39
+ @test_names ||= test_inspector.test_cases.map do |test_case|
40
+ normalize(test_case[:name])
41
+ end
42
+ end
43
+
44
+ def matches_concern?(test_name, concern)
45
+ return false unless concern[:type] == :controller_response
46
+
47
+ action = normalize(concern[:action])
48
+
49
+ test_name.include?(action) &&
50
+ response_language?(test_name)
51
+ end
52
+
53
+ def response_language?(test_name)
54
+ [
55
+ "get",
56
+ "post",
57
+ "patch",
58
+ "put",
59
+ "delete",
60
+ "response",
61
+ "respond",
62
+ "success",
63
+ "successful"
64
+ ].any? do |word|
65
+ test_name.split.include?(word)
66
+ end
67
+ end
68
+
69
+ def normalize(value)
70
+ value
71
+ .to_s
72
+ .downcase
73
+ .tr("_", " ")
74
+ .gsub(/[^a-z0-9\s]/, " ")
75
+ .split
76
+ .join(" ")
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,28 @@
1
+ module RailsProof
2
+ class ControllerTestPlan
3
+ attr_reader :inspector
4
+
5
+ def initialize(inspector)
6
+ @inspector = inspector
7
+ end
8
+
9
+ def concerns
10
+ @concerns ||= inspector.routes.filter_map do |route|
11
+ next if route[:name].to_s.empty?
12
+
13
+ {
14
+ type: :controller_response,
15
+ verb: route[:verb],
16
+ path: route[:path],
17
+ route_name: route[:name],
18
+ action: route[:action],
19
+ description: "#{route[:verb]} #{route[:path]} responds successfully"
20
+ }
21
+ end
22
+ end
23
+
24
+ def count
25
+ concerns.count
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,66 @@
1
+ module RailsProof
2
+ class ControllerTestWriter
3
+ attr_reader :controller_class_name, :concerns
4
+
5
+ def initialize(controller_class_name:, concerns:)
6
+ @controller_class_name = controller_class_name
7
+ @concerns = concerns
8
+ end
9
+
10
+ def render
11
+ concerns.map do |concern|
12
+ indent(render_concern(concern), 2)
13
+ end.join("\n\n")
14
+ end
15
+
16
+ def render_test_file
17
+ [
18
+ 'require "test_helper"',
19
+ "",
20
+ "class #{controller_class_name}Test < ActionDispatch::IntegrationTest",
21
+ render,
22
+ "end",
23
+ ""
24
+ ].join("\n")
25
+ end
26
+
27
+ private
28
+
29
+ def render_concern(concern)
30
+ case concern[:type]
31
+ when :controller_response
32
+ render_response_test(concern)
33
+ else
34
+ raise ArgumentError,
35
+ "Unsupported controller test concern: #{concern.inspect}"
36
+ end
37
+ end
38
+
39
+ def render_response_test(concern)
40
+ verb = concern.fetch(:verb).downcase
41
+ route_name = concern.fetch(:route_name)
42
+ action = concern.fetch(:action)
43
+
44
+ unless route_name
45
+ raise ArgumentError,
46
+ "Cannot generate controller test without a named route: #{concern.inspect}"
47
+ end
48
+
49
+ <<~RUBY.chomp
50
+ test "should #{verb} #{action}" do
51
+ #{verb} #{route_name}_url
52
+
53
+ assert_response :success
54
+ end
55
+ RUBY
56
+ end
57
+
58
+ def indent(source, spaces)
59
+ prefix = " " * spaces
60
+
61
+ source.lines.map do |line|
62
+ line.strip.empty? ? line : "#{prefix}#{line}"
63
+ end.join
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,50 @@
1
+ module RailsProof
2
+ class ModelInspector
3
+ attr_reader :model_class
4
+
5
+ def initialize(model_class)
6
+ @model_class = model_class
7
+ end
8
+
9
+ def model_name
10
+ model_class.name
11
+ end
12
+
13
+ def table_name
14
+ model_class.table_name
15
+ end
16
+
17
+ def columns
18
+ @columns ||= model_class.columns.map do |column|
19
+ {
20
+ name: column.name,
21
+ type: column.type,
22
+ null: column.null,
23
+ default: column.default
24
+ }
25
+ end
26
+ end
27
+
28
+ def associations
29
+ @associations ||= model_class.reflect_on_all_associations.map do |association|
30
+ {
31
+ macro: association.macro,
32
+ name: association.name,
33
+ class_name: association.class_name,
34
+ foreign_key: association.foreign_key,
35
+ options: association.options
36
+ }
37
+ end
38
+ end
39
+
40
+ def validators
41
+ @validators ||= model_class.validators.map do |validator|
42
+ {
43
+ class_name: validator.class.name,
44
+ attributes: validator.attributes,
45
+ options: validator.options
46
+ }
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,61 @@
1
+ module RailsProof
2
+ class ModelTestPlan
3
+ attr_reader :inspector
4
+
5
+ def initialize(inspector)
6
+ @inspector = inspector
7
+ end
8
+
9
+ def concerns
10
+ @concerns ||= association_concerns + validation_concerns
11
+ end
12
+
13
+ def count
14
+ concerns.count
15
+ end
16
+
17
+ private
18
+
19
+ def association_concerns
20
+ inspector.associations.map do |association|
21
+ {
22
+ type: :association,
23
+ macro: association[:macro],
24
+ name: association[:name],
25
+ description: "#{association[:macro]} :#{association[:name]}"
26
+ }
27
+ end
28
+ end
29
+
30
+ def validation_concerns
31
+ inspector.validators.flat_map do |validator|
32
+ next [] unless presence_validator?(validator)
33
+
34
+ validator[:attributes].filter_map do |attribute|
35
+ next if implicit_belongs_to_presence?(attribute)
36
+
37
+ {
38
+ type: :validation,
39
+ validation: :presence,
40
+ attribute: attribute,
41
+ description: "validates presence of #{attribute}"
42
+ }
43
+ end
44
+ end
45
+ end
46
+
47
+ def presence_validator?(validator)
48
+ validator[:class_name].end_with?("PresenceValidator")
49
+ end
50
+
51
+ def implicit_belongs_to_presence?(attribute)
52
+ belongs_to_association_names.include?(attribute)
53
+ end
54
+
55
+ def belongs_to_association_names
56
+ @belongs_to_association_names ||= inspector.associations.filter_map do |association|
57
+ association[:name] if association[:macro] == :belongs_to
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,189 @@
1
+ require "json"
2
+
3
+ module RailsProof
4
+ class OpenAiClient
5
+ class Error < StandardError; end
6
+
7
+ DEFAULT_MODEL = "gpt-5.6"
8
+
9
+ RESPONSE_SCHEMA = {
10
+ type: :object,
11
+ properties: {
12
+ suggestions: {
13
+ type: :array,
14
+ items: {
15
+ type: :object,
16
+ properties: {
17
+ kind: {
18
+ type: :string,
19
+ enum: %w[coverage contract_check]
20
+ },
21
+ name: { type: :string },
22
+ reason: { type: :string },
23
+ test_code: { type: :string }
24
+ },
25
+ required: %w[kind name reason test_code],
26
+ additionalProperties: false
27
+ }
28
+ }
29
+ },
30
+ required: %w[suggestions],
31
+ additionalProperties: false
32
+ }.freeze
33
+
34
+ attr_reader :model
35
+
36
+ def initialize(
37
+ model: ENV.fetch("RAILSPROOF_OPENAI_MODEL", DEFAULT_MODEL),
38
+ sdk_client: nil
39
+ )
40
+ @model = model
41
+ @sdk_client = sdk_client
42
+ end
43
+
44
+ def suggest_tests(context:)
45
+ response = client.responses.create(
46
+ model: model,
47
+ input: [
48
+ {
49
+ role: :system,
50
+ content: system_prompt
51
+ },
52
+ {
53
+ role: :user,
54
+ content: JSON.generate(context)
55
+ }
56
+ ],
57
+ text: {
58
+ format: {
59
+ type: :json_schema,
60
+ name: "rails_proof_test_suggestions",
61
+ strict: true,
62
+ schema: RESPONSE_SCHEMA
63
+ }
64
+ }
65
+ )
66
+
67
+ parse_suggestions(response.output_text)
68
+ rescue Error
69
+ raise
70
+ rescue StandardError => error
71
+ raise Error, "OpenAI request failed: #{error.message}"
72
+ end
73
+
74
+ private
75
+
76
+ def client
77
+ @sdk_client ||= begin
78
+ require "openai"
79
+
80
+ OpenAI::Client.new
81
+ rescue LoadError
82
+ raise Error,
83
+ "OpenAI client is not installed. Add the openai gem to use AI planning."
84
+ end
85
+ end
86
+
87
+ def system_prompt
88
+ <<~PROMPT
89
+ You are RailsProof's AI test planner and Minitest test generator.
90
+
91
+ Analyze Rails 8.1+ application code for meaningful Minitest coverage
92
+ that deterministic Rails inspection cannot already identify.
93
+
94
+ RailsProof has two kinds of AI-generated tests:
95
+
96
+ 1. coverage
97
+ The implementation and apparent public contract agree. Generate a
98
+ test that verifies meaningful behavior implemented by the source.
99
+
100
+ 2. contract_check
101
+ Strong evidence in the supplied source suggests that the apparent
102
+ public contract may disagree with the implementation. Generate a
103
+ test for the apparent intended contract, even when the current
104
+ implementation is likely to make that test fail.
105
+
106
+ Do not assume that the current implementation is correct merely
107
+ because it is the code that was supplied.
108
+
109
+ Look for strong local evidence of an implementation/contract mismatch,
110
+ including:
111
+ - method names whose semantics conflict with the implementation
112
+ - comments or surrounding code that contradict the implementation
113
+ - predicates whose implementation appears opposite to their name
114
+ - exact/equality semantics implemented as partial matching, or the
115
+ reverse
116
+ - state-changing method names whose implementation changes the wrong
117
+ state
118
+ - error-signaling method names whose implementation silently ignores
119
+ the error condition
120
+ - branches whose outcomes appear reversed relative to the public API
121
+
122
+ A contract_check must be supported by strong evidence in the supplied
123
+ source. Do not invent product requirements or speculate about behavior
124
+ when the intended contract is genuinely ambiguous.
125
+
126
+ If you identify behavior as a possible contract mismatch, do NOT also
127
+ generate a coverage test whose purpose is merely to lock that
128
+ suspicious implementation into the test suite.
129
+
130
+ Suggest only additional tests justified by the supplied source and
131
+ runtime context.
132
+
133
+ Do not duplicate deterministic concerns RailsProof already identified.
134
+
135
+ Do not suggest behavior that is already adequately covered by the
136
+ supplied existing tests.
137
+
138
+ Focus especially on:
139
+ - branches and conditional behavior
140
+ - custom public methods
141
+ - edge cases
142
+ - nil and blank handling
143
+ - state transitions
144
+ - persistence behavior
145
+ - side effects
146
+ - error conditions
147
+ - interactions between application objects
148
+ - possible implementation/contract disagreements
149
+
150
+ For every suggestion, generate the actual Minitest code needed to
151
+ exercise that behavior.
152
+
153
+ Each suggestion must have:
154
+ - kind: either "coverage" or "contract_check"
155
+ - name: a concise Minitest-style test description
156
+ - reason: why that behavior deserves a test
157
+ - test_code: exactly one complete Minitest test block
158
+
159
+ For a contract_check, the reason must explicitly describe:
160
+ - the evidence for the apparent contract
161
+ - how the implementation appears to disagree with that contract
162
+
163
+ test_code must:
164
+ - begin with a Minitest test declaration
165
+ - contain exactly one test block
166
+ - be valid Ruby
167
+ - contain no class declaration
168
+ - contain no require statement
169
+ - contain no Markdown code fences
170
+ - use only application behavior supported by the supplied context
171
+ - avoid assuming fixtures, factories, helpers, or methods not shown
172
+ in the supplied context
173
+ PROMPT
174
+ end
175
+
176
+ def parse_suggestions(output_text)
177
+ parsed = JSON.parse(output_text)
178
+ suggestions = parsed.fetch("suggestions")
179
+
180
+ unless suggestions.is_a?(Array)
181
+ raise Error, "OpenAI response suggestions must be an array"
182
+ end
183
+
184
+ suggestions
185
+ rescue JSON::ParserError, KeyError => error
186
+ raise Error, "Invalid OpenAI response: #{error.message}"
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,4 @@
1
+ module RailsProof
2
+ class Railtie < ::Rails::Railtie
3
+ end
4
+ end