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,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require_relative "rubycritic_report"
|
|
6
|
+
|
|
7
|
+
module QualityGate
|
|
8
|
+
module Adapters
|
|
9
|
+
# Runs RubyCritic and normalizes its JSON report into warnings.
|
|
10
|
+
class RubyCritic < Adapter
|
|
11
|
+
DEFAULT_LAUNCHER = ["rubycritic"].freeze
|
|
12
|
+
REPRESENTATIVE_REPORT_DIRECTORY = File.join(Dir.tmpdir, "quality-gate-rubycritic-report").freeze
|
|
13
|
+
private_constant :DEFAULT_LAUNCHER, :REPRESENTATIVE_REPORT_DIRECTORY
|
|
14
|
+
|
|
15
|
+
def name = "rubycritic"
|
|
16
|
+
|
|
17
|
+
def command = command_for(REPRESENTATIVE_REPORT_DIRECTORY)
|
|
18
|
+
|
|
19
|
+
def call
|
|
20
|
+
tool = name
|
|
21
|
+
stderr = +""
|
|
22
|
+
findings_in_temp_directory(tool) { stderr = _1 }
|
|
23
|
+
rescue StandardError => e
|
|
24
|
+
[failure_finding(tool, e, stderr)]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def parse(json)
|
|
28
|
+
RubyCriticReport.new(json).findings
|
|
29
|
+
rescue JSON::ParserError, KeyError, TypeError, ArgumentError => e
|
|
30
|
+
raise ParseError.new(tool: name, reason: e.message)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def command_for(report_directory)
|
|
36
|
+
launcher = config.fetch(:commands).fetch(:deep, {}).fetch(:rubycritic, DEFAULT_LAUNCHER)
|
|
37
|
+
launcher.dup.concat(
|
|
38
|
+
["--format", "json", "--no-browser", "--minimum-score", "0", "--path", report_directory, "."]
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def findings_in_temp_directory(tool, &on_stderr)
|
|
43
|
+
Dir.mktmpdir("quality-gate-rubycritic-") do |directory|
|
|
44
|
+
findings_at(directory, tool, &on_stderr)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def findings_at(directory, tool)
|
|
49
|
+
stderr, status = capture_report(directory, tool)
|
|
50
|
+
yield stderr
|
|
51
|
+
validate_successful_exit!(status)
|
|
52
|
+
parse(read_report(directory, tool))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def capture_report(directory, tool)
|
|
56
|
+
_stdout, stderr, status = capture(command_for(directory), resolved_timeout(tool))
|
|
57
|
+
[stderr, status]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def read_report(directory, tool)
|
|
61
|
+
report_path = File.join(directory, "report.json")
|
|
62
|
+
raise ParseError.new(tool:, reason: "report file is missing") unless File.file?(report_path)
|
|
63
|
+
|
|
64
|
+
File.read(report_path)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def validate_successful_exit!(status)
|
|
68
|
+
return if status&.exited? && status.exitstatus.zero?
|
|
69
|
+
|
|
70
|
+
raise ParseError.new(tool: name, reason: "process did not exit normally with status 0")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module QualityGate
|
|
6
|
+
module Adapters
|
|
7
|
+
# Validates RubyCritic's JSON document and converts smells to findings.
|
|
8
|
+
class RubyCriticReport
|
|
9
|
+
def initialize(json)
|
|
10
|
+
@report = JSON.parse(json)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def findings
|
|
14
|
+
validate_report!
|
|
15
|
+
analysed_modules.flat_map { module_findings(_1) }.uniq
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def validate_report!
|
|
21
|
+
raise TypeError, "report must be a JSON object" unless @report.is_a?(Hash)
|
|
22
|
+
|
|
23
|
+
validate_metadata!(@report.fetch("metadata"))
|
|
24
|
+
validate_score!(@report.fetch("score"))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def validate_metadata!(metadata)
|
|
28
|
+
raise TypeError, "metadata must be a JSON object" unless metadata.is_a?(Hash)
|
|
29
|
+
|
|
30
|
+
rubycritic = metadata.fetch("rubycritic")
|
|
31
|
+
raise TypeError, "metadata.rubycritic must be a JSON object" unless rubycritic.is_a?(Hash)
|
|
32
|
+
|
|
33
|
+
required_string(rubycritic.fetch("version"), "RubyCritic version")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def validate_score!(score)
|
|
37
|
+
return if score.is_a?(Numeric) && score.finite? && score.between?(0, 100)
|
|
38
|
+
|
|
39
|
+
raise TypeError, "score must be a finite number within 0..100"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def analysed_modules
|
|
43
|
+
modules = @report.fetch("analysed_modules")
|
|
44
|
+
return modules if modules.is_a?(Array) && !modules.empty?
|
|
45
|
+
|
|
46
|
+
raise TypeError, "analysed_modules must be a non-empty array"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def module_findings(entry)
|
|
50
|
+
raise TypeError, "module must be an object" unless entry.is_a?(Hash)
|
|
51
|
+
|
|
52
|
+
required_string(entry.fetch("path"), "module path")
|
|
53
|
+
smells = entry.fetch("smells")
|
|
54
|
+
raise TypeError, "smells must be an array" unless smells.is_a?(Array)
|
|
55
|
+
|
|
56
|
+
smells.flat_map { smell_findings(_1) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def smell_findings(smell)
|
|
60
|
+
raise TypeError, "smell must be an object" unless smell.is_a?(Hash)
|
|
61
|
+
|
|
62
|
+
type, context, message = smell_attributes(smell)
|
|
63
|
+
locations = smell.fetch("locations")
|
|
64
|
+
raise TypeError, "locations must be a non-empty array" unless locations.is_a?(Array) && !locations.empty?
|
|
65
|
+
|
|
66
|
+
locations.map { finding_for(_1, type, context, message) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def smell_attributes(smell)
|
|
70
|
+
[
|
|
71
|
+
required_string(smell.fetch("type"), "smell type"),
|
|
72
|
+
required_string(smell.fetch("context"), "smell context", allow_empty: true),
|
|
73
|
+
required_string(smell.fetch("message"), "smell message", allow_empty: true)
|
|
74
|
+
]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def finding_for(location, type, context, message)
|
|
78
|
+
Finding.new(
|
|
79
|
+
tool: "rubycritic", file: location_path(location), line: location_line(location),
|
|
80
|
+
rule: type, severity: :warning,
|
|
81
|
+
message: "#{context} #{message}".strip
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def location_path(location)
|
|
86
|
+
raise TypeError, "location must be an object" unless location.is_a?(Hash)
|
|
87
|
+
|
|
88
|
+
required_string(location.fetch("path"), "location path")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def location_line(location)
|
|
92
|
+
line = location.fetch("line")
|
|
93
|
+
validate_line!(line)
|
|
94
|
+
line
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def validate_line!(line)
|
|
98
|
+
return if line.is_a?(Integer) && line >= 0
|
|
99
|
+
|
|
100
|
+
raise TypeError, "location line must be an Integer greater than or equal to 0"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def required_string(value, field, allow_empty: false)
|
|
104
|
+
valid = value.is_a?(String) && (allow_empty || !value.empty?)
|
|
105
|
+
raise TypeError, "#{field} must be a #{allow_empty ? "String" : "non-empty String"}" unless valid
|
|
106
|
+
|
|
107
|
+
value
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
private_constant :RubyCriticReport
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -26,7 +26,9 @@ module QualityGate
|
|
|
26
26
|
tool = name
|
|
27
27
|
stderr = +""
|
|
28
28
|
argv = validated_command
|
|
29
|
-
|
|
29
|
+
timeout_seconds = resolved_timeout(tool)
|
|
30
|
+
invalidate_stale_simplecov_summary if simplecov_configured_for_verify?
|
|
31
|
+
stdout, stderr, status = capture(argv, timeout_seconds, env:, combine_output: true)
|
|
30
32
|
return [] if status.success?
|
|
31
33
|
|
|
32
34
|
[failed_test_finding(stdout, stderr)]
|
|
@@ -38,6 +40,15 @@ module QualityGate
|
|
|
38
40
|
|
|
39
41
|
private
|
|
40
42
|
|
|
43
|
+
def simplecov_configured_for_verify?
|
|
44
|
+
config.fetch(:adapters).fetch(:verify).any? { _1.to_s == "simplecov" }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def invalidate_stale_simplecov_summary
|
|
48
|
+
path = Adapters::SimpleCov::COVERAGE_PATH
|
|
49
|
+
File.delete(path) if File.file?(path)
|
|
50
|
+
end
|
|
51
|
+
|
|
41
52
|
def failed_test_finding(stdout, stderr)
|
|
42
53
|
test_failure(stdout, stderr, log_path: write_failure_log(stdout, stderr))
|
|
43
54
|
rescue StandardError => e
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
|
|
6
|
+
module QualityGate
|
|
7
|
+
class BaselineError < Error; end
|
|
8
|
+
|
|
9
|
+
module BaselineSchema
|
|
10
|
+
VERSION = 1
|
|
11
|
+
GATES = %w[fast verify].freeze
|
|
12
|
+
TOOLS = %w[rubocop reek herb].freeze
|
|
13
|
+
ROOT_KEYS = %w[schema_version gate tools findings].freeze
|
|
14
|
+
ENTRY_KEYS = %w[tool file rule severity message count].freeze
|
|
15
|
+
private_constant :VERSION, :GATES, :TOOLS, :ROOT_KEYS, :ENTRY_KEYS
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def validate(document)
|
|
20
|
+
validate_root(document)
|
|
21
|
+
validate_entries(document)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def validate_root(document)
|
|
25
|
+
validate_root_keys(document)
|
|
26
|
+
validate_gate(document["gate"])
|
|
27
|
+
validate_context_tools(document["tools"])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def validate_root_keys(document)
|
|
31
|
+
valid = document.is_a?(Hash) && keys_match?(document, ROOT_KEYS)
|
|
32
|
+
invalid("document must have exactly the snapshot keys") unless valid
|
|
33
|
+
version = document["schema_version"]
|
|
34
|
+
invalid("unsupported schema version") unless version.is_a?(Integer) && version == VERSION
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def validate_gate(gate)
|
|
38
|
+
invalid("gate must be fast or verify") unless GATES.include?(gate)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def validate_context_tools(tools)
|
|
42
|
+
validate_tools(tools)
|
|
43
|
+
invalid("at least one eligible analyzer is required") unless analyzer?(tools)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def validate_entries(document)
|
|
47
|
+
entries = document["findings"]
|
|
48
|
+
invalid("findings must be an array") unless entries.is_a?(Array)
|
|
49
|
+
entries.each { validate_entry(_1, document["tools"]) }
|
|
50
|
+
identities = entries.map { identity(_1) }
|
|
51
|
+
invalid("duplicate finding identity") unless identities.uniq == identities
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def validate_entry(entry, tools)
|
|
55
|
+
valid = entry.is_a?(Hash) && keys_match?(entry, ENTRY_KEYS)
|
|
56
|
+
invalid("finding entry must have exactly the entry keys") unless valid
|
|
57
|
+
invalid("finding tool is invalid") unless TOOLS.include?(entry["tool"]) && tools.include?(entry["tool"])
|
|
58
|
+
invalid("finding file is not canonical") unless BaselinePaths.stored?(entry["file"])
|
|
59
|
+
validate_entry_fields(entry)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def validate_entry_fields(entry)
|
|
63
|
+
invalid("finding rule must be a non-empty string") unless nonempty_string?(entry["rule"])
|
|
64
|
+
invalid("tool failure findings cannot be stored") if entry["rule"] == "tool_failure"
|
|
65
|
+
invalid("finding severity must be warning or info") unless %w[warning info].include?(entry["severity"])
|
|
66
|
+
invalid("finding message must be a string") unless entry["message"].is_a?(String)
|
|
67
|
+
validate_count(entry["count"])
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def validate_count(count)
|
|
71
|
+
invalid("finding count must be a positive integer") unless count.is_a?(Integer) && count.positive?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def validate_context(gate, tools)
|
|
75
|
+
validate_gate(gate)
|
|
76
|
+
validate_context_tools(tools)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def validate_tools(tools)
|
|
80
|
+
valid = tools.is_a?(Array) && tools.all? { nonempty_string?(_1) } && tools.uniq == tools
|
|
81
|
+
invalid("tools must be an array of unique non-empty strings") unless valid
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def identity(entry) = ENTRY_KEYS.first(5).map { entry.fetch(_1) }
|
|
85
|
+
|
|
86
|
+
def eligible?(finding)
|
|
87
|
+
TOOLS.include?(finding.tool) && %i[warning info].include?(finding.severity) && !finding.tool_failure?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def nonempty_string?(value) = value.is_a?(String) && !value.empty?
|
|
91
|
+
|
|
92
|
+
def analyzer?(tools) = tools.any? { TOOLS.include?(_1) }
|
|
93
|
+
|
|
94
|
+
def keys_match?(hash, keys) = hash.keys.all? { _1.is_a?(String) } && hash.keys.sort == keys.sort
|
|
95
|
+
|
|
96
|
+
def invalid(message) = raise(BaselineError, message)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
module BaselinePaths
|
|
100
|
+
module_function
|
|
101
|
+
|
|
102
|
+
def canonical(file, root)
|
|
103
|
+
return unless file.is_a?(String) && !file.empty?
|
|
104
|
+
|
|
105
|
+
project = File.expand_path(root)
|
|
106
|
+
relative_path(file, project)
|
|
107
|
+
rescue ArgumentError, TypeError => e
|
|
108
|
+
raise BaselineError, "invalid project root or finding path: #{e.message}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def relative_path(file, project)
|
|
112
|
+
absolute = File.expand_path(file, project)
|
|
113
|
+
prefix = project.end_with?(File::SEPARATOR) ? project : "#{project}#{File::SEPARATOR}"
|
|
114
|
+
absolute.delete_prefix(prefix) if absolute.start_with?(prefix)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def stored?(file)
|
|
118
|
+
return false unless file.is_a?(String) && !file.empty?
|
|
119
|
+
|
|
120
|
+
!file.start_with?(File::SEPARATOR) && !file.include?("\0") && canonical_segments?(file)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def canonical_segments?(file)
|
|
124
|
+
segments = file.split(File::SEPARATOR)
|
|
125
|
+
valid = !segments.include?("..") && segments.none? { |part| part.empty? || part == "." }
|
|
126
|
+
valid && segments.join(File::SEPARATOR) == file
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
module BaselinePersistence
|
|
131
|
+
module_function
|
|
132
|
+
|
|
133
|
+
def read(path)
|
|
134
|
+
Baseline.from_h(JSON.parse(File.read(path)))
|
|
135
|
+
rescue JSON::ParserError, SystemCallError, IOError => e
|
|
136
|
+
raise BaselineError, "cannot read baseline: #{e.message}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def write(path, document, create:)
|
|
140
|
+
bytes = serialize(document)
|
|
141
|
+
create ? create_file(path, bytes) : replace_file(path, bytes)
|
|
142
|
+
rescue SystemCallError, IOError, JSON::GeneratorError => e
|
|
143
|
+
raise BaselineError, "cannot write baseline: #{e.message}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def serialize(document) = "#{JSON.pretty_generate(document, indent: " ")}\n"
|
|
147
|
+
|
|
148
|
+
def create_file(path, bytes)
|
|
149
|
+
File.open(path, File::WRONLY | File::CREAT | File::EXCL) { _1.write(bytes) }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def replace_file(path, bytes) = replace_with_mode(path, bytes, file_mode(path))
|
|
153
|
+
|
|
154
|
+
def file_mode(path) = File.stat(path).mode & 0o777
|
|
155
|
+
|
|
156
|
+
def replace_with_mode(path, bytes, mode)
|
|
157
|
+
Tempfile.create([".quality-gate-baseline-", ".tmp"], File.dirname(path)) do |file|
|
|
158
|
+
write_temp(file, bytes, mode)
|
|
159
|
+
File.rename(file.path, path)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def write_temp(file, bytes, mode)
|
|
164
|
+
file.write(bytes)
|
|
165
|
+
file.flush
|
|
166
|
+
File.chmod(mode, file.path)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
class Baseline
|
|
171
|
+
Match = Data.define(:findings, :accepted)
|
|
172
|
+
private_constant :Match
|
|
173
|
+
|
|
174
|
+
def self.capture(gate:, tools:, findings:, root:)
|
|
175
|
+
BaselineSchema.validate_context(gate, tools)
|
|
176
|
+
new(gate:, tools:, entries: capture_entries(findings, root, tools))
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def self.read(path) = BaselinePersistence.read(path)
|
|
180
|
+
|
|
181
|
+
def self.from_h(document)
|
|
182
|
+
BaselineSchema.validate(document)
|
|
183
|
+
new(gate: document.fetch("gate"), tools: document.fetch("tools"), entries: document.fetch("findings"))
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def self.capture_entries(findings, root, tools)
|
|
187
|
+
raise BaselineError, "findings must be an array" unless findings.is_a?(Array)
|
|
188
|
+
|
|
189
|
+
counts = Hash.new(0)
|
|
190
|
+
findings.each { increment_count(counts, _1, root, tools) }
|
|
191
|
+
counts.sort.map { entry_from(_1) }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def self.increment_count(counts, finding, root, tools)
|
|
195
|
+
return unless tools.include?(finding.tool) && BaselineSchema.eligible?(finding)
|
|
196
|
+
|
|
197
|
+
file = BaselinePaths.canonical(finding.file, root)
|
|
198
|
+
return unless file
|
|
199
|
+
|
|
200
|
+
counts[[finding.tool, file, finding.rule, finding.severity.to_s, finding.message]] += 1
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def self.entry_from(pair)
|
|
204
|
+
key, count = pair
|
|
205
|
+
Hash[%w[tool file rule severity message].zip(key).push(["count", count])]
|
|
206
|
+
end
|
|
207
|
+
private_class_method :capture_entries, :increment_count, :entry_from
|
|
208
|
+
|
|
209
|
+
def initialize(gate:, tools:, entries:)
|
|
210
|
+
BaselineSchema.validate(snapshot_document(gate, tools, entries))
|
|
211
|
+
@gate = gate.dup.freeze
|
|
212
|
+
@tools = tools.map { _1.dup.freeze }.freeze
|
|
213
|
+
@entries = entries.sort_by { BaselineSchema.identity(_1) }.map { copy_entry(_1) }.freeze
|
|
214
|
+
freeze
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def validate_context!(gate:, tools:)
|
|
218
|
+
BaselineSchema.validate_context(gate, tools)
|
|
219
|
+
raise BaselineError, "baseline gate does not match configured gate" unless gate == @gate
|
|
220
|
+
raise BaselineError, "baseline tools do not match configured tools" unless tools == @tools
|
|
221
|
+
|
|
222
|
+
self
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def match(findings, root:)
|
|
226
|
+
remaining = @entries.to_h { [BaselineSchema.identity(_1), _1.fetch("count")] }
|
|
227
|
+
lists = [[], []]
|
|
228
|
+
findings.each { consume(_1, root, remaining, lists) }
|
|
229
|
+
Match.new(findings: lists.last.freeze, accepted: lists.first.freeze)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def write(path, create:) = BaselinePersistence.write(path, to_h, create:)
|
|
233
|
+
def count = @entries.sum { _1.fetch("count") }
|
|
234
|
+
|
|
235
|
+
def to_h
|
|
236
|
+
{ "schema_version" => 1, "gate" => @gate.dup, "tools" => @tools.map(&:dup),
|
|
237
|
+
"findings" => @entries.map { copy_entry(_1, frozen: false) } }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
private
|
|
241
|
+
|
|
242
|
+
def snapshot_document(gate, tools, entries)
|
|
243
|
+
{ "schema_version" => 1, "gate" => gate, "tools" => tools, "findings" => entries }
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def consume(finding, root, remaining, lists)
|
|
247
|
+
identity = match_identity(finding, root)
|
|
248
|
+
accepted = identity && remaining.fetch(identity, 0).positive?
|
|
249
|
+
list = accepted ? lists.first : lists.last
|
|
250
|
+
remaining[identity] -= 1 if accepted
|
|
251
|
+
list << finding
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def match_identity(finding, root)
|
|
255
|
+
return unless BaselineSchema.eligible?(finding)
|
|
256
|
+
|
|
257
|
+
file = BaselinePaths.canonical(finding.file, root)
|
|
258
|
+
[finding.tool, file, finding.rule, finding.severity.to_s, finding.message] if file
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def copy_entry(entry, frozen: true)
|
|
262
|
+
copy = entry.transform_values { _1.is_a?(String) ? _1.dup : _1 }
|
|
263
|
+
copy.each_value(&:freeze) if frozen
|
|
264
|
+
frozen ? copy.freeze : copy
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
private_constant :BaselineSchema, :BaselinePaths, :BaselinePersistence
|
|
269
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QualityGate
|
|
4
|
+
# Applies one baseline operation to a completed adapter result.
|
|
5
|
+
class BaselineRun
|
|
6
|
+
def initialize(operation:, gate:, tools:)
|
|
7
|
+
@operation = operation
|
|
8
|
+
@gate = gate
|
|
9
|
+
@tools = tools.map(&:to_s).freeze
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(result)
|
|
13
|
+
snapshot, match = snapshot_and_match(result)
|
|
14
|
+
apply(result, snapshot, match)
|
|
15
|
+
rescue BaselineError, SystemCallError, IOError => e
|
|
16
|
+
result.with(findings: result.findings + [Finding.tool_failure(tool: "baseline", message: e.message)])
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
attr_reader :operation, :gate, :tools
|
|
22
|
+
|
|
23
|
+
def snapshot_and_match(result)
|
|
24
|
+
snapshot = snapshot_for(result)
|
|
25
|
+
snapshot.validate_context!(gate:, tools:) unless operation.fetch(:mode) == :create
|
|
26
|
+
[snapshot, snapshot.match(result.findings, root: operation.fetch(:root))]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def snapshot_for(result)
|
|
30
|
+
return Baseline.read(operation.fetch(:path)) unless operation.fetch(:mode) == :create
|
|
31
|
+
|
|
32
|
+
Baseline.capture(gate:, tools:, findings: result.findings, root: operation.fetch(:root))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def apply(result, snapshot, match)
|
|
36
|
+
mode = operation.fetch(:mode)
|
|
37
|
+
return compare(result, match) if mode == :compare
|
|
38
|
+
return create(result, snapshot, match) if mode == :create
|
|
39
|
+
return ratchet(result, snapshot, match) if mode == :ratchet
|
|
40
|
+
|
|
41
|
+
raise BaselineError, "unsupported baseline mode #{mode}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def compare(result, match)
|
|
45
|
+
result.with(findings: match.findings, baseline: metadata(:compare, match.accepted))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def create(result, snapshot, match)
|
|
49
|
+
return blocked(result, :create, match) if match.findings.any?
|
|
50
|
+
|
|
51
|
+
snapshot.write(operation.fetch(:path), create: true)
|
|
52
|
+
result.with(findings: match.findings, baseline: metadata(:create, match.accepted, written: true))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def ratchet(result, snapshot, match)
|
|
56
|
+
return blocked(result, :ratchet, match) if match.findings.any?
|
|
57
|
+
|
|
58
|
+
removed = write_reduced_snapshot(result, snapshot)
|
|
59
|
+
result.with(findings: match.findings, baseline: metadata(:ratchet, match.accepted, removed:, written: true))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def write_reduced_snapshot(result, snapshot)
|
|
63
|
+
reduced = Baseline.capture(gate:, tools:, findings: result.findings, root: operation.fetch(:root))
|
|
64
|
+
reduced.write(operation.fetch(:path), create: false)
|
|
65
|
+
snapshot.count - reduced.count
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def blocked(result, mode, match)
|
|
69
|
+
findings = mode == :ratchet ? match.findings : result.findings
|
|
70
|
+
result.with(findings:, baseline: metadata(mode, match.accepted, status: "blocked"))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def metadata(mode, accepted, options = {})
|
|
74
|
+
{ mode:, status: options.fetch(:status, "applied"), accepted_findings: accepted,
|
|
75
|
+
accepted_count: accepted.length, removed_count: options.fetch(:removed, 0),
|
|
76
|
+
written: options.fetch(:written, false) }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
private_constant :BaselineRun
|
|
80
|
+
end
|