quality_gate 0.2.2 → 0.3.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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -0
  3. data/README.md +292 -21
  4. data/docs/codex.md +37 -9
  5. data/docs/releasing.md +16 -4
  6. data/lib/generators/quality_gate/install/install_generator.rb +18 -3
  7. data/lib/generators/quality_gate/install/templates/agents_section.md.tt +13 -2
  8. data/lib/generators/quality_gate/install/templates/codex_fast.rb.tt +36 -0
  9. data/lib/generators/quality_gate/install/templates/codex_hooks.json.tt +1 -0
  10. data/lib/generators/quality_gate/install/templates/codex_verify_stop.rb.tt +28 -0
  11. data/lib/generators/quality_gate/install/templates/quality_gate.yml.tt +6 -0
  12. data/lib/generators/quality_gate/install/templates/quality_gate_fast.rb.tt +16 -3
  13. data/lib/generators/quality_gate/install/templates/quality_gate_workflow.yml.tt +28 -0
  14. data/lib/generators/quality_gate/install/templates/rails_quality_gate.yml.tt +45 -0
  15. data/lib/generators/quality_gate/install/templates/ruby_agents_section.md.tt +13 -2
  16. data/lib/generators/quality_gate/install/templates/ruby_quality_gate.yml.tt +5 -0
  17. data/lib/generators/quality_gate/install/templates/simplecov.rb.tt +1 -0
  18. data/lib/quality_gate/adapters/bundler_audit.rb +28 -3
  19. data/lib/quality_gate/adapters/database_consistency.rb +51 -0
  20. data/lib/quality_gate/adapters/database_consistency_report.rb +61 -0
  21. data/lib/quality_gate/adapters/database_consistency_report_item.rb +122 -0
  22. data/lib/quality_gate/adapters/debride.rb +67 -0
  23. data/lib/quality_gate/adapters/debride_report.rb +80 -0
  24. data/lib/quality_gate/adapters/herb.rb +303 -0
  25. data/lib/quality_gate/adapters/reek.rb +21 -2
  26. data/lib/quality_gate/adapters/rubocop.rb +23 -9
  27. data/lib/quality_gate/adapters/rubycritic.rb +74 -0
  28. data/lib/quality_gate/adapters/rubycritic_report.rb +112 -0
  29. data/lib/quality_gate/adapters/test_suite.rb +12 -1
  30. data/lib/quality_gate/baseline.rb +269 -0
  31. data/lib/quality_gate/baseline_run.rb +80 -0
  32. data/lib/quality_gate/cli.rb +129 -6
  33. data/lib/quality_gate/codex_fast_hook.rb +163 -0
  34. data/lib/quality_gate/codex_patch_files.rb +95 -0
  35. data/lib/quality_gate/codex_stop_hook.rb +102 -0
  36. data/lib/quality_gate/config.rb +20 -6
  37. data/lib/quality_gate/database_consistency_runner.rb +124 -0
  38. data/lib/quality_gate/doctor.rb +217 -0
  39. data/lib/quality_gate/doctor_bounded_file.rb +74 -0
  40. data/lib/quality_gate/doctor_command.rb +130 -0
  41. data/lib/quality_gate/doctor_coverage.rb +113 -0
  42. data/lib/quality_gate/doctor_git.rb +121 -0
  43. data/lib/quality_gate/doctor_hooks.rb +99 -0
  44. data/lib/quality_gate/doctor_launchers.rb +300 -0
  45. data/lib/quality_gate/doctor_path_lookup.rb +52 -0
  46. data/lib/quality_gate/doctor_report.rb +38 -0
  47. data/lib/quality_gate/init_command.rb +3 -1
  48. data/lib/quality_gate/installation.rb +72 -17
  49. data/lib/quality_gate/installer.rb +3 -0
  50. data/lib/quality_gate/reporters/doctor.rb +57 -0
  51. data/lib/quality_gate/reporters/json.rb +18 -1
  52. data/lib/quality_gate/reporters/markdown.rb +11 -1
  53. data/lib/quality_gate/reporters/text.rb +14 -6
  54. data/lib/quality_gate/ruby_profile.rb +121 -2
  55. data/lib/quality_gate/runner.rb +15 -17
  56. data/lib/quality_gate/version.rb +1 -1
  57. data/lib/quality_gate.rb +15 -0
  58. data/sig/quality_gate.rbs +117 -2
  59. metadata +30 -1
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "adapters/simplecov"
5
+ require_relative "doctor_bounded_file"
6
+ require_relative "doctor_report"
7
+
8
+ module QualityGate
9
+ # Inspects configured coverage evidence without running coverage tools.
10
+ class DoctorCoverage
11
+ def initialize(dir:, config:)
12
+ @dir = File.expand_path(dir).freeze
13
+ @config = config
14
+ end
15
+
16
+ def call
17
+ checks = []
18
+ checks << undercover_check if enabled?("undercover")
19
+ checks << simplecov_check if simplecov_configured?
20
+ return [check("coverage", "not_applicable", "No configured coverage evidence is required.")] if checks.empty?
21
+
22
+ checks
23
+ end
24
+
25
+ private
26
+
27
+ def undercover_check
28
+ inspect_artifact("coverage.undercover", Adapters::Undercover::COVERAGE_PATH,
29
+ "Undercover coverage evidence", :undercover_shape?)
30
+ end
31
+
32
+ def simplecov_check
33
+ inspect_artifact("coverage.simplecov", Adapters::SimpleCov::COVERAGE_PATH,
34
+ "SimpleCov coverage evidence", :simplecov_shape?)
35
+ end
36
+
37
+ def inspect_artifact(id, relative_path, label, shape)
38
+ result = DoctorBoundedFile.read(path: File.join(@dir, relative_path))
39
+ return unreadable_artifact(id, label, result.status) unless result.status == :ok
40
+
41
+ parse_artifact(id, label, result.contents, shape)
42
+ end
43
+
44
+ def parse_artifact(id, label, contents, shape)
45
+ document = JSON.parse(contents)
46
+ return ready_artifact(id, label) if method(shape).call(document)
47
+
48
+ invalid_shape(id, label)
49
+ rescue JSON::ParserError
50
+ malformed_json(id, label)
51
+ end
52
+
53
+ def unreadable_artifact(id, label, status)
54
+ message = unreadable_message(status)
55
+ check(id, "unchecked", "#{label} #{message}")
56
+ end
57
+
58
+ def unreadable_message(status)
59
+ return "is missing before the initial suite; run quality_gate verify to create it." if status == :missing
60
+
61
+ "could not be read safely (#{status}); inspect the regular file and permissions."
62
+ end
63
+
64
+ def ready_artifact(id, label)
65
+ message = "#{label} is readable structured evidence only; freshness, wiring, and " \
66
+ "budget compliance were not checked."
67
+ check(id, "ready", message)
68
+ end
69
+
70
+ def invalid_shape(id, label)
71
+ message = "#{label} has an unusable JSON shape; rerun the test suite and " \
72
+ "inspect the formatter output."
73
+ check(id, "warning", message)
74
+ end
75
+
76
+ def malformed_json(id, label)
77
+ message = "#{label} is malformed JSON; rerun the test suite and inspect the " \
78
+ "coverage formatter output."
79
+ check(id, "warning", message)
80
+ end
81
+
82
+ def undercover_shape?(document)
83
+ document.is_a?(Hash) && document["meta"].is_a?(Hash) && document["coverage"].is_a?(Hash)
84
+ end
85
+
86
+ def simplecov_shape?(document)
87
+ result = document["result"] if document.is_a?(Hash)
88
+ simplecov_result?(result)
89
+ end
90
+
91
+ def simplecov_result?(result)
92
+ return false unless result.is_a?(Hash) && valid_percentage?(result["line"])
93
+
94
+ !(@config.fetch(:coverage) || {}).key?(:minimum_branch) || valid_percentage?(result["branch"])
95
+ end
96
+
97
+ def simplecov_configured?
98
+ enabled?("simplecov") && (@config.fetch(:coverage) || {}).any?
99
+ end
100
+
101
+ def enabled?(name)
102
+ @config.fetch(:adapters).values.any? { _1.include?(name) }
103
+ end
104
+
105
+ def valid_percentage?(value)
106
+ value.is_a?(Numeric) && value.finite? && value.between?(0, 100)
107
+ end
108
+
109
+ def check(id, status, message)
110
+ DoctorReport.check(id:, status:, message:)
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "adapters/undercover"
4
+ require_relative "doctor_report"
5
+ require_relative "doctor_path_lookup"
6
+
7
+ module QualityGate
8
+ # Checks Undercover comparison availability through its bounded Git resolver.
9
+ class DoctorGit < Adapters::Undercover
10
+ GIT_BUDGET_SECONDS = 5
11
+
12
+ def initialize(dir:, config:)
13
+ @doctor_dir = File.expand_path(dir).freeze
14
+ @paths = DoctorPathLookup.new(dir: @doctor_dir, path: ENV["PATH"])
15
+ super(config:)
16
+ end
17
+
18
+ def call
19
+ perform
20
+ ensure
21
+ clear_call_budget
22
+ end
23
+
24
+ private
25
+
26
+ def perform
27
+ return status_check("not_applicable", "Undercover is disabled; no comparison is needed.") unless enabled?
28
+
29
+ comparison_with_git
30
+ rescue StandardError => e
31
+ failure_check_for(e)
32
+ end
33
+
34
+ def comparison_with_git
35
+ return invalid_reference if option_like_reference?
36
+ return path_error unless git_available?
37
+
38
+ point = config.fetch(:compare_point)
39
+ point ? explicit_comparison_check(point) : automatic_comparison_check
40
+ end
41
+
42
+ def explicit_comparison_check(point)
43
+ start_call_budget(GIT_BUDGET_SECONDS)
44
+ commit = git_output("rev-parse", "--verify", "--quiet", "#{point}^{commit}", expected_exitstatuses: [1])
45
+ comparison_result(commit)
46
+ end
47
+
48
+ def automatic_comparison_check
49
+ start_call_budget(GIT_BUDGET_SECONDS)
50
+ point = compare_point
51
+ return no_automatic_point unless point
52
+
53
+ [check("comparison", "ready", "Automatic comparison resolves to local commit #{point}.")]
54
+ end
55
+
56
+ def enabled?
57
+ config.fetch(:adapters).values.any? { _1.include?("undercover") }
58
+ end
59
+
60
+ def git_command(arguments)
61
+ [@git_executable, "-C", @doctor_dir, *arguments]
62
+ end
63
+
64
+ def check(id, status, message)
65
+ DoctorReport.check(id:, status:, message:)
66
+ end
67
+
68
+ def no_automatic_point
69
+ status_check("unchecked", "No automatic comparison is available; fetch history or configure compare_point.")
70
+ end
71
+
72
+ def comparison_result(commit)
73
+ return [check("comparison", "ready", "Configured comparison resolves to commit #{commit}.")] if commit
74
+
75
+ [check("comparison", "blocked",
76
+ "Configured comparison does not resolve to a commit; choose an existing commit ref.")]
77
+ end
78
+
79
+ def invalid_reference
80
+ status_check("blocked", "The configured comparison ref is option-like and cannot be checked.")
81
+ end
82
+
83
+ def git_available?
84
+ @git_executable = @paths.resolve_executable("git")
85
+ @git_executable.is_a?(String)
86
+ end
87
+
88
+ def status_check(status, message)
89
+ [check("comparison", status, message)]
90
+ end
91
+
92
+ def failure_check_for(error)
93
+ return missing_git if error.is_a?(Errno::ENOENT)
94
+ return timed_out if error.is_a?(TimeoutError)
95
+
96
+ status_check("unchecked",
97
+ "Git comparison could not be inspected; check local history or configure compare_point.")
98
+ end
99
+
100
+ def option_like_reference?
101
+ config.fetch(:compare_point)&.start_with?("-")
102
+ end
103
+
104
+ def missing_git
105
+ status_check("blocked", "Git is unavailable on project-rooted PATH; install Git or add it to PATH.")
106
+ end
107
+
108
+ def timed_out
109
+ status_check("unchecked", "Git lookup exceeded five seconds; inspect local history or configure compare_point.")
110
+ end
111
+
112
+ def path_error
113
+ unless @paths.available?
114
+ return status_check("unchecked",
115
+ "PATH is absent, so Git availability is ambiguous; inspect runtime PATH.")
116
+ end
117
+
118
+ missing_git
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "doctor_bounded_file"
4
+ require_relative "doctor_report"
5
+
6
+ module QualityGate
7
+ # Summarizes recent automatic-hook history as historical evidence only.
8
+ class DoctorHooks
9
+ CLAUDE_HOOK_PATHS = %w[
10
+ .claude/hooks/quality_gate_fast.rb
11
+ .claude/hooks/quality_gate_verify_stop.rb
12
+ ].map!(&:freeze).freeze
13
+ CODEX_HOOK_PATHS = %w[
14
+ .codex/hooks.json
15
+ .codex/hooks/quality_gate_fast.rb
16
+ .codex/hooks/quality_gate_verify_stop.rb
17
+ ].map!(&:freeze).freeze
18
+ HOOK_PATHS = (CLAUDE_HOOK_PATHS + CODEX_HOOK_PATHS).freeze
19
+ INSTALLED_HOOKS_MESSAGE = "Installed hooks have no history; run a hooked edit and inspect feedback."
20
+ NO_INSTALLED_HOOKS_MESSAGE = "No hook history or installed hook files were found; " \
21
+ "automatic feedback was not checked."
22
+
23
+ def initialize(dir:)
24
+ @dir = File.expand_path(dir).freeze
25
+ end
26
+
27
+ def call
28
+ path = File.join(@dir, HookLog::DEFAULT_PATH)
29
+ file = DoctorBoundedFile.read(path:, max_bytes: 0)
30
+ summarize_file(file, path)
31
+ end
32
+
33
+ private
34
+
35
+ def summarize_file(file, path)
36
+ return missing_history if file.status == :missing
37
+ return unreadable_history(file.status) unless %i[ok oversized].include?(file.status)
38
+
39
+ summarize_history(path)
40
+ end
41
+
42
+ def installed_hooks?
43
+ HOOK_PATHS.any? { File.exist?(File.join(@dir, _1)) || File.symlink?(File.join(@dir, _1)) }
44
+ end
45
+
46
+ def missing_history
47
+ return [check("hooks", "unchecked", missing_installed_history_message)] if installed_hooks?
48
+
49
+ [check("hooks", "not_applicable", NO_INSTALLED_HOOKS_MESSAGE)]
50
+ end
51
+
52
+ def missing_installed_history_message
53
+ return INSTALLED_HOOKS_MESSAGE unless installed_codex_hook?
54
+
55
+ "Codex hook files are present, but Codex execution history and /hooks trust review are unchecked. " \
56
+ "Run a Codex Stop and review the hook in /hooks."
57
+ end
58
+
59
+ def installed_codex_hook?
60
+ CODEX_HOOK_PATHS.any? { File.exist?(File.join(@dir, _1)) || File.symlink?(File.join(@dir, _1)) }
61
+ end
62
+
63
+ def unreadable_history(status)
64
+ message = "Hook history is unsafe or unreadable (#{status}); inspect the log " \
65
+ "before relying on hook feedback."
66
+ [check("hooks", "unchecked", message)]
67
+ end
68
+
69
+ def summarize_history(path)
70
+ records = HookLog.new(path:).recent(limit: 20)
71
+ return empty_history if records.empty?
72
+
73
+ unavailable_history(records)
74
+ end
75
+
76
+ def unavailable_history(records)
77
+ count = records.count { %w[unavailable verify_unavailable].include?(_1.fetch("outcome")) }
78
+ return warning(count) if count.positive?
79
+
80
+ message = "Recent valid history had no unavailable outcomes; this describes " \
81
+ "history, not current hook installation."
82
+ [check("hooks", "ready", message)]
83
+ end
84
+
85
+ def empty_history
86
+ [check("hooks", "unchecked", "Hook history has no valid recent records; inspect the log.")]
87
+ end
88
+
89
+ def warning(count)
90
+ message = "#{count} of the last 20 checks were unavailable; inspect bundle " \
91
+ "installation and the Quality Gate hook setup."
92
+ [check("hooks", "warning", message)]
93
+ end
94
+
95
+ def check(id, status, message)
96
+ DoctorReport.check(id:, status:, message:)
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "doctor_report"
4
+ require_relative "doctor_path_lookup"
5
+
6
+ module QualityGate
7
+ class DoctorExplicitTargetCheck
8
+ def initialize(paths:)
9
+ @paths = paths
10
+ end
11
+
12
+ def check(argv)
13
+ command = argv.first(2) == %w[bundle exec] ? argv.drop(2) : argv
14
+ return ruby_target_problem(command) if ruby_command?(command)
15
+
16
+ bundle_target_problem(argv, command)
17
+ end
18
+
19
+ private
20
+
21
+ attr_reader :paths
22
+
23
+ def ruby_target_problem(command)
24
+ return search_target_problem(command[2]) if command[1] == "-S" && explicit_path?(command[2])
25
+ return unless script_argument?(command[1])
26
+
27
+ script_problem(command[1])
28
+ end
29
+
30
+ def script_problem(target)
31
+ path = paths.relative_path(target)
32
+ return missing_script(target) unless File.file?(path)
33
+ return unreadable_script(target) unless File.readable?(path)
34
+
35
+ nil
36
+ end
37
+
38
+ def bundle_target_problem(argv, command)
39
+ return unless argv.first(2) == %w[bundle exec] && explicit_path?(command.first)
40
+ return if paths.executable?(command.first)
41
+
42
+ ["blocked", "Explicit bundle target #{command.first} is missing; update the command path or install it."]
43
+ end
44
+
45
+ def search_target_problem(target)
46
+ return if paths.executable?(target)
47
+
48
+ ["blocked", "Ruby -S target #{target} is missing; update its path or install the launcher."]
49
+ end
50
+
51
+ def ruby_command?(command) = File.basename(command.first.to_s) == "ruby"
52
+ def explicit_path?(target) = target&.include?(File::SEPARATOR)
53
+ def script_argument?(target) = target && !target.start_with?("-")
54
+
55
+ def missing_script(target)
56
+ ["blocked", "Ruby script #{target} is missing; add the file or update commands.* argv."]
57
+ end
58
+
59
+ def unreadable_script(target)
60
+ ["blocked", "Ruby script #{target} is unreadable; update its file permissions."]
61
+ end
62
+ end
63
+
64
+ class DoctorRubySearchTarget
65
+ def initialize(paths:, known_launchers:)
66
+ @paths = paths
67
+ @known_launchers = known_launchers
68
+ end
69
+
70
+ def check(target)
71
+ return missing_target unless target
72
+ return explicit_target(target) if target.include?(File::SEPARATOR)
73
+
74
+ named_target(target)
75
+ end
76
+
77
+ private
78
+
79
+ attr_reader :paths, :known_launchers
80
+
81
+ def explicit_target(target)
82
+ ["ready", "Ruby and explicit target #{target} are present; command behavior was not run."]
83
+ end
84
+
85
+ def named_target(target)
86
+ return unknown_target unless known_launchers.include?(File.basename(target))
87
+ return path_unavailable unless paths.available?
88
+ return ready_target(target) if paths.executable?(target)
89
+
90
+ unchecked_target(target)
91
+ end
92
+
93
+ def missing_target = ["unchecked", "ruby -S target was not provided; supply a launcher name."]
94
+ def unknown_target = ["unchecked", "Ruby -S target is not recognized; inspect or simplify the configured command."]
95
+ def path_unavailable = ["unchecked", "PATH is unavailable; set PATH to inspect the ruby -S target."]
96
+
97
+ def ready_target(target)
98
+ ["ready", "Ruby and #{target} launchers are present; command behavior was not run."]
99
+ end
100
+
101
+ def unchecked_target(target)
102
+ ["unchecked", "Ruby -S target #{target} was not found; add it to PATH or inspect bundle paths."]
103
+ end
104
+ end
105
+
106
+ class DoctorLauncherWrapper
107
+ def initialize(paths:)
108
+ @paths = paths
109
+ end
110
+
111
+ def check(launcher, known:)
112
+ return explicit_launcher(launcher, known) if launcher.include?(File::SEPARATOR)
113
+ return named_launcher(launcher) if known
114
+
115
+ custom_launcher(launcher)
116
+ end
117
+
118
+ private
119
+
120
+ attr_reader :paths
121
+
122
+ def explicit_launcher(launcher, known)
123
+ return missing_explicit(launcher) unless paths.executable?(launcher)
124
+ return ready_launcher(launcher) if known
125
+
126
+ unchecked_custom
127
+ end
128
+
129
+ def named_launcher(launcher)
130
+ return direct_availability(launcher) unless paths.available?
131
+ return missing_direct(launcher) unless paths.executable?(launcher)
132
+
133
+ ready_launcher(launcher)
134
+ end
135
+
136
+ def custom_launcher(launcher)
137
+ return unavailable_custom unless paths.available?
138
+ return missing_custom(launcher) unless paths.executable?(launcher)
139
+
140
+ unchecked_custom
141
+ end
142
+
143
+ def direct_availability(launcher)
144
+ ["unchecked", "PATH is unavailable; set PATH to inspect #{launcher} availability."]
145
+ end
146
+
147
+ def missing_direct(launcher)
148
+ message = "Executable launcher #{launcher} is missing or not executable; " \
149
+ "install it, update permissions, or add it to PATH."
150
+ ["blocked", message]
151
+ end
152
+
153
+ def missing_explicit(launcher)
154
+ message = "Explicit launcher #{launcher} is missing or not executable; " \
155
+ "update permissions, its command path, or install it."
156
+ ["blocked", message]
157
+ end
158
+
159
+ def missing_custom(launcher)
160
+ ["blocked", "Custom wrapper #{launcher} is missing; install it or add it to PATH."]
161
+ end
162
+
163
+ def unavailable_custom
164
+ ["unchecked", "PATH is unavailable; set PATH to inspect the custom wrapper."]
165
+ end
166
+
167
+ def ready_launcher(launcher)
168
+ ["ready", "Executable #{launcher} is present; tool version and command behavior were not checked."]
169
+ end
170
+
171
+ def unchecked_custom
172
+ ["unchecked", "Custom wrapper is present but uninspected; verify its target or use a supported launcher."]
173
+ end
174
+ end
175
+
176
+ # Checks launch paths without starting analyzers or project commands.
177
+ class DoctorLaunchers
178
+ KNOWN_LAUNCHERS = %w[
179
+ bundle-audit brakeman herb herb-lint rails rake reek rspec rubocop undercover
180
+ ].map!(&:freeze).freeze
181
+
182
+ def initialize(dir:, path: ENV["PATH"])
183
+ @paths = DoctorPathLookup.new(dir:, path:)
184
+ @wrapper = DoctorLauncherWrapper.new(paths: @paths)
185
+ @explicit_targets = DoctorExplicitTargetCheck.new(paths: @paths)
186
+ end
187
+
188
+ def check(adapter:, argv:, gate: nil)
189
+ report(adapter, gate, *assess(argv))
190
+ rescue SystemCallError, ArgumentError
191
+ inspection_error(adapter, gate)
192
+ end
193
+
194
+ private
195
+
196
+ attr_reader :paths, :wrapper, :explicit_targets
197
+
198
+ def assess(argv)
199
+ return invalid_argv unless valid_argv?(argv)
200
+
201
+ explicit_targets.check(argv) || launch_assessment(argv)
202
+ end
203
+
204
+ def launch_assessment(argv)
205
+ return assess_bundle(argv.drop(2)) if argv.first(2) == %w[bundle exec]
206
+ return assess_ruby(argv) if ruby_launcher?(argv.first)
207
+
208
+ wrapper.check(argv.first, known: known_launcher?(argv.first))
209
+ end
210
+
211
+ def invalid_argv
212
+ ["blocked", "Command argv is invalid; provide a non-empty string array in commands.*."]
213
+ end
214
+
215
+ def valid_argv?(argv)
216
+ argv.is_a?(Array) && argv.any? && argv.all? { _1.is_a?(String) && !_1.empty? }
217
+ end
218
+
219
+ def assess_bundle(command)
220
+ return ["unchecked", "PATH is unavailable; set PATH to inspect the bundle launcher."] unless paths.available?
221
+ return missing_bundle_launcher unless paths.executable?("bundle")
222
+
223
+ return missing_bundle_target(command.first) if missing_bare_bundle_target?(command.first)
224
+
225
+ status, message = assess(command)
226
+ [status, "bundle is available; #{message}"]
227
+ end
228
+
229
+ def missing_bundle_launcher
230
+ ["blocked", "The bundle launcher is missing; install Bundler or add it to PATH."]
231
+ end
232
+
233
+ def missing_bare_bundle_target?(target)
234
+ target && !target.include?(File::SEPARATOR) && !paths.executable?(target)
235
+ end
236
+
237
+ def missing_bundle_target(target)
238
+ ["unchecked", "Bundle target #{target} was not found; install it or add it to the bundle PATH."]
239
+ end
240
+
241
+ def assess_ruby(argv)
242
+ return missing_ruby_path unless ruby_path_available?(argv.first)
243
+ return missing_ruby_launcher unless paths.executable?(argv.first)
244
+
245
+ assess_ruby_arguments(argv.drop(1))
246
+ end
247
+
248
+ def assess_ruby_arguments(arguments)
249
+ return unsupported_ruby_form if arguments.empty?
250
+ return assess_ruby_script(arguments) unless arguments.first.start_with?("-")
251
+ return assess_ruby_search(arguments.drop(1)) if arguments.first == "-S"
252
+
253
+ unsupported_ruby_form
254
+ end
255
+
256
+ def missing_ruby_launcher
257
+ ["blocked", "The ruby launcher is missing; install Ruby or add it to PATH."]
258
+ end
259
+
260
+ def unsupported_ruby_form
261
+ ["unchecked", "Ruby options are not inspected; use a simple ruby script command or inspect it manually."]
262
+ end
263
+
264
+ def ruby_path_available?(launcher)
265
+ launcher.include?(File::SEPARATOR) || paths.available?
266
+ end
267
+
268
+ def missing_ruby_path
269
+ ["unchecked", "Ruby launcher is unresolved; set PATH or configure its explicit path."]
270
+ end
271
+
272
+ def assess_ruby_script(arguments)
273
+ script = arguments.first
274
+ ["ready", "Ruby launcher and readable script #{script} are present; command behavior was not run."]
275
+ end
276
+
277
+ def assess_ruby_search(arguments)
278
+ DoctorRubySearchTarget.new(paths:, known_launchers: KNOWN_LAUNCHERS).check(arguments.first)
279
+ end
280
+
281
+ def known_launcher?(value)
282
+ KNOWN_LAUNCHERS.include?(File.basename(value))
283
+ end
284
+
285
+ def ruby_launcher?(value)
286
+ File.basename(value) == "ruby"
287
+ end
288
+
289
+ def report(adapter, gate, status, message)
290
+ id = gate ? "command.#{gate}.#{adapter}" : "command.#{adapter}"
291
+ DoctorReport.check(id:, status:, message:)
292
+ end
293
+
294
+ def inspection_error(adapter, gate)
295
+ message = "Could not inspect the launcher; verify its command path and argv."
296
+ report(adapter, gate, "unchecked", message)
297
+ end
298
+ end
299
+ private_constant :DoctorExplicitTargetCheck, :DoctorLauncherWrapper, :DoctorRubySearchTarget
300
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QualityGate
4
+ # Resolves executable and script paths using the supplied project directory.
5
+ class DoctorPathLookup
6
+ def initialize(dir:, path: ENV["PATH"])
7
+ @dir = File.expand_path(dir)
8
+ @path = path
9
+ end
10
+
11
+ def resolve_executable(name)
12
+ return resolve_explicit(name) if explicit_path?(name)
13
+ return unless @path
14
+
15
+ resolve_from_path(name)
16
+ end
17
+
18
+ def executable?(name) = !resolve_executable(name).nil?
19
+
20
+ def relative_path(name) = File.expand_path(name, @dir)
21
+
22
+ def available? = !@path.nil?
23
+
24
+ private
25
+
26
+ def resolve_from_path(name)
27
+ path_entries.each do |entry|
28
+ candidate = File.expand_path(name, entry)
29
+ return candidate if executable_file?(candidate)
30
+ end
31
+ nil
32
+ end
33
+
34
+ def resolve_explicit(name)
35
+ path = relative_path(name)
36
+ path if executable_file?(path)
37
+ end
38
+
39
+ def explicit_path?(name) = name.include?(File::SEPARATOR)
40
+
41
+ def path_entries
42
+ entries = @path.empty? ? [""] : @path.split(File::PATH_SEPARATOR, -1)
43
+ entries.map { File.expand_path(_1.empty? ? @dir : _1, @dir) }
44
+ end
45
+
46
+ def executable_file?(path)
47
+ File.file?(path) && File.executable?(path)
48
+ end
49
+ end
50
+
51
+ private_constant :DoctorPathLookup
52
+ end