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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +292 -21
- data/docs/codex.md +37 -9
- data/docs/releasing.md +16 -4
- data/lib/generators/quality_gate/install/install_generator.rb +18 -3
- data/lib/generators/quality_gate/install/templates/agents_section.md.tt +13 -2
- data/lib/generators/quality_gate/install/templates/codex_fast.rb.tt +36 -0
- data/lib/generators/quality_gate/install/templates/codex_hooks.json.tt +1 -0
- data/lib/generators/quality_gate/install/templates/codex_verify_stop.rb.tt +28 -0
- data/lib/generators/quality_gate/install/templates/quality_gate.yml.tt +6 -0
- data/lib/generators/quality_gate/install/templates/quality_gate_fast.rb.tt +16 -3
- data/lib/generators/quality_gate/install/templates/quality_gate_workflow.yml.tt +28 -0
- data/lib/generators/quality_gate/install/templates/rails_quality_gate.yml.tt +45 -0
- data/lib/generators/quality_gate/install/templates/ruby_agents_section.md.tt +13 -2
- data/lib/generators/quality_gate/install/templates/ruby_quality_gate.yml.tt +5 -0
- data/lib/generators/quality_gate/install/templates/simplecov.rb.tt +1 -0
- data/lib/quality_gate/adapters/bundler_audit.rb +28 -3
- data/lib/quality_gate/adapters/database_consistency.rb +51 -0
- data/lib/quality_gate/adapters/database_consistency_report.rb +61 -0
- data/lib/quality_gate/adapters/database_consistency_report_item.rb +122 -0
- data/lib/quality_gate/adapters/debride.rb +67 -0
- data/lib/quality_gate/adapters/debride_report.rb +80 -0
- data/lib/quality_gate/adapters/herb.rb +303 -0
- data/lib/quality_gate/adapters/reek.rb +21 -2
- data/lib/quality_gate/adapters/rubocop.rb +23 -9
- data/lib/quality_gate/adapters/rubycritic.rb +74 -0
- data/lib/quality_gate/adapters/rubycritic_report.rb +112 -0
- data/lib/quality_gate/adapters/test_suite.rb +12 -1
- data/lib/quality_gate/baseline.rb +269 -0
- data/lib/quality_gate/baseline_run.rb +80 -0
- data/lib/quality_gate/cli.rb +129 -6
- data/lib/quality_gate/codex_fast_hook.rb +163 -0
- data/lib/quality_gate/codex_patch_files.rb +95 -0
- data/lib/quality_gate/codex_stop_hook.rb +102 -0
- data/lib/quality_gate/config.rb +20 -6
- data/lib/quality_gate/database_consistency_runner.rb +124 -0
- data/lib/quality_gate/doctor.rb +217 -0
- data/lib/quality_gate/doctor_bounded_file.rb +74 -0
- data/lib/quality_gate/doctor_command.rb +130 -0
- data/lib/quality_gate/doctor_coverage.rb +113 -0
- data/lib/quality_gate/doctor_git.rb +121 -0
- data/lib/quality_gate/doctor_hooks.rb +99 -0
- data/lib/quality_gate/doctor_launchers.rb +300 -0
- data/lib/quality_gate/doctor_path_lookup.rb +52 -0
- data/lib/quality_gate/doctor_report.rb +38 -0
- data/lib/quality_gate/init_command.rb +3 -1
- data/lib/quality_gate/installation.rb +72 -17
- data/lib/quality_gate/installer.rb +3 -0
- data/lib/quality_gate/reporters/doctor.rb +57 -0
- data/lib/quality_gate/reporters/json.rb +18 -1
- data/lib/quality_gate/reporters/markdown.rb +11 -1
- data/lib/quality_gate/reporters/text.rb +14 -6
- data/lib/quality_gate/ruby_profile.rb +121 -2
- data/lib/quality_gate/runner.rb +15 -17
- data/lib/quality_gate/version.rb +1 -1
- data/lib/quality_gate.rb +15 -0
- data/sig/quality_gate.rbs +117 -2
- 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
|