cleo_quality_review 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.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/cleo_quality_review.gemspec +31 -0
  3. data/config/default.yml +7 -0
  4. data/exe/check_quality +20 -0
  5. data/lib/cleo_quality_review/changes_diff.rb +67 -0
  6. data/lib/cleo_quality_review/checks/debride.rb +65 -0
  7. data/lib/cleo_quality_review/checks/fasterer.rb +35 -0
  8. data/lib/cleo_quality_review/checks/flog.rb +35 -0
  9. data/lib/cleo_quality_review/checks/quality_check.rb +143 -0
  10. data/lib/cleo_quality_review/checks/reek.rb +53 -0
  11. data/lib/cleo_quality_review/checks/registry.rb +72 -0
  12. data/lib/cleo_quality_review/checks.rb +38 -0
  13. data/lib/cleo_quality_review/cli.rb +105 -0
  14. data/lib/cleo_quality_review/command_result.rb +21 -0
  15. data/lib/cleo_quality_review/command_runner.rb +27 -0
  16. data/lib/cleo_quality_review/configuration.rb +193 -0
  17. data/lib/cleo_quality_review/diff_map.rb +95 -0
  18. data/lib/cleo_quality_review/formatter.rb +58 -0
  19. data/lib/cleo_quality_review/github_review_builder.rb +140 -0
  20. data/lib/cleo_quality_review/github_review_publisher.rb +150 -0
  21. data/lib/cleo_quality_review/llm_client.rb +59 -0
  22. data/lib/cleo_quality_review/llm_config.rb +40 -0
  23. data/lib/cleo_quality_review/llm_errors.rb +19 -0
  24. data/lib/cleo_quality_review/llm_logger.rb +66 -0
  25. data/lib/cleo_quality_review/llm_providers/open_ai.rb +188 -0
  26. data/lib/cleo_quality_review/llm_providers/open_ai_config.rb +83 -0
  27. data/lib/cleo_quality_review/llm_providers/registry.rb +61 -0
  28. data/lib/cleo_quality_review/llm_providers/stub.rb +107 -0
  29. data/lib/cleo_quality_review/llm_providers.rb +44 -0
  30. data/lib/cleo_quality_review/options.rb +171 -0
  31. data/lib/cleo_quality_review/prompt_builder.rb +95 -0
  32. data/lib/cleo_quality_review/prompt_loader.rb +49 -0
  33. data/lib/cleo_quality_review/result.rb +58 -0
  34. data/lib/cleo_quality_review/run.rb +78 -0
  35. data/lib/cleo_quality_review/run_artifacts/raw_check_outputs.rb +97 -0
  36. data/lib/cleo_quality_review/run_artifacts.rb +146 -0
  37. data/lib/cleo_quality_review/runner.rb +158 -0
  38. data/lib/cleo_quality_review/target_resolver.rb +127 -0
  39. data/lib/cleo_quality_review/version.rb +7 -0
  40. data/lib/cleo_quality_review.rb +23 -0
  41. data/prompts/agent.md +53 -0
  42. data/prompts/github.md +29 -0
  43. data/prompts/human.md +23 -0
  44. data/prompts/pr_review.md +62 -0
  45. metadata +141 -0
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module CleoQualityReview
6
+ ##
7
+ # Parses command-line options for the quality review CLI
8
+ class Options
9
+ FORMATS = %w[human agent github pr_review].freeze
10
+ DEFAULT_FORMAT = "human"
11
+ DEFAULT_CHECKS = ["all"].freeze
12
+
13
+ ##
14
+ # Value object containing parsed command-line options
15
+ #
16
+ # @!attribute [r] format
17
+ # @return [String] output format
18
+ # @!attribute [r] checks
19
+ # @return [Array<String>] checks to run
20
+ # @!attribute [r] files
21
+ # @return [Array<String>] explicit file paths
22
+ # @!attribute [r] exclude
23
+ # @return [Array<String>] checks to exclude
24
+ # @!attribute [r] changed
25
+ # @return [Boolean] whether to filter to changed files only
26
+ ParseResult = Struct.new(:format, :checks, :files, :exclude, :changed, :log, :review_id, :review_file, keyword_init: true) do
27
+ ##
28
+ # @return [String] validated review_id
29
+ # @raise [OptionParser::MissingArgument] if review_id is blank
30
+ def validated_review_id
31
+ raise OptionParser::MissingArgument, "--review-id is required" if review_id.to_s.strip == ""
32
+
33
+ review_id
34
+ end
35
+
36
+ ##
37
+ # @return [Hash] run loading attributes
38
+ def run_loading_params
39
+ { format: format, log: log }
40
+ end
41
+ end
42
+
43
+ ##
44
+ # Parse command-line arguments
45
+ # @param [Array<String>] argv command-line arguments
46
+ # @return [ParseResult]
47
+ # @raise [OptionParser::ParseError] if arguments are invalid
48
+ def self.parse(argv)
49
+ new(argv).parse
50
+ end
51
+
52
+ ##
53
+ # @param [Array<String>] argv command-line arguments
54
+ def initialize(argv)
55
+ @argv = argv.dup
56
+ @format = DEFAULT_FORMAT
57
+ @checks = []
58
+ @files = []
59
+ @exclude = []
60
+ @changed = false
61
+ @log = false
62
+ @review_id = nil
63
+ @review_file = nil
64
+ end
65
+
66
+ ##
67
+ # Parse the arguments and return the result
68
+ # @return [ParseResult]
69
+ # @raise [OptionParser::InvalidArgument] if format is invalid
70
+ def parse
71
+ parser.parse!(argv)
72
+ validate_format!
73
+ files.concat(argv)
74
+
75
+ ParseResult.new(
76
+ format: format,
77
+ checks: checks.empty? ? DEFAULT_CHECKS.dup : checks,
78
+ files: files,
79
+ exclude: exclude,
80
+ changed: changed,
81
+ log: log,
82
+ review_id: review_id,
83
+ review_file: review_file,
84
+ )
85
+ end
86
+
87
+ private
88
+
89
+ attr_reader :argv, :format, :checks, :files, :exclude, :changed, :log, :review_id, :review_file
90
+
91
+ def parser
92
+ OptionParser.new do |opts|
93
+ opts.banner = "Usage: check_quality [options] [files...]"
94
+ register_options(opts)
95
+ end
96
+ end
97
+
98
+ def register_options(opts)
99
+ register_format_option(opts)
100
+ register_check_options(opts)
101
+ register_target_options(opts)
102
+ register_output_options(opts)
103
+ register_help_option(opts)
104
+ end
105
+
106
+ def register_format_option(opts)
107
+ opts.on("-f", "--format FORMAT", FORMATS, "Output format: #{FORMATS.join(', ')} (default: human)") do |value|
108
+ @format = value
109
+ end
110
+ end
111
+
112
+ def register_check_options(opts)
113
+ register_checks_option(opts)
114
+ register_only_option(opts)
115
+ register_exclude_option(opts)
116
+ end
117
+
118
+ def register_checks_option(opts)
119
+ opts.on("-c", "--checks CHECKS", Array, "Checks to run: all, reek, flog, fasterer, debride") { |values| checks.concat(values) }
120
+ end
121
+
122
+ def register_only_option(opts)
123
+ opts.on("--only CHECKS", Array, "Alias for --checks") { |values| checks.concat(values) }
124
+ end
125
+
126
+ def register_exclude_option(opts)
127
+ opts.on("-x", "--exclude CHECKS", Array, "Checks to exclude: reek, flog, fasterer, debride") { |values| exclude.concat(values) }
128
+ end
129
+
130
+ def register_target_options(opts)
131
+ opts.on("--files PATHS", Array, "Comma-separated files or directories to check") do |values|
132
+ files.concat(values)
133
+ end
134
+
135
+ opts.on("--changed", "Only check files changed from main branch") do
136
+ @changed = true
137
+ end
138
+ end
139
+
140
+ def register_output_options(opts)
141
+ register_log_option(opts)
142
+ register_review_id_option(opts)
143
+ register_review_file_option(opts)
144
+ end
145
+
146
+ def register_log_option(opts)
147
+ opts.on("--log", "Log LLM queries and responses to log/[provider].log") { @log = true }
148
+ end
149
+
150
+ def register_review_id_option(opts)
151
+ opts.on("--review-id REVIEW_ID", "Reuse an existing analysis artifact by review ID") { |value| @review_id = value }
152
+ end
153
+
154
+ def register_review_file_option(opts)
155
+ opts.on("--review-file PATH", "Rendered pr_review JSON to publish") { |value| @review_file = value }
156
+ end
157
+
158
+ def register_help_option(opts)
159
+ opts.on("-h", "--help", "Print help") do
160
+ puts opts
161
+ exit 0
162
+ end
163
+ end
164
+
165
+ def validate_format!
166
+ return if FORMATS.include?(format)
167
+
168
+ raise OptionParser::InvalidArgument, "format must be one of: #{FORMATS.join(', ')}"
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "target_resolver"
4
+
5
+ module CleoQualityReview
6
+ ##
7
+ # Builds the complete LLM prompt from run data and artifacts
8
+ class PromptBuilder
9
+ ##
10
+ # @param [Run] run the quality review run
11
+ # @param [String] prompt base prompt template
12
+ # @param [RunArtifacts] artifacts run artifacts containing diffs and outputs
13
+ def initialize(run:, prompt:, artifacts:)
14
+ @run = run
15
+ @prompt = prompt
16
+ @artifacts = artifacts
17
+ end
18
+
19
+ ##
20
+ # Build the complete prompt with all sections
21
+ # @return [String]
22
+ def build
23
+ [
24
+ prompt,
25
+ metadata_section,
26
+ diff_section,
27
+ check_outputs_section,
28
+ target_files_section,
29
+ ].join("\n\n")
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :run, :prompt, :artifacts
35
+
36
+ def metadata_section
37
+ target_files = run.target_files
38
+ <<~MARKDOWN
39
+ ## Run metadata
40
+
41
+ Review ID: #{run.review_id}
42
+ Timestamp: #{run.timestamp}
43
+ Checks: #{run.checks.join(", ")}
44
+ Target files: #{target_files.empty? ? "(none)" : target_files.join(", ")}
45
+ MARKDOWN
46
+ end
47
+
48
+ def diff_section
49
+ fenced("Git diff against #{TargetResolver::BASE_REF}", "diff", artifacts.changes_diff)
50
+ end
51
+
52
+ def check_outputs_section
53
+ artifacts.raw_check_output_records.map do |record|
54
+ fenced("Raw #{raw_output_title(record)} output", language_for(record.path), record.raw_output)
55
+ end.join("\n\n")
56
+ end
57
+
58
+ def target_files_section
59
+ run.target_files.map do |path|
60
+ fenced("File: #{path}", language_for(path), file_content(path))
61
+ end.join("\n\n")
62
+ end
63
+
64
+ def file_content(path)
65
+ File.read(path, invalid: :replace, undef: :replace)
66
+ rescue Errno::ENOENT
67
+ "(file not found)"
68
+ end
69
+
70
+ def language_for(path)
71
+ case File.extname(path)
72
+ when ".json"
73
+ "json"
74
+ when ".rb"
75
+ "ruby"
76
+ else
77
+ "text"
78
+ end
79
+ end
80
+
81
+ def raw_output_title(record)
82
+ [record.tool_type, record.check_name].compact.join("/")
83
+ end
84
+
85
+ def fenced(title, language, content)
86
+ <<~MARKDOWN
87
+ ## #{title}
88
+
89
+ ```#{language}
90
+ #{content}
91
+ ```
92
+ MARKDOWN
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CleoQualityReview
4
+ ##
5
+ # Loads prompt templates from local or gem-bundled locations
6
+ class PromptLoader
7
+ GEM_PROMPTS_DIRECTORY = File.expand_path("../../prompts", __dir__)
8
+ LOCAL_PROMPTS_DIRECTORY = ".cleo_quality_review"
9
+
10
+ ##
11
+ # Load a prompt template for the given format
12
+ # @param [String] format output format name
13
+ # @return [String] prompt template content
14
+ # @raise [ArgumentError] if no prompt found for the format
15
+ def self.load(format: "human")
16
+ new(format: format).load
17
+ end
18
+
19
+ ##
20
+ # @param [String] format output format name
21
+ def initialize(format:)
22
+ @format = format
23
+ end
24
+
25
+ ##
26
+ # Load the prompt template
27
+ # @return [String] prompt content
28
+ # @raise [ArgumentError] if no prompt found
29
+ def load
30
+ prompt_paths.each do |path|
31
+ return File.read(path) if File.file?(path)
32
+ end
33
+
34
+ raise ArgumentError, "No prompt found for format #{format.inspect}"
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :format
40
+
41
+ def prompt_paths
42
+ [
43
+ File.join(LOCAL_PROMPTS_DIRECTORY, "prompts", "#{format}.md"),
44
+ File.join(LOCAL_PROMPTS_DIRECTORY, "#{format}.md"),
45
+ File.join(GEM_PROMPTS_DIRECTORY, "#{format}.md"),
46
+ ].compact
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CleoQualityReview
4
+ ##
5
+ # Value object representing a single finding from a quality check
6
+ #
7
+ # @!attribute [r] tool_name
8
+ # @return [String] name of the tool that produced this result
9
+ # @!attribute [r] tool_type
10
+ # @return [String] category for the tool that produced this result
11
+ # @!attribute [r] check
12
+ # @return [String] specific check or rule that triggered
13
+ # @!attribute [r] timestamp
14
+ # @return [Integer] epoch milliseconds when the check ran
15
+ # @!attribute [r] result
16
+ # @return [String] description of the finding
17
+ # @!attribute [r] filepath
18
+ # @return [String, nil] path to the file with the issue
19
+ # @!attribute [r] line
20
+ # @return [Integer, nil] line number of the issue
21
+ Result = Struct.new(
22
+ :tool_name,
23
+ :tool_type,
24
+ :check,
25
+ :timestamp,
26
+ :result,
27
+ :filepath,
28
+ :line,
29
+ keyword_init: true,
30
+ ) do
31
+ def self.from_h(hash)
32
+ new(
33
+ tool_name: hash["tool_name"] || hash["tool"],
34
+ tool_type: hash["tool_type"],
35
+ check: hash["check"],
36
+ timestamp: hash["timestamp"],
37
+ result: hash["result"],
38
+ filepath: hash["filepath"],
39
+ line: hash["line"],
40
+ )
41
+ end
42
+
43
+ ##
44
+ # Convert the result to a hash, omitting nil values
45
+ # @return [Hash{Symbol => Object}]
46
+ def to_h
47
+ {
48
+ tool_name: tool_name,
49
+ tool_type: tool_type,
50
+ check: check,
51
+ timestamp: timestamp,
52
+ result: result,
53
+ filepath: filepath,
54
+ line: line,
55
+ }.compact
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CleoQualityReview
4
+ ##
5
+ # Value object representing a quality review run with its configuration and results
6
+ #
7
+ # @!attribute [r] timestamp
8
+ # @return [Integer] epoch milliseconds when the run started
9
+ # @!attribute [r] review_id
10
+ # @return [String] deterministic identifier for the reviewed diff
11
+ # @!attribute [r] format
12
+ # @return [String] output format (human, agent, github)
13
+ # @!attribute [r] checks
14
+ # @return [Array<String>] names of checks that were run
15
+ # @!attribute [r] target_files
16
+ # @return [Array<String>] file paths that were analyzed
17
+ # @!attribute [r] ruby_files
18
+ # @return [Array<String>] Ruby file paths that were analyzed
19
+ # @!attribute [r] run_directory
20
+ # @return [String] path to the directory containing run artifacts
21
+ # @!attribute [r] results
22
+ # @return [Array<Result>] findings from the quality checks
23
+ # @!attribute [r] artifacts
24
+ # @return [RunArtifacts, nil] artifacts associated with this run
25
+ Run = Struct.new(
26
+ :timestamp,
27
+ :review_id,
28
+ :format,
29
+ :checks,
30
+ :target_files,
31
+ :ruby_files,
32
+ :run_directory,
33
+ :results,
34
+ :artifacts,
35
+ :log,
36
+ keyword_init: true,
37
+ ) do
38
+ ##
39
+ # Convert the run to a hash representation
40
+ # @return [Hash{Symbol => Object}]
41
+ def to_h
42
+ {
43
+ timestamp: timestamp,
44
+ review_id: review_id,
45
+ format: format,
46
+ checks: checks,
47
+ target_files: target_files,
48
+ ruby_files: ruby_files,
49
+ run_directory: run_directory,
50
+ changes_diff: artifacts&.changes_diff,
51
+ check_outputs: check_outputs,
52
+ findings: Array(results).map(&:to_h),
53
+ }
54
+ end
55
+
56
+ ##
57
+ # Build array of check output hashes for serialization
58
+ # @return [Array<Hash{Symbol => String}>]
59
+ def check_outputs
60
+ return [] unless artifacts
61
+
62
+ artifacts.raw_check_output_records.map(&:to_h)
63
+ end
64
+
65
+ ##
66
+ # Build manifest data for artifact persistence
67
+ # @return [Hash{Symbol => Object}]
68
+ def manifest_data
69
+ {
70
+ review_id: review_id,
71
+ timestamp: timestamp,
72
+ checks: checks,
73
+ target_files: target_files,
74
+ ruby_files: ruby_files,
75
+ }
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module CleoQualityReview
6
+ class RunArtifacts
7
+ ##
8
+ # Handles persisted raw output files for quality checks.
9
+ class RawCheckOutputs
10
+ ##
11
+ # Raw output content and metadata for one quality check.
12
+ Record = Struct.new(:check_name, :tool_name, :tool_type, :extension, :path, :raw_output, keyword_init: true) do
13
+ def self.from_path(filepath:, check_name:, tool_type:)
14
+ new(
15
+ check_name: check_name,
16
+ tool_name: check_name,
17
+ tool_type: tool_type,
18
+ extension: File.extname(filepath).delete_prefix("."),
19
+ path: filepath,
20
+ raw_output: File.read(filepath, invalid: :replace, undef: :replace),
21
+ )
22
+ end
23
+
24
+ def to_pair
25
+ [check_name, raw_output]
26
+ end
27
+
28
+ def to_record_pair
29
+ [check_name, self]
30
+ end
31
+
32
+ def to_h
33
+ {
34
+ check_name: check_name,
35
+ tool_name: tool_name,
36
+ tool_type: tool_type,
37
+ extension: extension,
38
+ path: path,
39
+ raw_output: raw_output,
40
+ }.compact
41
+ end
42
+ end
43
+
44
+ def initialize(path:)
45
+ @path = path
46
+ end
47
+
48
+ def write(check_output)
49
+ check_name, tool_type, extension, output = check_output.to_h.values_at(
50
+ :check_name, :tool_type, :extension, :raw_output
51
+ )
52
+ check_path = check_output_path(check_name: check_name, tool_type: tool_type)
53
+ FileUtils.mkdir_p(check_path)
54
+ File.write(File.join(check_path, "raw_output.#{extension}"), output)
55
+ end
56
+
57
+ def to_h
58
+ records.to_h(&:to_pair)
59
+ end
60
+
61
+ def records
62
+ records_by_check_name = legacy_records.to_h(&:to_record_pair)
63
+ records_by_check_name.merge!(typed_records.to_h(&:to_record_pair))
64
+ records_by_check_name.values
65
+ end
66
+
67
+ private
68
+
69
+ attr_reader :path
70
+
71
+ def check_output_path(check_name:, tool_type:)
72
+ normalized_tool_type = tool_type.to_s.strip
73
+ return File.join(path, check_name) if normalized_tool_type.empty?
74
+
75
+ File.join(path, normalized_tool_type, check_name)
76
+ end
77
+
78
+ def typed_records
79
+ Dir.glob(File.join(path, "*", "*", "raw_output.*")).sort.map do |filepath|
80
+ check_dir = File.dirname(filepath)
81
+ check_name = File.basename(check_dir)
82
+ tool_type = File.basename(File.dirname(check_dir))
83
+
84
+ Record.from_path(filepath: filepath, check_name: check_name, tool_type: tool_type)
85
+ end
86
+ end
87
+
88
+ def legacy_records
89
+ Dir.glob(File.join(path, "*", "raw_output.*")).sort.map do |filepath|
90
+ check_name = File.basename(File.dirname(filepath))
91
+
92
+ Record.from_path(filepath: filepath, check_name: check_name, tool_type: nil)
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ require_relative "result"
7
+ require_relative "run"
8
+ require_relative "run_artifacts/raw_check_outputs"
9
+
10
+ module CleoQualityReview
11
+ ##
12
+ # Manages artifacts produced during a quality review run
13
+ class RunArtifacts
14
+ ROOT = "tmp/quality_checks"
15
+ RawCheckOutput = RawCheckOutputs::Record
16
+
17
+ ##
18
+ # @param [String] review_id deterministic identifier for the reviewed diff
19
+ # @param [Integer] timestamp epoch milliseconds for the run
20
+ # @param [Array<String>] target_files file paths being analyzed
21
+ # @param [String] changes_diff captured git diff content
22
+ def initialize(review_id:, changes_diff: nil, **_run_metadata)
23
+ @review_id = review_id.to_s
24
+ @changes_diff_content = changes_diff
25
+ @path = File.join(ROOT, @review_id)
26
+ end
27
+
28
+ ##
29
+ # Load artifacts by review ID
30
+ # @param [String] review_id deterministic identifier for the reviewed diff
31
+ # @return [RunArtifacts]
32
+ def self.load(review_id:)
33
+ new(review_id: review_id)
34
+ end
35
+
36
+ ##
37
+ # Prepare the artifact directory and capture initial data
38
+ # @return [self]
39
+ def prepare!
40
+ FileUtils.mkdir_p(@path)
41
+ write_changes_diff
42
+ self
43
+ end
44
+
45
+ ##
46
+ # @return [Boolean] whether this artifact directory contains a complete analysis
47
+ def complete?
48
+ File.file?(artifact_path("complete.json"))
49
+ end
50
+
51
+ ##
52
+ # Write raw check output to a file
53
+ # @param [Checks::CheckOutput] check_output output record from a quality check
54
+ # @return [void]
55
+ def write_check_output(check_output)
56
+ raw_check_output_store.write(check_output)
57
+ end
58
+
59
+ ##
60
+ # Persist run metadata for later render/publish commands
61
+ # @param [Run] run completed run
62
+ # @return [void]
63
+ def write_run(run)
64
+ File.write(artifact_path("results.json"), JSON.pretty_generate(Array(run.results).map(&:to_h)))
65
+ File.write(artifact_path("manifest.json"), JSON.pretty_generate(run.manifest_data))
66
+ File.write(artifact_path("complete.json"), JSON.pretty_generate({ review_id: @review_id, completed: true }))
67
+ end
68
+
69
+ ##
70
+ # Reconstruct a run from persisted artifacts
71
+ # @param [String] format output format to render
72
+ # @param [Boolean] log whether LLM logging should be enabled
73
+ # @return [Run]
74
+ def to_run(format:, log: false)
75
+ raise ArgumentError, "No completed quality review artifacts found for review ID #{@review_id}" unless complete?
76
+
77
+ manifest = read_manifest
78
+ target_files = manifest.fetch("target_files", [])
79
+ Run.new(
80
+ timestamp: manifest.fetch("timestamp"),
81
+ review_id: manifest.fetch("review_id"),
82
+ format: format,
83
+ checks: manifest.fetch("checks", []),
84
+ target_files: target_files,
85
+ ruby_files: manifest.fetch("ruby_files", target_files),
86
+ run_directory: @path,
87
+ results: read_results,
88
+ artifacts: self,
89
+ log: log,
90
+ )
91
+ end
92
+
93
+ ##
94
+ # Read the captured git diff for changes
95
+ # @return [String]
96
+ def changes_diff
97
+ File.read(artifact_path("changes.diff"))
98
+ end
99
+
100
+ ##
101
+ # Read all raw check outputs from the artifact directory
102
+ # @return [Hash{String => String}] check name to output content mapping
103
+ def raw_check_outputs
104
+ raw_check_output_store.to_h
105
+ end
106
+
107
+ ##
108
+ # Read all raw check outputs with metadata from the artifact directory
109
+ # @return [Array<RawCheckOutputs::Record>]
110
+ def raw_check_output_records
111
+ raw_check_output_store.records
112
+ end
113
+
114
+ ##
115
+ # @return [String] path to the artifacts directory
116
+ def to_s
117
+ @path
118
+ end
119
+
120
+ private
121
+
122
+ def artifact_path(filename)
123
+ File.join(@path, filename)
124
+ end
125
+
126
+ def write_changes_diff
127
+ File.write(artifact_path("changes.diff"), @changes_diff_content.to_s)
128
+ end
129
+
130
+ def read_manifest
131
+ JSON.parse(File.read(artifact_path("manifest.json")))
132
+ rescue Errno::ENOENT
133
+ raise ArgumentError, "Missing manifest for review ID #{@review_id}"
134
+ end
135
+
136
+ def read_results
137
+ JSON.parse(File.read(artifact_path("results.json"))).map { |hash| Result.from_h(hash) }
138
+ rescue Errno::ENOENT
139
+ []
140
+ end
141
+
142
+ def raw_check_output_store
143
+ @raw_check_output_store ||= RawCheckOutputs.new(path: @path)
144
+ end
145
+ end
146
+ end