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.
Files changed (110) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/NOTICE +14 -0
  4. data/README.md +192 -0
  5. data/exe/railverdict +6 -0
  6. data/exe/railverdict-minitest-reporter.rb +90 -0
  7. data/lib/rail_verdict/analyzers/_shared.rb +54 -0
  8. data/lib/rail_verdict/analyzers/bundler_audit.rb +186 -0
  9. data/lib/rail_verdict/analyzers/minitest.rb +312 -0
  10. data/lib/rail_verdict/analyzers/rspec.rb +274 -0
  11. data/lib/rail_verdict/analyzers/rubocop.rb +289 -0
  12. data/lib/rail_verdict/analyzers/simplecov.rb +178 -0
  13. data/lib/rail_verdict/baseline.rb +151 -0
  14. data/lib/rail_verdict/check.rb +415 -0
  15. data/lib/rail_verdict/cli.rb +471 -0
  16. data/lib/rail_verdict/comparison.rb +216 -0
  17. data/lib/rail_verdict/configuration.rb +165 -0
  18. data/lib/rail_verdict/contracts/analyzer_result.rb +138 -0
  19. data/lib/rail_verdict/contracts/finding.rb +154 -0
  20. data/lib/rail_verdict/contracts/gate_result.rb +171 -0
  21. data/lib/rail_verdict/coverage/changed_line_evaluator.rb +70 -0
  22. data/lib/rail_verdict/doctor.rb +132 -0
  23. data/lib/rail_verdict/errors.rb +17 -0
  24. data/lib/rail_verdict/findings_command.rb +24 -0
  25. data/lib/rail_verdict/fingerprint.rb +72 -0
  26. data/lib/rail_verdict/git/command.rb +48 -0
  27. data/lib/rail_verdict/git/context.rb +361 -0
  28. data/lib/rail_verdict/git/diff_parser.rb +200 -0
  29. data/lib/rail_verdict/git/errors.rb +16 -0
  30. data/lib/rail_verdict/init.rb +38 -0
  31. data/lib/rail_verdict/intelligence/ai_analysis.rb +82 -0
  32. data/lib/rail_verdict/intelligence/ai_failure.rb +44 -0
  33. data/lib/rail_verdict/intelligence/budget.rb +36 -0
  34. data/lib/rail_verdict/intelligence/cache.rb +74 -0
  35. data/lib/rail_verdict/intelligence/context_builder.rb +92 -0
  36. data/lib/rail_verdict/intelligence/manifest.rb +70 -0
  37. data/lib/rail_verdict/intelligence/orchestrator.rb +77 -0
  38. data/lib/rail_verdict/intelligence/prompt.rb +28 -0
  39. data/lib/rail_verdict/intelligence/provider.rb +14 -0
  40. data/lib/rail_verdict/intelligence/providers/fake_provider.rb +31 -0
  41. data/lib/rail_verdict/intelligence/providers/openai_compat.rb +119 -0
  42. data/lib/rail_verdict/intelligence/redactor.rb +51 -0
  43. data/lib/rail_verdict/intelligence/secret_detector.rb +45 -0
  44. data/lib/rail_verdict/intelligence/source_reader.rb +49 -0
  45. data/lib/rail_verdict/intelligence.rb +11 -0
  46. data/lib/rail_verdict/mcp/cache.rb +233 -0
  47. data/lib/rail_verdict/mcp/repository_root.rb +31 -0
  48. data/lib/rail_verdict/mcp/serializers.rb +98 -0
  49. data/lib/rail_verdict/mcp/server.rb +110 -0
  50. data/lib/rail_verdict/mcp/tools/build_repair_packet.rb +85 -0
  51. data/lib/rail_verdict/mcp/tools/explain.rb +100 -0
  52. data/lib/rail_verdict/mcp/tools/get_finding.rb +139 -0
  53. data/lib/rail_verdict/mcp/tools/investigate.rb +107 -0
  54. data/lib/rail_verdict/mcp/tools/list_findings.rb +119 -0
  55. data/lib/rail_verdict/mcp/tools/verify.rb +137 -0
  56. data/lib/rail_verdict/mcp/tools/verify_repair.rb +105 -0
  57. data/lib/rail_verdict/mcp/validators.rb +81 -0
  58. data/lib/rail_verdict/mcp.rb +7 -0
  59. data/lib/rail_verdict/process_runner.rb +292 -0
  60. data/lib/rail_verdict/rails_context/classifier.rb +57 -0
  61. data/lib/rail_verdict/rails_context/confidence.rb +13 -0
  62. data/lib/rail_verdict/rails_context/constant_inferencer.rb +56 -0
  63. data/lib/rail_verdict/rails_context/context.rb +115 -0
  64. data/lib/rail_verdict/rails_context/detector.rb +141 -0
  65. data/lib/rail_verdict/rails_context/limits.rb +17 -0
  66. data/lib/rail_verdict/rails_context/resolvers/association_extractor.rb +48 -0
  67. data/lib/rail_verdict/rails_context/resolvers/policy_resolver.rb +45 -0
  68. data/lib/rail_verdict/rails_context/resolvers/route_resolver.rb +40 -0
  69. data/lib/rail_verdict/rails_context/resolvers/route_scanner.rb +55 -0
  70. data/lib/rail_verdict/rails_context/resolvers/schema_resolver.rb +90 -0
  71. data/lib/rail_verdict/rails_context/resolvers/test_candidates.rb +75 -0
  72. data/lib/rail_verdict/rails_context/resolvers/view_resolver.rb +45 -0
  73. data/lib/rail_verdict/rails_context/schema_parser.rb +50 -0
  74. data/lib/rail_verdict/rails_context.rb +21 -0
  75. data/lib/rail_verdict/repair/boundary.rb +36 -0
  76. data/lib/rail_verdict/repair/command.rb +89 -0
  77. data/lib/rail_verdict/repair/constraints.rb +31 -0
  78. data/lib/rail_verdict/repair/context_assembler.rb +301 -0
  79. data/lib/rail_verdict/repair/diff_context.rb +51 -0
  80. data/lib/rail_verdict/repair/packet.rb +158 -0
  81. data/lib/rail_verdict/repair/prompt_renderer.rb +27 -0
  82. data/lib/rail_verdict/repair/verification_plan.rb +45 -0
  83. data/lib/rail_verdict/repair/verifier.rb +84 -0
  84. data/lib/rail_verdict/reporters/console.rb +107 -0
  85. data/lib/rail_verdict/reporters/github_annotations.rb +104 -0
  86. data/lib/rail_verdict/reporters/json_reporter.rb +19 -0
  87. data/lib/rail_verdict/reporters/sarif.rb +138 -0
  88. data/lib/rail_verdict/run_context.rb +99 -0
  89. data/lib/rail_verdict/schema_validator.rb +85 -0
  90. data/lib/rail_verdict/strict_yaml.rb +134 -0
  91. data/lib/rail_verdict/verification/policy.rb +359 -0
  92. data/lib/rail_verdict/version.rb +5 -0
  93. data/lib/rail_verdict/waiver.rb +67 -0
  94. data/lib/rail_verdict/waiver_store.rb +59 -0
  95. data/lib/rail_verdict.rb +64 -0
  96. data/schemas/ai-analysis-v1.schema.json +54 -0
  97. data/schemas/baseline-v1.schema.json +108 -0
  98. data/schemas/configuration-v1.1.schema.json +150 -0
  99. data/schemas/configuration-v1.2.schema.json +182 -0
  100. data/schemas/configuration-v1.3.schema.json +198 -0
  101. data/schemas/configuration-v1.4.schema.json +146 -0
  102. data/schemas/configuration-v1.schema.json +72 -0
  103. data/schemas/coverage-v1.schema.json +51 -0
  104. data/schemas/finding-v1.schema.json +131 -0
  105. data/schemas/minitest-reporter-v1.schema.json +113 -0
  106. data/schemas/repair-packet-v1.schema.json +364 -0
  107. data/schemas/result-v1.schema.json +378 -0
  108. data/schemas/waiver-v1.schema.json +45 -0
  109. data/schemas/waivers-v1.schema.json +67 -0
  110. 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,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailVerdict
4
+ module RailsContext
5
+ module Confidence
6
+ VALUES = %w[exact conventional inferred unresolved].freeze
7
+
8
+ def self.valid?(value)
9
+ VALUES.include?(value.to_s)
10
+ end
11
+ end
12
+ end
13
+ 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