rail_verdict 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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/NOTICE +14 -0
- data/README.md +192 -0
- data/exe/railverdict +6 -0
- data/exe/railverdict-minitest-reporter.rb +90 -0
- data/lib/rail_verdict/analyzers/_shared.rb +54 -0
- data/lib/rail_verdict/analyzers/bundler_audit.rb +186 -0
- data/lib/rail_verdict/analyzers/minitest.rb +312 -0
- data/lib/rail_verdict/analyzers/rspec.rb +274 -0
- data/lib/rail_verdict/analyzers/rubocop.rb +289 -0
- data/lib/rail_verdict/analyzers/simplecov.rb +178 -0
- data/lib/rail_verdict/baseline.rb +151 -0
- data/lib/rail_verdict/check.rb +415 -0
- data/lib/rail_verdict/cli.rb +471 -0
- data/lib/rail_verdict/comparison.rb +216 -0
- data/lib/rail_verdict/configuration.rb +165 -0
- data/lib/rail_verdict/contracts/analyzer_result.rb +138 -0
- data/lib/rail_verdict/contracts/finding.rb +154 -0
- data/lib/rail_verdict/contracts/gate_result.rb +171 -0
- data/lib/rail_verdict/coverage/changed_line_evaluator.rb +70 -0
- data/lib/rail_verdict/doctor.rb +132 -0
- data/lib/rail_verdict/errors.rb +17 -0
- data/lib/rail_verdict/findings_command.rb +24 -0
- data/lib/rail_verdict/fingerprint.rb +72 -0
- data/lib/rail_verdict/git/command.rb +48 -0
- data/lib/rail_verdict/git/context.rb +361 -0
- data/lib/rail_verdict/git/diff_parser.rb +200 -0
- data/lib/rail_verdict/git/errors.rb +16 -0
- data/lib/rail_verdict/init.rb +38 -0
- data/lib/rail_verdict/intelligence/ai_analysis.rb +82 -0
- data/lib/rail_verdict/intelligence/ai_failure.rb +44 -0
- data/lib/rail_verdict/intelligence/budget.rb +36 -0
- data/lib/rail_verdict/intelligence/cache.rb +74 -0
- data/lib/rail_verdict/intelligence/context_builder.rb +92 -0
- data/lib/rail_verdict/intelligence/manifest.rb +70 -0
- data/lib/rail_verdict/intelligence/orchestrator.rb +77 -0
- data/lib/rail_verdict/intelligence/prompt.rb +28 -0
- data/lib/rail_verdict/intelligence/provider.rb +14 -0
- data/lib/rail_verdict/intelligence/providers/fake_provider.rb +31 -0
- data/lib/rail_verdict/intelligence/providers/openai_compat.rb +119 -0
- data/lib/rail_verdict/intelligence/redactor.rb +51 -0
- data/lib/rail_verdict/intelligence/secret_detector.rb +45 -0
- data/lib/rail_verdict/intelligence/source_reader.rb +49 -0
- data/lib/rail_verdict/intelligence.rb +11 -0
- data/lib/rail_verdict/mcp/cache.rb +233 -0
- data/lib/rail_verdict/mcp/repository_root.rb +31 -0
- data/lib/rail_verdict/mcp/serializers.rb +98 -0
- data/lib/rail_verdict/mcp/server.rb +110 -0
- data/lib/rail_verdict/mcp/tools/build_repair_packet.rb +85 -0
- data/lib/rail_verdict/mcp/tools/explain.rb +100 -0
- data/lib/rail_verdict/mcp/tools/get_finding.rb +139 -0
- data/lib/rail_verdict/mcp/tools/investigate.rb +107 -0
- data/lib/rail_verdict/mcp/tools/list_findings.rb +119 -0
- data/lib/rail_verdict/mcp/tools/verify.rb +137 -0
- data/lib/rail_verdict/mcp/tools/verify_repair.rb +105 -0
- data/lib/rail_verdict/mcp/validators.rb +81 -0
- data/lib/rail_verdict/mcp.rb +7 -0
- data/lib/rail_verdict/process_runner.rb +292 -0
- data/lib/rail_verdict/rails_context/classifier.rb +57 -0
- data/lib/rail_verdict/rails_context/confidence.rb +13 -0
- data/lib/rail_verdict/rails_context/constant_inferencer.rb +56 -0
- data/lib/rail_verdict/rails_context/context.rb +115 -0
- data/lib/rail_verdict/rails_context/detector.rb +141 -0
- data/lib/rail_verdict/rails_context/limits.rb +17 -0
- data/lib/rail_verdict/rails_context/resolvers/association_extractor.rb +48 -0
- data/lib/rail_verdict/rails_context/resolvers/policy_resolver.rb +45 -0
- data/lib/rail_verdict/rails_context/resolvers/route_resolver.rb +40 -0
- data/lib/rail_verdict/rails_context/resolvers/route_scanner.rb +55 -0
- data/lib/rail_verdict/rails_context/resolvers/schema_resolver.rb +90 -0
- data/lib/rail_verdict/rails_context/resolvers/test_candidates.rb +75 -0
- data/lib/rail_verdict/rails_context/resolvers/view_resolver.rb +45 -0
- data/lib/rail_verdict/rails_context/schema_parser.rb +50 -0
- data/lib/rail_verdict/rails_context.rb +21 -0
- data/lib/rail_verdict/repair/boundary.rb +36 -0
- data/lib/rail_verdict/repair/command.rb +89 -0
- data/lib/rail_verdict/repair/constraints.rb +31 -0
- data/lib/rail_verdict/repair/context_assembler.rb +301 -0
- data/lib/rail_verdict/repair/diff_context.rb +51 -0
- data/lib/rail_verdict/repair/packet.rb +158 -0
- data/lib/rail_verdict/repair/prompt_renderer.rb +27 -0
- data/lib/rail_verdict/repair/verification_plan.rb +45 -0
- data/lib/rail_verdict/repair/verifier.rb +84 -0
- data/lib/rail_verdict/reporters/console.rb +107 -0
- data/lib/rail_verdict/reporters/github_annotations.rb +104 -0
- data/lib/rail_verdict/reporters/json_reporter.rb +19 -0
- data/lib/rail_verdict/reporters/sarif.rb +138 -0
- data/lib/rail_verdict/run_context.rb +99 -0
- data/lib/rail_verdict/schema_validator.rb +85 -0
- data/lib/rail_verdict/strict_yaml.rb +134 -0
- data/lib/rail_verdict/verification/policy.rb +359 -0
- data/lib/rail_verdict/version.rb +5 -0
- data/lib/rail_verdict/waiver.rb +67 -0
- data/lib/rail_verdict/waiver_store.rb +59 -0
- data/lib/rail_verdict.rb +64 -0
- data/schemas/ai-analysis-v1.schema.json +54 -0
- data/schemas/baseline-v1.schema.json +108 -0
- data/schemas/configuration-v1.1.schema.json +150 -0
- data/schemas/configuration-v1.2.schema.json +182 -0
- data/schemas/configuration-v1.3.schema.json +198 -0
- data/schemas/configuration-v1.4.schema.json +146 -0
- data/schemas/configuration-v1.schema.json +72 -0
- data/schemas/coverage-v1.schema.json +51 -0
- data/schemas/finding-v1.schema.json +131 -0
- data/schemas/minitest-reporter-v1.schema.json +113 -0
- data/schemas/repair-packet-v1.schema.json +364 -0
- data/schemas/result-v1.schema.json +378 -0
- data/schemas/waiver-v1.schema.json +45 -0
- data/schemas/waivers-v1.schema.json +67 -0
- metadata +192 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailVerdict
|
|
4
|
+
class ProcessRunner
|
|
5
|
+
class DirectoryError < RailVerdict::Error; end
|
|
6
|
+
|
|
7
|
+
DEFAULT_TIMEOUT_SECONDS = 30.0
|
|
8
|
+
DEFAULT_MAX_STDOUT_BYTES = 4 * 1024 * 1024
|
|
9
|
+
DEFAULT_MAX_STDERR_BYTES = 64 * 1024
|
|
10
|
+
READ_CHUNK_BYTES = 64 * 1024
|
|
11
|
+
TERM_GRACE_SECONDS = 0.1
|
|
12
|
+
REAP_POLL_SECONDS = 0.01
|
|
13
|
+
|
|
14
|
+
ENV_ALLOWLIST = %w[
|
|
15
|
+
PATH HOME GEM_HOME GEM_PATH LANG RAILVERDICT_MINITEST_OUTPUT
|
|
16
|
+
].freeze
|
|
17
|
+
FORCED_ENV = { "LC_ALL" => "C.UTF-8", "TZ" => "UTC" }.freeze
|
|
18
|
+
|
|
19
|
+
RunResult = Struct.new(
|
|
20
|
+
:status, :exit_code, :signal, :stdout, :stderr,
|
|
21
|
+
:stdout_truncated, :stderr_truncated, :detail,
|
|
22
|
+
keyword_init: true
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
def run(executable, argv, chdir:, timeout_seconds: DEFAULT_TIMEOUT_SECONDS,
|
|
27
|
+
max_stdout_bytes: DEFAULT_MAX_STDOUT_BYTES, max_stderr_bytes: DEFAULT_MAX_STDERR_BYTES)
|
|
28
|
+
directory = verify_directory(chdir)
|
|
29
|
+
argv = argv.map { |element| validate_argv_element(element) }
|
|
30
|
+
env = build_env
|
|
31
|
+
|
|
32
|
+
stdout_read, stdout_write = IO.pipe
|
|
33
|
+
stderr_read, stderr_write = IO.pipe
|
|
34
|
+
pid = nil
|
|
35
|
+
|
|
36
|
+
begin
|
|
37
|
+
pid = Process.spawn(
|
|
38
|
+
env,
|
|
39
|
+
[executable, File.basename(executable)],
|
|
40
|
+
*argv,
|
|
41
|
+
chdir: directory,
|
|
42
|
+
pgroup: true,
|
|
43
|
+
in: :close,
|
|
44
|
+
out: stdout_write,
|
|
45
|
+
err: stderr_write
|
|
46
|
+
)
|
|
47
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ENOTDIR, ArgumentError => error
|
|
48
|
+
return spawn_failure_result(error)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
registry.register(pid)
|
|
52
|
+
stdout_write.close
|
|
53
|
+
stderr_write.close
|
|
54
|
+
|
|
55
|
+
execute_child(pid, stdout_read, stderr_read, timeout_seconds, max_stdout_bytes, max_stderr_bytes)
|
|
56
|
+
ensure
|
|
57
|
+
registry.unregister(pid) if pid
|
|
58
|
+
[stdout_read, stdout_write, stderr_read, stderr_write].compact.each do |io|
|
|
59
|
+
io.close unless io.closed?
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def registry
|
|
64
|
+
@registry ||= Registry.new
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def build_env
|
|
68
|
+
env = ENV.keys.to_h { |key| [key, nil] }
|
|
69
|
+
ENV_ALLOWLIST.each do |key|
|
|
70
|
+
value = ENV[key]
|
|
71
|
+
env[key] = value unless value.nil?
|
|
72
|
+
end
|
|
73
|
+
env.merge(FORCED_ENV)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def spawn_failure_result(error)
|
|
79
|
+
RunResult.new(
|
|
80
|
+
status: :spawn_failed,
|
|
81
|
+
exit_code: nil,
|
|
82
|
+
signal: nil,
|
|
83
|
+
stdout: String.new(encoding: Encoding::UTF_8),
|
|
84
|
+
stderr: String.new(encoding: Encoding::UTF_8),
|
|
85
|
+
stdout_truncated: false,
|
|
86
|
+
stderr_truncated: false,
|
|
87
|
+
detail: "#{error.class}: #{error.message}"
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def verify_directory(chdir)
|
|
92
|
+
directory = File.realpath(chdir)
|
|
93
|
+
raise DirectoryError, "working directory is not a usable directory: #{chdir}" unless File.directory?(directory)
|
|
94
|
+
|
|
95
|
+
directory
|
|
96
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ENOTDIR
|
|
97
|
+
raise DirectoryError, "working directory is not usable: #{chdir}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def validate_argv_element(element)
|
|
101
|
+
raise ArgumentError, "argv elements must be strings" unless element.is_a?(String)
|
|
102
|
+
raise ArgumentError, "argv elements cannot contain NUL bytes" if element.include?("\u0000")
|
|
103
|
+
|
|
104
|
+
element
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def execute_child(pid, stdout_read, stderr_read, timeout_seconds, max_stdout_bytes, max_stderr_bytes)
|
|
108
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds
|
|
109
|
+
output = { buffer: String.new(encoding: Encoding::BINARY), cap: max_stdout_bytes, truncated: false }
|
|
110
|
+
errors = { buffer: String.new(encoding: Encoding::BINARY), cap: max_stderr_bytes, truncated: false }
|
|
111
|
+
streams = { stdout_read => output, stderr_read => errors }
|
|
112
|
+
|
|
113
|
+
timed_out = drain_streams(streams, deadline) == :timed_out
|
|
114
|
+
status, timed_out = await_child(pid, deadline, timed_out)
|
|
115
|
+
build_result(status, timed_out, output, errors, timeout_seconds)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def drain_streams(streams, deadline)
|
|
119
|
+
open_streams = streams.keys
|
|
120
|
+
|
|
121
|
+
until open_streams.empty?
|
|
122
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
123
|
+
return :timed_out if remaining <= 0
|
|
124
|
+
|
|
125
|
+
ready = IO.select(open_streams, nil, nil, remaining)
|
|
126
|
+
next unless ready
|
|
127
|
+
|
|
128
|
+
ready.first.each do |io|
|
|
129
|
+
next unless read_stream(io, streams.fetch(io))
|
|
130
|
+
|
|
131
|
+
io.close
|
|
132
|
+
open_streams.delete(io)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def read_stream(io, stream_state)
|
|
140
|
+
loop do
|
|
141
|
+
chunk = io.read_nonblock(READ_CHUNK_BYTES, exception: false)
|
|
142
|
+
return true if chunk.nil?
|
|
143
|
+
return false if chunk == :wait_readable
|
|
144
|
+
|
|
145
|
+
stream_state[:buffer] << chunk
|
|
146
|
+
if stream_state[:buffer].bytesize > stream_state.fetch(:cap)
|
|
147
|
+
stream_state[:truncated] = true
|
|
148
|
+
return true
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def await_child(pid, deadline, already_timed_out)
|
|
154
|
+
loop do
|
|
155
|
+
if already_timed_out || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
156
|
+
terminate_process_group(pid)
|
|
157
|
+
_, status = Process.waitpid2(pid)
|
|
158
|
+
return [status, true]
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
reaped_pid, status = Process.waitpid2(pid, Process::WNOHANG)
|
|
162
|
+
return [status, false] if reaped_pid
|
|
163
|
+
|
|
164
|
+
sleep REAP_POLL_SECONDS
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def build_result(status, timed_out, output, errors, timeout_seconds)
|
|
169
|
+
if timed_out
|
|
170
|
+
return RunResult.new(
|
|
171
|
+
status: :timed_out,
|
|
172
|
+
exit_code: nil,
|
|
173
|
+
signal: nil,
|
|
174
|
+
stdout: encode_output(output[:buffer]),
|
|
175
|
+
stderr: encode_output(errors[:buffer]),
|
|
176
|
+
stdout_truncated: output[:truncated],
|
|
177
|
+
stderr_truncated: errors[:truncated],
|
|
178
|
+
detail: "process exceeded the #{timeout_seconds}s monotonic timeout and was terminated"
|
|
179
|
+
)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
if status.exited?
|
|
183
|
+
RunResult.new(
|
|
184
|
+
status: :exited,
|
|
185
|
+
exit_code: status.exitstatus,
|
|
186
|
+
signal: nil,
|
|
187
|
+
stdout: encode_output(output[:buffer]),
|
|
188
|
+
stderr: encode_output(errors[:buffer]),
|
|
189
|
+
stdout_truncated: output[:truncated],
|
|
190
|
+
stderr_truncated: errors[:truncated],
|
|
191
|
+
detail: nil
|
|
192
|
+
)
|
|
193
|
+
else
|
|
194
|
+
signal_name = signal_name_for(status)
|
|
195
|
+
RunResult.new(
|
|
196
|
+
status: :signaled,
|
|
197
|
+
exit_code: nil,
|
|
198
|
+
signal: signal_name,
|
|
199
|
+
stdout: encode_output(output[:buffer]),
|
|
200
|
+
stderr: encode_output(errors[:buffer]),
|
|
201
|
+
stdout_truncated: output[:truncated],
|
|
202
|
+
stderr_truncated: errors[:truncated],
|
|
203
|
+
detail: "process terminated by signal #{signal_name}"
|
|
204
|
+
)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def signal_name_for(status)
|
|
209
|
+
return nil unless status.signaled?
|
|
210
|
+
|
|
211
|
+
Signal.signame(status.termsig)
|
|
212
|
+
rescue RuntimeError
|
|
213
|
+
"SIG#{status.termsig}"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def encode_output(bytes)
|
|
217
|
+
bytes.dup.force_encoding(Encoding::UTF_8).scrub("\uFFFD").freeze
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def terminate_process_group(pid)
|
|
221
|
+
signal_group(pid, "TERM")
|
|
222
|
+
grace_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + TERM_GRACE_SECONDS
|
|
223
|
+
while process_group_alive?(pid) && Process.clock_gettime(Process::CLOCK_MONOTONIC) < grace_deadline
|
|
224
|
+
sleep REAP_POLL_SECONDS
|
|
225
|
+
end
|
|
226
|
+
signal_group(pid, "KILL") if process_group_alive?(pid)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def signal_group(pid, signal)
|
|
230
|
+
Process.kill(signal, -pid)
|
|
231
|
+
rescue Errno::ESRCH, Errno::EINVAL
|
|
232
|
+
begin
|
|
233
|
+
Process.kill(signal, pid)
|
|
234
|
+
rescue Errno::ESRCH
|
|
235
|
+
nil
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def process_group_alive?(pid)
|
|
240
|
+
Process.kill(0, -pid)
|
|
241
|
+
true
|
|
242
|
+
rescue Errno::ESRCH
|
|
243
|
+
false
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
class Registry
|
|
248
|
+
def initialize
|
|
249
|
+
@pids = {}
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def register(pid)
|
|
253
|
+
@pids[pid] = true
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def unregister(pid)
|
|
257
|
+
@pids.delete(pid)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def size
|
|
261
|
+
@pids.size
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def terminate_all
|
|
265
|
+
pids = @pids.keys
|
|
266
|
+
return if pids.empty?
|
|
267
|
+
|
|
268
|
+
pids.each { |pid| signal_group(pid, "TERM") }
|
|
269
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + ProcessRunner::TERM_GRACE_SECONDS
|
|
270
|
+
sleep 0.01 while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
|
|
271
|
+
pids.each do |pid|
|
|
272
|
+
signal_group(pid, "KILL")
|
|
273
|
+
end
|
|
274
|
+
# The active runner owns waitpid; the signal path must not reap the
|
|
275
|
+
# child before its normal cleanup resumes.
|
|
276
|
+
@pids.clear
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
private
|
|
280
|
+
|
|
281
|
+
def signal_group(pid, signal)
|
|
282
|
+
Process.kill(signal, -pid)
|
|
283
|
+
rescue Errno::ESRCH, Errno::EINVAL, Errno::EPERM
|
|
284
|
+
begin
|
|
285
|
+
Process.kill(signal, pid)
|
|
286
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
287
|
+
nil
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailVerdict
|
|
4
|
+
module RailsContext
|
|
5
|
+
module Classifier
|
|
6
|
+
KINDS = %w[model controller job mailer helper service policy component view route schema migration test spec config lib unknown].freeze
|
|
7
|
+
|
|
8
|
+
PREFIX_MAP = {
|
|
9
|
+
"app/models/" => "model",
|
|
10
|
+
"app/controllers/" => "controller",
|
|
11
|
+
"app/jobs/" => "job",
|
|
12
|
+
"app/mailers/" => "mailer",
|
|
13
|
+
"app/helpers/" => "helper",
|
|
14
|
+
"app/services/" => "service",
|
|
15
|
+
"app/policies/" => "policy",
|
|
16
|
+
"app/components/" => "component",
|
|
17
|
+
"app/views/" => "view"
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def self.classify(path)
|
|
21
|
+
normalized = normalize(path)
|
|
22
|
+
return "unknown" if normalized.empty?
|
|
23
|
+
|
|
24
|
+
PREFIX_MAP.each do |prefix, kind|
|
|
25
|
+
return kind if normalized.start_with?(prefix)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
case normalized
|
|
29
|
+
when "config/routes.rb"
|
|
30
|
+
"route"
|
|
31
|
+
when "db/schema.rb"
|
|
32
|
+
"schema"
|
|
33
|
+
when /\Adb\/migrate\//
|
|
34
|
+
"migration"
|
|
35
|
+
when /\Atest\//
|
|
36
|
+
"test"
|
|
37
|
+
when /\Aspec\//
|
|
38
|
+
"spec"
|
|
39
|
+
when /\Aconfig\//
|
|
40
|
+
"config"
|
|
41
|
+
when /\Alib\//
|
|
42
|
+
"lib"
|
|
43
|
+
else
|
|
44
|
+
"unknown"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.normalize(path)
|
|
49
|
+
raw = path.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?").strip
|
|
50
|
+
raw = raw.delete_prefix("./").delete_prefix("/")
|
|
51
|
+
raw = raw.unicode_normalize(:nfc) if raw.respond_to?(:unicode_normalize)
|
|
52
|
+
raw.gsub(%r{//+}, "/")
|
|
53
|
+
end
|
|
54
|
+
private_class_method :normalize
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailVerdict
|
|
4
|
+
module RailsContext
|
|
5
|
+
module ConstantInferencer
|
|
6
|
+
def self.infer(kind:, path:)
|
|
7
|
+
normalized = path.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?").strip
|
|
8
|
+
normalized = normalized.delete_prefix("./").delete_prefix("/")
|
|
9
|
+
normalized = normalized.unicode_normalize(:nfc) if normalized.respond_to?(:unicode_normalize)
|
|
10
|
+
|
|
11
|
+
internal = case kind
|
|
12
|
+
when "model"
|
|
13
|
+
strip_prefix(normalized, "app/models/")
|
|
14
|
+
when "controller"
|
|
15
|
+
strip_prefix(normalized, "app/controllers/")
|
|
16
|
+
when "job"
|
|
17
|
+
strip_prefix(normalized, "app/jobs/")
|
|
18
|
+
when "mailer"
|
|
19
|
+
strip_prefix(normalized, "app/mailers/")
|
|
20
|
+
when "helper"
|
|
21
|
+
strip_prefix(normalized, "app/helpers/")
|
|
22
|
+
when "service"
|
|
23
|
+
strip_prefix(normalized, "app/services/")
|
|
24
|
+
when "policy"
|
|
25
|
+
strip_prefix(normalized, "app/policies/")
|
|
26
|
+
when "component"
|
|
27
|
+
strip_prefix(normalized, "app/components/")
|
|
28
|
+
else
|
|
29
|
+
return nil
|
|
30
|
+
end
|
|
31
|
+
return nil if internal.nil? || internal.empty?
|
|
32
|
+
return nil unless internal.end_with?(".rb")
|
|
33
|
+
|
|
34
|
+
bare = internal.delete_suffix(".rb")
|
|
35
|
+
parts = bare.split("/").reject(&:empty?)
|
|
36
|
+
return nil if parts.empty?
|
|
37
|
+
|
|
38
|
+
parts.map { |segment| camelize(segment) }.join("::")
|
|
39
|
+
rescue StandardError
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.strip_prefix(path, prefix)
|
|
44
|
+
return nil unless path.start_with?(prefix)
|
|
45
|
+
|
|
46
|
+
path[prefix.length..]
|
|
47
|
+
end
|
|
48
|
+
private_class_method :strip_prefix
|
|
49
|
+
|
|
50
|
+
def self.camelize(segment)
|
|
51
|
+
segment.split("_").map { |word| word.empty? ? "" : word[0].upcase + word[1..].to_s }.join
|
|
52
|
+
end
|
|
53
|
+
private_class_method :camelize
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailVerdict
|
|
4
|
+
module RailsContext
|
|
5
|
+
class Context
|
|
6
|
+
attr_reader :detected, :scope, :entries, :unresolved
|
|
7
|
+
|
|
8
|
+
def self.build(repository_root:, git_context: nil)
|
|
9
|
+
root = File.realpath(repository_root.to_s) rescue repository_root.to_s
|
|
10
|
+
detected = Detector.detect(repository_root: root)
|
|
11
|
+
scope = git_context ? "changed" : "full"
|
|
12
|
+
|
|
13
|
+
if git_context.nil?
|
|
14
|
+
return new(detected: detected, scope: scope, entries: [], unresolved: [])
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
changed = git_context.changed_files.map(&:path).compact.sort
|
|
18
|
+
rails_changed = changed.select { |path| Classifier.classify(path) != "unknown" }
|
|
19
|
+
rails_changed = rails_changed.reject do |path|
|
|
20
|
+
full = File.join(root, path)
|
|
21
|
+
File.file?(full) && File.size(full) > Limits::MAX_FILE_BYTES rescue false
|
|
22
|
+
end.first(Limits::MAX_FILES)
|
|
23
|
+
|
|
24
|
+
entries = rails_changed.map do |source_path|
|
|
25
|
+
kind = Classifier.classify(source_path)
|
|
26
|
+
constant = ConstantInferencer.infer(kind: kind, path: source_path)
|
|
27
|
+
related = []
|
|
28
|
+
unresolved = []
|
|
29
|
+
|
|
30
|
+
related.concat(Resolvers::TestCandidates.for(repository_root: root, kind: kind, path: source_path))
|
|
31
|
+
related.concat(Resolvers::PolicyResolver.for(repository_root: root, kind: kind, path: source_path, constant: constant))
|
|
32
|
+
related.concat(Resolvers::ViewResolver.for(repository_root: root, kind: kind, path: source_path, constant: constant))
|
|
33
|
+
related.concat(Resolvers::SchemaResolver.for(repository_root: root, kind: kind, constant: constant, path: source_path))
|
|
34
|
+
if kind == "model"
|
|
35
|
+
assocs = Resolvers::AssociationExtractor.extract(repository_root: root, path: source_path)
|
|
36
|
+
assocs.each do |assoc|
|
|
37
|
+
related << { "path" => source_path, "relationship" => "association:#{assoc['macro']} :#{assoc['name']}", "confidence" => assoc["confidence"], "provenance" => assoc["provenance"] }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
if kind == "controller" || source_path == "config/routes.rb"
|
|
41
|
+
related.concat(Resolvers::RouteResolver.for(repository_root: root, kind: kind, constant: constant))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
related = related.first(Limits::MAX_RELATED).sort_by { |item| [item["path"], item["relationship"]] }
|
|
45
|
+
|
|
46
|
+
scan = kind == "controller" ? Resolvers::RouteScanner.scan(repository_root: root) : nil
|
|
47
|
+
if scan && scan[:unresolved]
|
|
48
|
+
unresolved << { "source_path" => source_path, "reason" => "dynamic_route" }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
{
|
|
52
|
+
"source_path" => source_path,
|
|
53
|
+
"kind" => kind,
|
|
54
|
+
"constant" => constant,
|
|
55
|
+
"confidence" => constant ? "exact" : "conventional",
|
|
56
|
+
"provenance" => "path:#{source_path}",
|
|
57
|
+
"related" => related,
|
|
58
|
+
"unresolved" => unresolved
|
|
59
|
+
}
|
|
60
|
+
end.sort_by { |entry| entry["source_path"] }
|
|
61
|
+
|
|
62
|
+
unresolved_global = []
|
|
63
|
+
route_scan = Resolvers::RouteScanner.scan(repository_root: root)
|
|
64
|
+
if route_scan[:unresolved]
|
|
65
|
+
unresolved_global << { "source_path" => "config/routes.rb", "reason" => "dynamic_dsl" }
|
|
66
|
+
end
|
|
67
|
+
has_structure = File.file?(File.join(root, "db/structure.sql"))
|
|
68
|
+
if has_structure
|
|
69
|
+
unresolved_global << { "source_path" => "db/structure.sql", "reason" => "structure_sql_not_parsed" }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
new(detected: detected, scope: scope, entries: entries, unresolved: unresolved_global)
|
|
73
|
+
rescue StandardError
|
|
74
|
+
fallback_detected = begin; Detector.detect(repository_root: root); rescue StandardError; {}; end
|
|
75
|
+
new(detected: fallback_detected, scope: git_context ? "changed" : "full", entries: [], unresolved: [])
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def initialize(detected:, scope:, entries:, unresolved:)
|
|
79
|
+
@detected = deep_freeze(detected)
|
|
80
|
+
@scope = scope.dup.freeze
|
|
81
|
+
@entries = deep_freeze(entries)
|
|
82
|
+
@unresolved = deep_freeze(unresolved)
|
|
83
|
+
freeze
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def to_h
|
|
87
|
+
result = {
|
|
88
|
+
"detected" => detected,
|
|
89
|
+
"scope" => scope,
|
|
90
|
+
"entries" => entries
|
|
91
|
+
}
|
|
92
|
+
result["unresolved"] = unresolved unless unresolved.empty?
|
|
93
|
+
result
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def deep_freeze(value)
|
|
99
|
+
case value
|
|
100
|
+
when Hash
|
|
101
|
+
value.each { |key, child| key.freeze if key.is_a?(String); deep_freeze(child) }
|
|
102
|
+
value.freeze
|
|
103
|
+
when Array
|
|
104
|
+
value.each { |child| deep_freeze(child) }
|
|
105
|
+
value.freeze
|
|
106
|
+
when String
|
|
107
|
+
value.freeze
|
|
108
|
+
else
|
|
109
|
+
value
|
|
110
|
+
end
|
|
111
|
+
value
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailVerdict
|
|
4
|
+
module RailsContext
|
|
5
|
+
module Detector
|
|
6
|
+
def self.detect(repository_root:)
|
|
7
|
+
root = File.realpath(repository_root.to_s) rescue repository_root.to_s
|
|
8
|
+
lockfile = read_text(File.join(root, "Gemfile.lock"), Limits::MAX_FILE_BYTES)
|
|
9
|
+
ruby_version = lockfile ? lockfile[RunContext::GEMFILE_LOCK_RUBY, 1] : nil
|
|
10
|
+
rails_version = lockfile ? lockfile[RunContext::GEMFILE_LOCK_RAILS, 1] : nil
|
|
11
|
+
{
|
|
12
|
+
"ruby_version" => ruby_version,
|
|
13
|
+
"target_ruby_version" => ruby_version,
|
|
14
|
+
"rails_version" => rails_version,
|
|
15
|
+
"test_framework" => detect_test_framework(root, lockfile),
|
|
16
|
+
"database_adapter" => detect_database_adapter(root, lockfile),
|
|
17
|
+
"dependencies" => parse_dependencies(lockfile),
|
|
18
|
+
"structure" => detect_structure(root)
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.detect_test_framework(root, lockfile)
|
|
23
|
+
has_spec_dir = File.directory?(File.join(root, "spec"))
|
|
24
|
+
has_test_dir = File.directory?(File.join(root, "test"))
|
|
25
|
+
lock = lockfile.to_s
|
|
26
|
+
has_rspec = lock.include?("rspec-rails (") || lock.include?("rspec-core (")
|
|
27
|
+
has_minitest = lock.include?("minitest (")
|
|
28
|
+
if has_rspec && (has_spec_dir || lock.include?("rspec"))
|
|
29
|
+
return has_test_dir && has_minitest ? "both" : "rspec"
|
|
30
|
+
end
|
|
31
|
+
if has_test_dir
|
|
32
|
+
return "minitest"
|
|
33
|
+
end
|
|
34
|
+
if has_spec_dir
|
|
35
|
+
return "rspec"
|
|
36
|
+
end
|
|
37
|
+
"unknown"
|
|
38
|
+
end
|
|
39
|
+
private_class_method :detect_test_framework
|
|
40
|
+
|
|
41
|
+
def self.detect_database_adapter(root, lockfile)
|
|
42
|
+
db_yml = read_text(File.join(root, "config/database.yml"), Limits::DATABASE_YML_MAX_BYTES)
|
|
43
|
+
if db_yml
|
|
44
|
+
begin
|
|
45
|
+
data = StrictYaml.parse(db_yml, File.join(root, "config/database.yml"))
|
|
46
|
+
adapter = extract_adapter(data)
|
|
47
|
+
return adapter if adapter
|
|
48
|
+
rescue StandardError
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
lock = lockfile.to_s
|
|
53
|
+
return "postgresql" if lock.include?("pg (")
|
|
54
|
+
return "mysql2" if lock.include?("mysql2 (")
|
|
55
|
+
return "sqlite3" if lock.include?("sqlite3 (")
|
|
56
|
+
return "trilogy" if lock.include?("trilogy (")
|
|
57
|
+
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
private_class_method :detect_database_adapter
|
|
61
|
+
|
|
62
|
+
def self.extract_adapter(data)
|
|
63
|
+
return nil unless data.is_a?(Hash)
|
|
64
|
+
|
|
65
|
+
%w[development test production].each do |env|
|
|
66
|
+
section = data[env]
|
|
67
|
+
next unless section.is_a?(Hash)
|
|
68
|
+
|
|
69
|
+
adapter = section["adapter"]
|
|
70
|
+
return adapter.to_s.strip if adapter.is_a?(String) && !adapter.strip.empty?
|
|
71
|
+
end
|
|
72
|
+
default = data["default"]
|
|
73
|
+
if default.is_a?(Hash)
|
|
74
|
+
adapter = default["adapter"]
|
|
75
|
+
return adapter.to_s.strip if adapter.is_a?(String) && !adapter.strip.empty?
|
|
76
|
+
end
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
private_class_method :extract_adapter
|
|
80
|
+
|
|
81
|
+
def self.parse_dependencies(lockfile)
|
|
82
|
+
return [] unless lockfile
|
|
83
|
+
|
|
84
|
+
gems = []
|
|
85
|
+
lockfile.each_line do |line|
|
|
86
|
+
next unless line.match?(/\A\s{4}[a-z0-9_\-]+\s+\(/)
|
|
87
|
+
|
|
88
|
+
match = line.match(/\A\s{4}([a-z0-9_\-]+)\s+\(([^)]+)\)/)
|
|
89
|
+
next unless match
|
|
90
|
+
|
|
91
|
+
gems << { "name" => match[1], "version" => match[2].strip }
|
|
92
|
+
break if gems.length >= Limits::DEPENDENCIES_MAX
|
|
93
|
+
end
|
|
94
|
+
gems
|
|
95
|
+
end
|
|
96
|
+
private_class_method :parse_dependencies
|
|
97
|
+
|
|
98
|
+
def self.detect_structure(root)
|
|
99
|
+
counts = {}
|
|
100
|
+
counts["app_models"] = count_files(root, "app/models/**/*.rb")
|
|
101
|
+
counts["app_controllers"] = count_files(root, "app/controllers/**/*.rb")
|
|
102
|
+
counts["app_jobs"] = count_files(root, "app/jobs/**/*.rb")
|
|
103
|
+
counts["app_mailers"] = count_files(root, "app/mailers/**/*.rb")
|
|
104
|
+
counts["app_helpers"] = count_files(root, "app/helpers/**/*.rb")
|
|
105
|
+
counts["app_views"] = count_files(root, "app/views/**/*")
|
|
106
|
+
counts["has_routes"] = File.file?(File.join(root, "config/routes.rb"))
|
|
107
|
+
counts["has_schema"] = File.file?(File.join(root, "db/schema.rb"))
|
|
108
|
+
counts["has_structure_sql"] = File.file?(File.join(root, "db/structure.sql"))
|
|
109
|
+
counts["has_app"] = File.directory?(File.join(root, "app"))
|
|
110
|
+
counts
|
|
111
|
+
rescue StandardError
|
|
112
|
+
{}
|
|
113
|
+
end
|
|
114
|
+
private_class_method :detect_structure
|
|
115
|
+
|
|
116
|
+
def self.count_files(root, pattern)
|
|
117
|
+
paths = Dir.glob(File.join(root, pattern))
|
|
118
|
+
paths.count { |path| File.file?(path) }
|
|
119
|
+
rescue StandardError
|
|
120
|
+
0
|
|
121
|
+
end
|
|
122
|
+
private_class_method :count_files
|
|
123
|
+
|
|
124
|
+
def self.read_text(path, max_bytes)
|
|
125
|
+
return nil unless File.file?(path)
|
|
126
|
+
return nil if File.size(path) > max_bytes rescue nil
|
|
127
|
+
|
|
128
|
+
text = File.binread(path)
|
|
129
|
+
return nil if text.bytesize > max_bytes
|
|
130
|
+
|
|
131
|
+
text.force_encoding(Encoding::UTF_8)
|
|
132
|
+
return nil unless text.valid_encoding?
|
|
133
|
+
|
|
134
|
+
text
|
|
135
|
+
rescue StandardError
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
private_class_method :read_text
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailVerdict
|
|
4
|
+
module RailsContext
|
|
5
|
+
module Limits
|
|
6
|
+
MAX_FILE_BYTES = 1_048_576
|
|
7
|
+
MAX_FILES = 500
|
|
8
|
+
MAX_RELATED = 20
|
|
9
|
+
MAX_SCHEMA_TABLES = 100
|
|
10
|
+
MAX_COLUMNS_PER_TABLE = 500
|
|
11
|
+
MAX_ASSOCIATIONS = 50
|
|
12
|
+
ROUTES_MAX_BYTES = 524_288
|
|
13
|
+
DATABASE_YML_MAX_BYTES = 16_384
|
|
14
|
+
DEPENDENCIES_MAX = 200
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|