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,241 @@
1
+ require "digest"
2
+ require "json"
3
+ require "pathname"
4
+ require "securerandom"
5
+ require "time"
6
+ require "rails_proof/ai_test_identity"
7
+
8
+ module RailsProof
9
+ class ReviewStore
10
+ attr_reader :root
11
+
12
+ def initialize(root:)
13
+ @root = Pathname.new(root)
14
+ end
15
+
16
+ def save(
17
+ concern:,
18
+ test_output:,
19
+ test_file_path:,
20
+ test_class_name:,
21
+ target_path: nil
22
+ )
23
+ review_directory.mkpath
24
+
25
+ timestamp = Time.now.utc.iso8601
26
+ fingerprint = target_fingerprint(target_path)
27
+
28
+ existing_path = find_existing_review(
29
+ concern: concern,
30
+ target_path: target_path,
31
+ test_file_path: test_file_path,
32
+ target_fingerprint: fingerprint
33
+ )
34
+
35
+ if existing_path
36
+ update_existing_review(
37
+ path: existing_path,
38
+ concern: concern,
39
+ test_output: test_output,
40
+ timestamp: timestamp
41
+ )
42
+
43
+ return existing_path
44
+ end
45
+
46
+ path = review_directory.join(
47
+ review_filename(
48
+ test_class_name: test_class_name,
49
+ name: concern_name(concern)
50
+ )
51
+ )
52
+
53
+ path.write(
54
+ JSON.pretty_generate(
55
+ review_record(
56
+ concern: concern,
57
+ test_output: test_output,
58
+ test_file_path: test_file_path,
59
+ test_class_name: test_class_name,
60
+ target_path: target_path,
61
+ target_fingerprint: fingerprint,
62
+ timestamp: timestamp
63
+ )
64
+ ) + "\n"
65
+ )
66
+
67
+ path
68
+ end
69
+
70
+ def outstanding_findings(
71
+ target_path:,
72
+ test_file_path:
73
+ )
74
+ fingerprint = target_fingerprint(target_path)
75
+
76
+ return [] unless fingerprint
77
+
78
+ review_paths.filter_map do |path|
79
+ record = read_record(path)
80
+
81
+ next unless record
82
+ next unless record["status"] == "needs_review"
83
+ next unless record["target_path"] == target_path
84
+ next unless record["test_file_path"] == test_file_path
85
+ next unless record["target_fingerprint"] == fingerprint
86
+
87
+ record
88
+ end
89
+ end
90
+
91
+ private
92
+
93
+ def review_directory
94
+ root.join(".rails_proof/review")
95
+ end
96
+
97
+ def review_paths
98
+ return [] unless review_directory.directory?
99
+
100
+ review_directory.glob("*.json")
101
+ end
102
+
103
+ def review_filename(test_class_name:, name:)
104
+ timestamp = Time.now.utc.strftime("%Y%m%dT%H%M%S")
105
+ slug = slugify("#{test_class_name}-#{name}")
106
+ token = SecureRandom.hex(4)
107
+
108
+ "#{timestamp}-#{slug}-#{token}.json"
109
+ end
110
+
111
+ def slugify(value)
112
+ value
113
+ .to_s
114
+ .downcase
115
+ .gsub(/[^a-z0-9]+/, "-")
116
+ .gsub(/\A-+|-+\z/, "")
117
+ .slice(0, 80)
118
+ end
119
+
120
+ def review_record(
121
+ concern:,
122
+ test_output:,
123
+ test_file_path:,
124
+ test_class_name:,
125
+ target_path:,
126
+ target_fingerprint:,
127
+ timestamp:
128
+ )
129
+ {
130
+ version: 3,
131
+ status: "needs_review",
132
+ created_at: timestamp,
133
+ last_seen_at: timestamp,
134
+ occurrences: 1,
135
+ target_path: target_path,
136
+ target_fingerprint: target_fingerprint,
137
+ test_file_path: test_file_path,
138
+ test_class_name: test_class_name,
139
+ kind: concern_kind(concern),
140
+ name: concern_name(concern),
141
+ reason: concern_reason(concern),
142
+ test_code: concern_test_code(concern),
143
+ test_fingerprint:
144
+ RailsProof::AiTestIdentity.test_fingerprint(
145
+ concern_test_code(concern)
146
+ ),
147
+ test_output: test_output
148
+ }
149
+ end
150
+
151
+ def update_existing_review(
152
+ path:,
153
+ concern:,
154
+ test_output:,
155
+ timestamp:
156
+ )
157
+ record = read_record(path)
158
+
159
+ return unless record
160
+
161
+ record["last_seen_at"] = timestamp
162
+ record["occurrences"] =
163
+ record.fetch("occurrences", 1).to_i + 1
164
+ record["kind"] = concern_kind(concern)
165
+ record["name"] = concern_name(concern)
166
+ record["reason"] = concern_reason(concern)
167
+ record["test_code"] = concern_test_code(concern)
168
+ record["test_fingerprint"] =
169
+ RailsProof::AiTestIdentity.test_fingerprint(
170
+ concern_test_code(concern)
171
+ )
172
+ record["test_output"] = test_output
173
+
174
+ path.write(
175
+ JSON.pretty_generate(record) + "\n"
176
+ )
177
+ end
178
+
179
+ def find_existing_review(
180
+ concern:,
181
+ target_path:,
182
+ test_file_path:,
183
+ target_fingerprint:
184
+ )
185
+ return nil unless target_fingerprint
186
+
187
+ review_paths.find do |path|
188
+ record = read_record(path)
189
+
190
+ next false unless record
191
+ next false unless record["status"] == "needs_review"
192
+ next false unless record["target_path"] == target_path
193
+ next false unless record["test_file_path"] == test_file_path
194
+ next false unless(
195
+ record["target_fingerprint"] == target_fingerprint
196
+ )
197
+
198
+ RailsProof::AiTestIdentity.same?(
199
+ first_name: concern_name(concern),
200
+ first_test_code: concern_test_code(concern),
201
+ second_name: record["name"],
202
+ second_test_code: record["test_code"]
203
+ )
204
+ end
205
+ end
206
+
207
+ def concern_name(concern)
208
+ concern[:name] || concern["name"]
209
+ end
210
+
211
+ def concern_reason(concern)
212
+ concern[:reason] || concern["reason"]
213
+ end
214
+
215
+ def concern_test_code(concern)
216
+ concern[:test_code] || concern["test_code"]
217
+ end
218
+
219
+ def target_fingerprint(target_path)
220
+ return nil if target_path.blank?
221
+
222
+ path = root.join(target_path)
223
+
224
+ return nil unless path.file?
225
+
226
+ Digest::SHA256.hexdigest(path.read)
227
+ end
228
+
229
+ def concern_kind(concern)
230
+ kind = concern[:kind] || concern["kind"]
231
+
232
+ kind&.to_s
233
+ end
234
+
235
+ def read_record(path)
236
+ JSON.parse(path.read)
237
+ rescue JSON::ParserError, Errno::ENOENT
238
+ nil
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,145 @@
1
+ require "pathname"
2
+ require "active_support/core_ext/string/inflections"
3
+
4
+ module RailsProof
5
+ class TargetDiscovery
6
+ Target = Struct.new(
7
+ :type,
8
+ :path,
9
+ :class_name,
10
+ keyword_init: true
11
+ )
12
+
13
+ SUPPORTED_DIRECTORIES = {
14
+ "app/models" => :model,
15
+ "app/controllers" => :controller
16
+ }.freeze
17
+
18
+ attr_reader :root, :scope
19
+
20
+ def initialize(root:, scope: nil)
21
+ @root = Pathname.new(root)
22
+ @scope = scope
23
+ end
24
+
25
+ def targets
26
+ @targets ||= if normalized_scope
27
+ discover_scope(normalized_scope)
28
+ else
29
+ discover_all
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def normalized_scope
36
+ return @normalized_scope if defined?(@normalized_scope)
37
+
38
+ value = scope.to_s.strip
39
+
40
+ @normalized_scope = if value.empty?
41
+ nil
42
+ else
43
+ normalize_path(value)
44
+ end
45
+ end
46
+
47
+ def normalize_path(path)
48
+ pathname = Pathname.new(path)
49
+
50
+ if pathname.absolute?
51
+ raise ArgumentError, "RailsProof scope must be relative to the Rails application"
52
+ end
53
+
54
+ normalized = pathname.cleanpath.to_s.delete_prefix("./")
55
+
56
+ if normalized == ".." || normalized.start_with?("../")
57
+ raise ArgumentError, "RailsProof scope must stay inside the Rails application"
58
+ end
59
+
60
+ normalized
61
+ end
62
+
63
+ def discover_scope(path)
64
+ absolute_path = root.join(path)
65
+
66
+ if absolute_path.file?
67
+ [target_for_file(path)]
68
+ elsif absolute_path.directory?
69
+ discover_directory(path)
70
+ else
71
+ raise ArgumentError, "RailsProof target not found: #{path}"
72
+ end
73
+ end
74
+
75
+ def discover_all
76
+ SUPPORTED_DIRECTORIES.keys.flat_map do |directory|
77
+ next [] unless root.join(directory).directory?
78
+
79
+ discover_directory(directory)
80
+ end
81
+ end
82
+
83
+ def discover_directory(directory)
84
+ type = type_for_path(directory)
85
+
86
+ unless type
87
+ raise ArgumentError, "Unsupported RailsProof target: #{directory}"
88
+ end
89
+
90
+ pattern = root.join(directory, "**", "*.rb").to_s
91
+
92
+ Dir.glob(pattern).sort.filter_map do |absolute_path|
93
+ relative_path = Pathname.new(absolute_path)
94
+ .relative_path_from(root)
95
+ .to_s
96
+
97
+ next if ignored_path?(relative_path)
98
+
99
+ target_for_file(relative_path)
100
+ end
101
+ end
102
+
103
+ def target_for_file(path)
104
+ type = type_for_path(path)
105
+
106
+ unless type && path.end_with?(".rb")
107
+ raise ArgumentError, "Unsupported RailsProof target: #{path}"
108
+ end
109
+
110
+ if ignored_path?(path)
111
+ raise ArgumentError, "RailsProof does not inspect base or concern files: #{path}"
112
+ end
113
+
114
+ Target.new(
115
+ type: type,
116
+ path: path,
117
+ class_name: class_name_for(path, type)
118
+ )
119
+ end
120
+
121
+ def type_for_path(path)
122
+ SUPPORTED_DIRECTORIES.each do |directory, type|
123
+ return type if path == directory || path.start_with?("#{directory}/")
124
+ end
125
+
126
+ nil
127
+ end
128
+
129
+ def ignored_path?(path)
130
+ path == "app/models/application_record.rb" ||
131
+ path.start_with?("app/models/concerns/") ||
132
+ path == "app/controllers/application_controller.rb" ||
133
+ path.start_with?("app/controllers/concerns/")
134
+ end
135
+
136
+ def class_name_for(path, type)
137
+ prefix = type == :model ? "app/models/" : "app/controllers/"
138
+
139
+ path
140
+ .delete_prefix(prefix)
141
+ .delete_suffix(".rb")
142
+ .camelize
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,84 @@
1
+ module RailsProof
2
+ class TestCoveragePlan
3
+ attr_reader :model_test_plan, :test_inspector
4
+
5
+ def initialize(model_test_plan, test_inspector)
6
+ @model_test_plan = model_test_plan
7
+ @test_inspector = test_inspector
8
+ end
9
+
10
+ def covered_concerns
11
+ @covered_concerns ||= model_test_plan.concerns.select do |concern|
12
+ covered?(concern)
13
+ end
14
+ end
15
+
16
+ def missing_concerns
17
+ @missing_concerns ||= model_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
+ case concern[:type]
46
+ when :association
47
+ association_covered?(test_name, concern)
48
+ when :validation
49
+ validation_covered?(test_name, concern)
50
+ else
51
+ false
52
+ end
53
+ end
54
+
55
+ def association_covered?(test_name, concern)
56
+ macro = normalize(concern[:macro].to_s)
57
+ name = normalize(concern[:name].to_s)
58
+
59
+ test_name.include?(macro) &&
60
+ test_name.include?(name)
61
+ end
62
+
63
+ def validation_covered?(test_name, concern)
64
+ attribute = normalize(concern[:attribute].to_s)
65
+
66
+ test_name.include?(attribute) &&
67
+ (
68
+ test_name.include?("presence") ||
69
+ test_name.include?("present") ||
70
+ test_name.include?("required") ||
71
+ test_name.include?("requires")
72
+ )
73
+ end
74
+
75
+ def normalize(value)
76
+ value
77
+ .downcase
78
+ .tr("_", " ")
79
+ .gsub(/[^a-z0-9\s]/, " ")
80
+ .split
81
+ .join(" ")
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,54 @@
1
+ module RailsProof
2
+ class TestInspector
3
+ attr_reader :source
4
+
5
+ def initialize(source)
6
+ @source = source
7
+ end
8
+
9
+ def test_cases
10
+ @test_cases ||= rails_style_tests + method_style_tests
11
+ end
12
+
13
+ def count
14
+ test_cases.count
15
+ end
16
+
17
+ def empty?
18
+ test_cases.empty?
19
+ end
20
+
21
+ private
22
+
23
+ def rails_style_tests
24
+ source.each_line.filter_map do |line|
25
+ match = line.match(
26
+ /^\s*test\s*(?:\(\s*)?(["'])(.+?)\1\s*\)?\s+do\b/
27
+ )
28
+
29
+ next unless match
30
+
31
+ {
32
+ style: :rails,
33
+ name: match[2]
34
+ }
35
+ end
36
+ end
37
+
38
+ def method_style_tests
39
+ source.each_line.filter_map do |line|
40
+ match = line.match(/^\s*def\s+(test_[a-zA-Z0-9_!?]+)/)
41
+
42
+ next unless match
43
+
44
+ method_name = match[1]
45
+
46
+ {
47
+ style: :method,
48
+ name: method_name.delete_prefix("test_").tr("_", " "),
49
+ method_name: method_name
50
+ }
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,136 @@
1
+ require "open3"
2
+ require "pathname"
3
+
4
+ module RailsProof
5
+ class TestRunner
6
+ class Error < StandardError; end
7
+
8
+ Result = Struct.new(
9
+ :passed,
10
+ :output,
11
+ :command,
12
+ keyword_init: true
13
+ ) do
14
+ def passed?
15
+ passed
16
+ end
17
+ end
18
+
19
+ attr_reader :root, :test_file_path
20
+
21
+ def initialize(root:, test_file_path:)
22
+ @root = Pathname.new(root).expand_path
23
+ @test_file_path = Pathname.new(test_file_path)
24
+ end
25
+
26
+ def run
27
+ stdout, stderr, status = Open3.capture3(
28
+ { "RAILS_ENV" => "test" },
29
+ *command,
30
+ chdir: working_directory.to_s
31
+ )
32
+
33
+ output = [stdout, stderr].reject(&:empty?).join
34
+
35
+ raise_unavailable_test_api!(output)
36
+
37
+ Result.new(
38
+ passed: status.success?,
39
+ output: output,
40
+ command: command
41
+ )
42
+ end
43
+
44
+ def command
45
+ @command ||= resolve_runner.fetch(:command)
46
+ end
47
+
48
+ def working_directory
49
+ @working_directory ||= resolve_runner.fetch(:working_directory)
50
+ end
51
+
52
+ private
53
+
54
+ def raise_unavailable_test_api!(output)
55
+ message =
56
+ unavailable_test_api_message(output)
57
+
58
+ return unless message
59
+
60
+ raise Error, message
61
+ end
62
+
63
+ def unavailable_test_api_message(output)
64
+ if output.match?(
65
+ /NoMethodError: undefined method ['`]stub['`]/
66
+ )
67
+ return "candidate uses .stub, but .stub is unavailable in this test environment"
68
+ end
69
+
70
+ if output.match?(
71
+ /NameError: uninitialized constant Minitest::Mock/
72
+ )
73
+ return "candidate uses Minitest::Mock, but Minitest::Mock is unavailable in this test environment"
74
+ end
75
+
76
+ nil
77
+ end
78
+
79
+ def resolve_runner
80
+ @resolve_runner ||= begin
81
+ normal_rails_runner || ancestor_test_runner || raise_runner_error
82
+ end
83
+ end
84
+
85
+ def normal_rails_runner
86
+ rails = root.join("bin/rails")
87
+ test_helper = root.join("test/test_helper.rb")
88
+
89
+ return unless rails.file? && test_helper.file?
90
+
91
+ {
92
+ working_directory: root,
93
+ command: [
94
+ rails.to_s,
95
+ "test",
96
+ test_file_path.to_s
97
+ ]
98
+ }
99
+ end
100
+
101
+ def ancestor_test_runner
102
+ current = root
103
+
104
+ loop do
105
+ runner = current.join("bin/test")
106
+ test_helper = current.join("test/test_helper.rb")
107
+
108
+ if runner.file? && test_helper.file?
109
+ absolute_test_path = root.join(test_file_path).expand_path
110
+ relative_test_path =
111
+ absolute_test_path.relative_path_from(current).to_s
112
+
113
+ return {
114
+ working_directory: current,
115
+ command: [
116
+ runner.to_s,
117
+ relative_test_path
118
+ ]
119
+ }
120
+ end
121
+
122
+ parent = current.parent
123
+ break if parent == current
124
+
125
+ current = parent
126
+ end
127
+
128
+ nil
129
+ end
130
+
131
+ def raise_runner_error
132
+ raise Error,
133
+ "RailsProof could not find a Rails test runner for #{root}"
134
+ end
135
+ end
136
+ end