quality_gate 0.1.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/CHANGELOG.md +14 -0
- data/LICENSE.txt +21 -0
- data/README.md +571 -0
- data/config/37signals.yml +26 -0
- data/config/cops.yml +37 -0
- data/config/reek.yml +6 -0
- data/config/rubocop.yml +60 -0
- data/config/ruby.yml +8 -0
- data/docs/codex.md +31 -0
- data/docs/dogfood-log.md +47 -0
- data/docs/incidents.md +18 -0
- data/docs/releasing.md +46 -0
- data/exe/quality_gate +6 -0
- data/lib/generators/quality_gate/install/install_generator.rb +66 -0
- data/lib/generators/quality_gate/install/templates/agents_section.md.tt +15 -0
- data/lib/generators/quality_gate/install/templates/bullet.rb.tt +10 -0
- data/lib/generators/quality_gate/install/templates/claude_settings.json.tt +29 -0
- data/lib/generators/quality_gate/install/templates/hook_log_filesystem.rb.tt +76 -0
- data/lib/generators/quality_gate/install/templates/quality_gate.yml.tt +42 -0
- data/lib/generators/quality_gate/install/templates/quality_gate_fast.rb.tt +248 -0
- data/lib/generators/quality_gate/install/templates/quality_gate_verify_stop.rb.tt +466 -0
- data/lib/generators/quality_gate/install/templates/rubocop.yml.tt +1 -0
- data/lib/generators/quality_gate/install/templates/ruby_agents_section.md.tt +15 -0
- data/lib/generators/quality_gate/install/templates/ruby_quality_gate.yml.tt +40 -0
- data/lib/generators/quality_gate/install/templates/ruby_rubocop.yml.tt +1 -0
- data/lib/generators/quality_gate/install/templates/ruby_simplecov.rb.tt +11 -0
- data/lib/generators/quality_gate/install/templates/simplecov.rb.tt +10 -0
- data/lib/generators/quality_gate/install/templates/strong_migrations.rb.tt +10 -0
- data/lib/quality_gate/adapter.rb +328 -0
- data/lib/quality_gate/adapters/brakeman.rb +125 -0
- data/lib/quality_gate/adapters/bundler_audit.rb +235 -0
- data/lib/quality_gate/adapters/reek.rb +108 -0
- data/lib/quality_gate/adapters/rubocop.rb +142 -0
- data/lib/quality_gate/adapters/simplecov.rb +103 -0
- data/lib/quality_gate/adapters/test_suite.rb +81 -0
- data/lib/quality_gate/adapters/undercover.rb +357 -0
- data/lib/quality_gate/cli.rb +451 -0
- data/lib/quality_gate/config.rb +290 -0
- data/lib/quality_gate/exit_code.rb +14 -0
- data/lib/quality_gate/finding.rb +50 -0
- data/lib/quality_gate/hook_log.rb +131 -0
- data/lib/quality_gate/init_command.rb +90 -0
- data/lib/quality_gate/installation.rb +1577 -0
- data/lib/quality_gate/installer.rb +104 -0
- data/lib/quality_gate/railtie.rb +16 -0
- data/lib/quality_gate/reporters/field_sanitizer.rb +42 -0
- data/lib/quality_gate/reporters/json.rb +59 -0
- data/lib/quality_gate/reporters/text.rb +45 -0
- data/lib/quality_gate/rubocop.rb +34 -0
- data/lib/quality_gate/ruby_profile.rb +170 -0
- data/lib/quality_gate/runner.rb +150 -0
- data/lib/quality_gate/version.rb +5 -0
- data/lib/quality_gate.rb +27 -0
- data/lib/rubocop/cop/quality_gate/association_default_block_value.rb +46 -0
- data/lib/rubocop/cop/quality_gate/broadcast_in_controller.rb +64 -0
- data/lib/rubocop/cop/quality_gate/controller_instance_variables.rb +47 -0
- data/lib/rubocop/cop/quality_gate/prefer_after_save_commit.rb +84 -0
- data/lib/rubocop/cop/quality_gate/private_only_concern.rb +77 -0
- data/llm.txt +13 -0
- data/sig/quality_gate.rbs +226 -0
- metadata +260 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "installation"
|
|
5
|
+
require_relative "ruby_profile"
|
|
6
|
+
|
|
7
|
+
module QualityGate
|
|
8
|
+
# Installs the framework-independent Quality Gate files into a Ruby project.
|
|
9
|
+
class Installer
|
|
10
|
+
include Installation
|
|
11
|
+
|
|
12
|
+
VALID_OPTIONS = %i[
|
|
13
|
+
profile
|
|
14
|
+
test_framework
|
|
15
|
+
test_helper
|
|
16
|
+
test_command
|
|
17
|
+
agents
|
|
18
|
+
pretend
|
|
19
|
+
skip_coverage
|
|
20
|
+
].freeze
|
|
21
|
+
DEFAULT_TEMPLATE_ROOT = File.expand_path("../generators/quality_gate/install/templates", __dir__).freeze
|
|
22
|
+
INSTALL_STEPS = %i[
|
|
23
|
+
create_settings_file
|
|
24
|
+
create_rules_file
|
|
25
|
+
create_initializers
|
|
26
|
+
inject_coverage
|
|
27
|
+
create_agent_integration
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
attr_reader :destination_root, :stdout, :profile
|
|
31
|
+
|
|
32
|
+
def initialize(destination_root:, options: {}, stdout: $stdout)
|
|
33
|
+
@options = normalize_options(options)
|
|
34
|
+
@profile = RubyProfile.new(destination_root:, options: @options)
|
|
35
|
+
@destination_root = @profile.destination_root
|
|
36
|
+
@stdout = stdout
|
|
37
|
+
@template_source_root = DEFAULT_TEMPLATE_ROOT
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def options
|
|
41
|
+
@options.merge(skip_initializers: true)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def call
|
|
45
|
+
INSTALL_STEPS.each { |step| public_send(step) }
|
|
46
|
+
print_summary
|
|
47
|
+
|
|
48
|
+
results.fetch(:needs_person, []).empty? ? 0 : 1
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def behavior
|
|
52
|
+
:invoke
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def test_helper_path
|
|
56
|
+
profile.test_helper
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def template_for(name)
|
|
60
|
+
profile.template_for(name)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
attr_reader :template_source_root
|
|
66
|
+
|
|
67
|
+
def normalize_options(value)
|
|
68
|
+
normalized = option_pairs(value).to_h
|
|
69
|
+
validate_options!(normalized)
|
|
70
|
+
normalized.freeze
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def option_pairs(value)
|
|
74
|
+
raise ArgumentError, "options must be a mapping" unless value.respond_to?(:to_h)
|
|
75
|
+
|
|
76
|
+
value.to_h.map { |key, option_value| [key.to_sym, option_value] }
|
|
77
|
+
rescue TypeError, NoMethodError
|
|
78
|
+
raise ArgumentError, "option keys must be symbols or strings"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def validate_options!(normalized)
|
|
82
|
+
unknown = normalized.keys - VALID_OPTIONS
|
|
83
|
+
return if unknown.empty?
|
|
84
|
+
|
|
85
|
+
raise ArgumentError, "unknown installer option(s): #{unknown.map(&:inspect).join(", ")}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def create_new_file(relative_path, content)
|
|
89
|
+
write_new_file(File.join(destination_root, relative_path), content)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def write_new_file(path, content)
|
|
93
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
94
|
+
File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o644) do |file|
|
|
95
|
+
file.binmode
|
|
96
|
+
file.write(content)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def say(message)
|
|
101
|
+
stdout.puts(message)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
if defined?(Rails::Railtie)
|
|
4
|
+
module QualityGate
|
|
5
|
+
# Integrates Quality Gate's generator and task files with Rails applications.
|
|
6
|
+
class Railtie < Rails::Railtie
|
|
7
|
+
generators do
|
|
8
|
+
require_relative "../generators/quality_gate/install/install_generator"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
rake_tasks do
|
|
12
|
+
Dir[File.expand_path("../tasks/**/*.rake", __dir__)].sort.each { load _1 }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QualityGate
|
|
4
|
+
module Reporters
|
|
5
|
+
# Normalizes reporter string fields without mutating source findings.
|
|
6
|
+
module FieldSanitizer
|
|
7
|
+
REPLACEMENT_CHARACTER = "\uFFFD"
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def for_text(value)
|
|
12
|
+
sanitize_text(scrub_utf8(value), preserve_newlines: false)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def for_text_message(value)
|
|
16
|
+
sanitize_text(scrub_utf8(value), preserve_newlines: true)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def for_json(value)
|
|
20
|
+
scrub_utf8(value)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def scrub_utf8(value)
|
|
24
|
+
value.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: REPLACEMENT_CHARACTER)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def sanitize_text(value, preserve_newlines:)
|
|
28
|
+
value.each_char.map { sanitize_character(_1, preserve_newlines) }.join.strip
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def sanitize_character(character, preserve_newlines)
|
|
32
|
+
return character if preserve_newlines && character == "\n"
|
|
33
|
+
return "" if preserve_newlines && character == "\e"
|
|
34
|
+
return character unless character.match?(/[[:cntrl:]\u2028\u2029]/)
|
|
35
|
+
|
|
36
|
+
" "
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private_constant :FieldSanitizer
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module QualityGate
|
|
6
|
+
module Reporters
|
|
7
|
+
# Writes machine-readable reports as one JSON object per run.
|
|
8
|
+
class Json
|
|
9
|
+
def initialize(io:)
|
|
10
|
+
@io = io
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(result)
|
|
14
|
+
io.puts(::JSON.generate(payload_for(result)))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
attr_reader :io
|
|
20
|
+
|
|
21
|
+
def payload_for(result)
|
|
22
|
+
{
|
|
23
|
+
"checks" => result.checks.map { serialize_check(_1) },
|
|
24
|
+
"findings" => result.findings.map { serialize_finding(_1) },
|
|
25
|
+
"summary" => {
|
|
26
|
+
"findings" => result.findings.length,
|
|
27
|
+
"tool_failures" => result.failed_tools.length,
|
|
28
|
+
"failed_tools" => result.failed_tools.map { FieldSanitizer.for_json(_1) }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def serialize_finding(finding)
|
|
34
|
+
{
|
|
35
|
+
"tool" => FieldSanitizer.for_json(finding.tool),
|
|
36
|
+
"file" => FieldSanitizer.for_json(finding.file),
|
|
37
|
+
"line" => finding.line,
|
|
38
|
+
"rule" => FieldSanitizer.for_json(finding.rule),
|
|
39
|
+
"severity" => FieldSanitizer.for_json(finding.severity.to_s),
|
|
40
|
+
"message" => FieldSanitizer.for_json(finding.message)
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def serialize_check(check)
|
|
45
|
+
{
|
|
46
|
+
"tool" => FieldSanitizer.for_json(check_value(check, :tool)),
|
|
47
|
+
"status" => FieldSanitizer.for_json(check_value(check, :status)),
|
|
48
|
+
"scope" => FieldSanitizer.for_json(check_value(check, :scope)),
|
|
49
|
+
"requested_files" => check_value(check, :requested_files).map { FieldSanitizer.for_json(_1) },
|
|
50
|
+
"duration_ms" => check_value(check, :duration_ms)
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def check_value(check, key)
|
|
55
|
+
check.fetch(key) { check.fetch(key.to_s) }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QualityGate
|
|
4
|
+
module Reporters
|
|
5
|
+
# Writes line-oriented reports for humans.
|
|
6
|
+
class Text
|
|
7
|
+
def initialize(io:)
|
|
8
|
+
@io = io
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(result)
|
|
12
|
+
result.findings.each do |finding|
|
|
13
|
+
io.puts(render_finding(finding))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
io.puts("#{result.findings.length} findings, #{result.failed_tools.length} tool failures")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
attr_reader :io
|
|
22
|
+
|
|
23
|
+
def render_finding(finding)
|
|
24
|
+
lines = FieldSanitizer.for_text_message(finding.message).split("\n", -1)
|
|
25
|
+
["#{finding_prefix(finding)} #{lines.first}", *lines.drop(1).map { " #{_1}" }].join("\n")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def finding_prefix(finding)
|
|
29
|
+
[
|
|
30
|
+
FieldSanitizer.for_text(finding.tool),
|
|
31
|
+
FieldSanitizer.for_text(finding.severity.to_s),
|
|
32
|
+
finding_location(finding),
|
|
33
|
+
FieldSanitizer.for_text(finding.rule)
|
|
34
|
+
].compact.join(" ")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def finding_location(finding)
|
|
38
|
+
file = FieldSanitizer.for_text(finding.file)
|
|
39
|
+
return if file.empty?
|
|
40
|
+
|
|
41
|
+
finding.line.zero? ? file : "#{file}:#{finding.line}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubocop"
|
|
4
|
+
require "lint_roller"
|
|
5
|
+
require_relative "version"
|
|
6
|
+
require_relative "../rubocop/cop/quality_gate/association_default_block_value"
|
|
7
|
+
require_relative "../rubocop/cop/quality_gate/prefer_after_save_commit"
|
|
8
|
+
require_relative "../rubocop/cop/quality_gate/private_only_concern"
|
|
9
|
+
require_relative "../rubocop/cop/quality_gate/broadcast_in_controller"
|
|
10
|
+
require_relative "../rubocop/cop/quality_gate/controller_instance_variables"
|
|
11
|
+
|
|
12
|
+
module QualityGate
|
|
13
|
+
# Loads optional Rails conventions through RuboCop's plugin interface.
|
|
14
|
+
class RuboCopPlugin < LintRoller::Plugin
|
|
15
|
+
def about
|
|
16
|
+
LintRoller::About.new(
|
|
17
|
+
name: "quality_gate", version: VERSION,
|
|
18
|
+
homepage: "https://github.com/lucianghinda/quality_gate",
|
|
19
|
+
description: "Conservative, opt-in Rails convention checks."
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def supported?(context)
|
|
24
|
+
context.engine == :rubocop
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def rules(_context)
|
|
28
|
+
LintRoller::Rules.new(
|
|
29
|
+
type: :path, config_format: :rubocop,
|
|
30
|
+
value: Pathname.new(File.expand_path("../../config/cops.yml", __dir__))
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "json"
|
|
5
|
+
require "shellwords"
|
|
6
|
+
|
|
7
|
+
module QualityGate
|
|
8
|
+
module RubyProfileSupport
|
|
9
|
+
DEFAULT_COMMANDS = {
|
|
10
|
+
"minitest" => %w[bundle exec rake test].freeze,
|
|
11
|
+
"rspec" => %w[bundle exec rspec].freeze
|
|
12
|
+
}.freeze
|
|
13
|
+
private_constant :DEFAULT_COMMANDS
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def options(value)
|
|
18
|
+
raise ArgumentError, "options must be a mapping" unless value.respond_to?(:to_h)
|
|
19
|
+
|
|
20
|
+
value.to_h.transform_keys(&:to_sym)
|
|
21
|
+
rescue NoMethodError
|
|
22
|
+
raise ArgumentError, "option keys must be symbols or strings"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def destination_root(value)
|
|
26
|
+
path = value.to_s
|
|
27
|
+
raise ArgumentError, "destination_root must be a non-empty directory" if path.empty?
|
|
28
|
+
|
|
29
|
+
expanded = File.expand_path(path)
|
|
30
|
+
return expanded if File.directory?(expanded)
|
|
31
|
+
|
|
32
|
+
raise ArgumentError, "destination_root must be an existing directory: #{path}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def relative_path(root_path, value, name)
|
|
36
|
+
validate_path_value!(value, name)
|
|
37
|
+
raise ArgumentError, "#{name} must be relative to the project" if Pathname.new(value).absolute?
|
|
38
|
+
|
|
39
|
+
relative = Pathname.new(File.expand_path(value, root_path)).relative_path_from(Pathname.new(root_path)).to_s
|
|
40
|
+
return relative unless outside_project?(relative)
|
|
41
|
+
|
|
42
|
+
raise ArgumentError, "#{name} must stay inside the project"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def command(value, framework)
|
|
46
|
+
command = value.nil? ? DEFAULT_COMMANDS.fetch(framework) : parse_command(value)
|
|
47
|
+
validate_command!(command)
|
|
48
|
+
command.map(&:dup).freeze
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def parse_command(value)
|
|
52
|
+
return value.dup if value.is_a?(Array)
|
|
53
|
+
return parse_shellwords(value) if value.is_a?(String)
|
|
54
|
+
|
|
55
|
+
raise ArgumentError, "test_command must be a command string or argv array"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def parse_shellwords(value)
|
|
59
|
+
Shellwords.split(value)
|
|
60
|
+
rescue ArgumentError => e
|
|
61
|
+
raise ArgumentError, "test_command could not be parsed: #{e.message}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def validate_path_value!(value, name)
|
|
65
|
+
return if value.is_a?(String) && !value.empty?
|
|
66
|
+
|
|
67
|
+
raise ArgumentError, "#{name} must be a non-empty path"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def validate_command!(command)
|
|
71
|
+
valid = command.is_a?(Array) && command.any? && command.all? { _1.is_a?(String) && !_1.empty? }
|
|
72
|
+
return if valid
|
|
73
|
+
|
|
74
|
+
raise ArgumentError, "test_command must not be empty"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def outside_project?(relative)
|
|
78
|
+
relative == ".." || relative.start_with?("../")
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
private_constant :RubyProfileSupport
|
|
82
|
+
|
|
83
|
+
# Resolves the framework, helper, command, and templates for a plain Ruby project.
|
|
84
|
+
class RubyProfile
|
|
85
|
+
DEFAULT_HELPERS = {
|
|
86
|
+
"minitest" => "test/test_helper.rb",
|
|
87
|
+
"rspec" => "spec/spec_helper.rb"
|
|
88
|
+
}.freeze
|
|
89
|
+
TEMPLATE_MAP = {
|
|
90
|
+
"quality_gate.yml.tt" => "ruby_quality_gate.yml.tt",
|
|
91
|
+
"rubocop.yml.tt" => "ruby_rubocop.yml.tt",
|
|
92
|
+
"simplecov.rb.tt" => "ruby_simplecov.rb.tt",
|
|
93
|
+
"agents_section.md.tt" => "ruby_agents_section.md.tt"
|
|
94
|
+
}.freeze
|
|
95
|
+
SUPPORTED_FRAMEWORKS = DEFAULT_HELPERS.keys.freeze
|
|
96
|
+
private_constant :DEFAULT_HELPERS, :TEMPLATE_MAP, :SUPPORTED_FRAMEWORKS
|
|
97
|
+
|
|
98
|
+
attr_reader :destination_root, :test_helper, :test_command
|
|
99
|
+
|
|
100
|
+
def initialize(destination_root:, options: {})
|
|
101
|
+
@destination_root = RubyProfileSupport.destination_root(destination_root)
|
|
102
|
+
@options = RubyProfileSupport.options(options)
|
|
103
|
+
resolve_settings
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def coverage? = @coverage
|
|
107
|
+
|
|
108
|
+
def template_for(name)
|
|
109
|
+
TEMPLATE_MAP.fetch(name, name)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
attr_reader :options, :framework
|
|
115
|
+
|
|
116
|
+
def resolve_settings
|
|
117
|
+
validate_profile!
|
|
118
|
+
@coverage = !option(:skip_coverage)
|
|
119
|
+
@framework = resolve_framework
|
|
120
|
+
@test_helper = resolve_test_helper
|
|
121
|
+
@test_command = RubyProfileSupport.command(option(:test_command), framework)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def option(key)
|
|
125
|
+
options.fetch(key, nil)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def validate_profile!
|
|
129
|
+
return if (option(:profile) || "ruby").to_s == "ruby"
|
|
130
|
+
|
|
131
|
+
raise ArgumentError, "unsupported profile; only ruby is supported"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def resolve_framework
|
|
135
|
+
explicit = option(:test_framework)
|
|
136
|
+
return validate_framework(explicit) if explicit
|
|
137
|
+
return "minitest" if option(:test_helper)
|
|
138
|
+
|
|
139
|
+
detected_framework
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def validate_framework(value)
|
|
143
|
+
framework = value.to_s
|
|
144
|
+
return framework if SUPPORTED_FRAMEWORKS.include?(framework)
|
|
145
|
+
|
|
146
|
+
raise ArgumentError, "test_framework must be minitest or rspec"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def detected_framework
|
|
150
|
+
available = DEFAULT_HELPERS.select { |_framework, helper| helper_present?(helper) }.keys
|
|
151
|
+
return available.first if available.one?
|
|
152
|
+
return "minitest" if available.empty?
|
|
153
|
+
|
|
154
|
+
raise ArgumentError, "both test helpers exist; pass --test-framework minitest or rspec"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def helper_present?(helper)
|
|
158
|
+
File.file?(File.join(destination_root, helper))
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def resolve_test_helper
|
|
162
|
+
helper = option(:test_helper) || DEFAULT_HELPERS.fetch(framework)
|
|
163
|
+
relative = RubyProfileSupport.relative_path(destination_root, helper, "test_helper")
|
|
164
|
+
return relative if !coverage? || helper_present?(relative)
|
|
165
|
+
|
|
166
|
+
raise ArgumentError,
|
|
167
|
+
"test helper #{relative.inspect} does not exist; pass --skip-coverage or provide an existing helper"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QualityGate
|
|
4
|
+
module RunnerValueCopy
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def freeze_copy(value)
|
|
8
|
+
case value
|
|
9
|
+
when Array
|
|
10
|
+
value.map { freeze_copy(_1) }.freeze
|
|
11
|
+
when Hash
|
|
12
|
+
value.each_with_object({}) do |(key, child), copy|
|
|
13
|
+
copy[freeze_copy(key)] = freeze_copy(child)
|
|
14
|
+
end.freeze
|
|
15
|
+
when String
|
|
16
|
+
value.dup.freeze
|
|
17
|
+
else
|
|
18
|
+
value
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
private_constant :RunnerValueCopy
|
|
23
|
+
|
|
24
|
+
# Runs adapters in order and reduces their findings into one immutable result.
|
|
25
|
+
class Runner
|
|
26
|
+
Result = Data.define(:findings, :failed_tools, :checks) do
|
|
27
|
+
def initialize(findings:, checks: [])
|
|
28
|
+
copied_findings = findings.dup.freeze
|
|
29
|
+
failed_tools = copied_findings.select(&:tool_failure?).map(&:tool).uniq
|
|
30
|
+
copied_checks = RunnerValueCopy.freeze_copy(checks)
|
|
31
|
+
|
|
32
|
+
super(
|
|
33
|
+
findings: copied_findings,
|
|
34
|
+
failed_tools: failed_tools.map { _1.dup.freeze }.freeze,
|
|
35
|
+
checks: copied_checks
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Override Data#with so replacements cannot bypass the derived-state invariant.
|
|
40
|
+
def with(findings: self.findings, checks: self.checks)
|
|
41
|
+
self.class.new(findings: findings, checks: checks)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def exit_code
|
|
45
|
+
return ExitCode::TOOL_FAILURE if failed_tools.any?
|
|
46
|
+
return ExitCode::FINDINGS if findings.any?
|
|
47
|
+
|
|
48
|
+
ExitCode::CLEAN
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
CHECK_SCOPES = {
|
|
53
|
+
"rubocop" => "selected_files",
|
|
54
|
+
"reek" => "selected_files",
|
|
55
|
+
"test_suite" => "test_suite",
|
|
56
|
+
"undercover" => "git_diff",
|
|
57
|
+
"simplecov" => "coverage_summary",
|
|
58
|
+
"brakeman" => "project",
|
|
59
|
+
"bundler_audit" => "lockfile"
|
|
60
|
+
}.freeze
|
|
61
|
+
SELECTION_SUPPORTED_TOOLS = %w[rubocop reek].freeze
|
|
62
|
+
private_constant :CHECK_SCOPES, :SELECTION_SUPPORTED_TOOLS
|
|
63
|
+
|
|
64
|
+
def initialize(adapters:, config:, diagnostic_io: nil)
|
|
65
|
+
@adapters = adapters.dup.freeze
|
|
66
|
+
@config = config
|
|
67
|
+
@diagnostic_io = diagnostic_io
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def call
|
|
71
|
+
findings = []
|
|
72
|
+
checks = []
|
|
73
|
+
|
|
74
|
+
adapters.each do |adapter|
|
|
75
|
+
tool = adapter_name(adapter)
|
|
76
|
+
write_progress(tool, scope_for(tool))
|
|
77
|
+
started_at = monotonic_time
|
|
78
|
+
adapter_findings = adapter.call
|
|
79
|
+
findings.concat(adapter_findings)
|
|
80
|
+
checks << check_for(tool, adapter_findings, elapsed_ms_since(started_at))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
Result.new(findings:, checks:)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
attr_reader :adapters, :config, :diagnostic_io
|
|
89
|
+
|
|
90
|
+
def adapter_name(adapter)
|
|
91
|
+
adapter.name.to_s
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def check_for(tool, findings, duration_ms)
|
|
95
|
+
{
|
|
96
|
+
tool: tool,
|
|
97
|
+
status: status_for(findings),
|
|
98
|
+
scope: scope_for(tool),
|
|
99
|
+
requested_files: selection_supported?(tool) ? requested_files : [],
|
|
100
|
+
duration_ms: duration_ms
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def selection_supported?(tool)
|
|
105
|
+
SELECTION_SUPPORTED_TOOLS.include?(tool)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def scope_for(tool)
|
|
109
|
+
scope = CHECK_SCOPES.fetch(tool, "unknown")
|
|
110
|
+
return "project" if scope == "selected_files" && requested_files.empty?
|
|
111
|
+
|
|
112
|
+
scope
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def requested_files
|
|
116
|
+
config.fetch(:files).map(&:to_s)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def status_for(findings)
|
|
120
|
+
return "tool_failure" if findings.any?(&:tool_failure?)
|
|
121
|
+
return "skipped" if findings.any? { _1.rule == "undercover_skipped" }
|
|
122
|
+
return "findings" if findings.any?
|
|
123
|
+
|
|
124
|
+
"clean"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def elapsed_ms_since(started_at)
|
|
128
|
+
[(monotonic_time - started_at) * 1000, 0].max.round
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def monotonic_time
|
|
132
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def write_progress(tool, scope)
|
|
136
|
+
return unless diagnostic_io
|
|
137
|
+
|
|
138
|
+
diagnostic_io.puts("Running #{sanitize_progress(tool)} (scope: #{scope})...")
|
|
139
|
+
rescue StandardError
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def sanitize_progress(value)
|
|
144
|
+
value.to_s
|
|
145
|
+
.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "�")
|
|
146
|
+
.gsub(/(?:[[:cntrl:]]|[\u2028\u2029])+/, " ")
|
|
147
|
+
.strip
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
data/lib/quality_gate.rb
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "quality_gate/version"
|
|
4
|
+
require_relative "quality_gate/exit_code"
|
|
5
|
+
require_relative "quality_gate/finding"
|
|
6
|
+
require_relative "quality_gate/reporters/field_sanitizer"
|
|
7
|
+
require_relative "quality_gate/reporters/json"
|
|
8
|
+
require_relative "quality_gate/reporters/text"
|
|
9
|
+
require_relative "quality_gate/hook_log"
|
|
10
|
+
|
|
11
|
+
module QualityGate
|
|
12
|
+
class Error < StandardError; end
|
|
13
|
+
# Your code goes here...
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
require_relative "quality_gate/adapter"
|
|
17
|
+
require_relative "quality_gate/adapters/brakeman"
|
|
18
|
+
require_relative "quality_gate/adapters/bundler_audit"
|
|
19
|
+
require_relative "quality_gate/adapters/reek"
|
|
20
|
+
require_relative "quality_gate/adapters/rubocop"
|
|
21
|
+
require_relative "quality_gate/adapters/simplecov"
|
|
22
|
+
require_relative "quality_gate/adapters/test_suite"
|
|
23
|
+
require_relative "quality_gate/adapters/undercover"
|
|
24
|
+
require_relative "quality_gate/config"
|
|
25
|
+
require_relative "quality_gate/runner"
|
|
26
|
+
require_relative "quality_gate/cli"
|
|
27
|
+
require_relative "quality_gate/railtie" if defined?(Rails::Railtie)
|