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,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
- stdout, stderr, status = capture(argv, resolved_timeout(tool), env:, combine_output: true)
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