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.
- checksums.yaml +7 -0
- data/cleo_quality_review.gemspec +31 -0
- data/config/default.yml +7 -0
- data/exe/check_quality +20 -0
- data/lib/cleo_quality_review/changes_diff.rb +67 -0
- data/lib/cleo_quality_review/checks/debride.rb +65 -0
- data/lib/cleo_quality_review/checks/fasterer.rb +35 -0
- data/lib/cleo_quality_review/checks/flog.rb +35 -0
- data/lib/cleo_quality_review/checks/quality_check.rb +143 -0
- data/lib/cleo_quality_review/checks/reek.rb +53 -0
- data/lib/cleo_quality_review/checks/registry.rb +72 -0
- data/lib/cleo_quality_review/checks.rb +38 -0
- data/lib/cleo_quality_review/cli.rb +105 -0
- data/lib/cleo_quality_review/command_result.rb +21 -0
- data/lib/cleo_quality_review/command_runner.rb +27 -0
- data/lib/cleo_quality_review/configuration.rb +193 -0
- data/lib/cleo_quality_review/diff_map.rb +95 -0
- data/lib/cleo_quality_review/formatter.rb +58 -0
- data/lib/cleo_quality_review/github_review_builder.rb +140 -0
- data/lib/cleo_quality_review/github_review_publisher.rb +150 -0
- data/lib/cleo_quality_review/llm_client.rb +59 -0
- data/lib/cleo_quality_review/llm_config.rb +40 -0
- data/lib/cleo_quality_review/llm_errors.rb +19 -0
- data/lib/cleo_quality_review/llm_logger.rb +66 -0
- data/lib/cleo_quality_review/llm_providers/open_ai.rb +188 -0
- data/lib/cleo_quality_review/llm_providers/open_ai_config.rb +83 -0
- data/lib/cleo_quality_review/llm_providers/registry.rb +61 -0
- data/lib/cleo_quality_review/llm_providers/stub.rb +107 -0
- data/lib/cleo_quality_review/llm_providers.rb +44 -0
- data/lib/cleo_quality_review/options.rb +171 -0
- data/lib/cleo_quality_review/prompt_builder.rb +95 -0
- data/lib/cleo_quality_review/prompt_loader.rb +49 -0
- data/lib/cleo_quality_review/result.rb +58 -0
- data/lib/cleo_quality_review/run.rb +78 -0
- data/lib/cleo_quality_review/run_artifacts/raw_check_outputs.rb +97 -0
- data/lib/cleo_quality_review/run_artifacts.rb +146 -0
- data/lib/cleo_quality_review/runner.rb +158 -0
- data/lib/cleo_quality_review/target_resolver.rb +127 -0
- data/lib/cleo_quality_review/version.rb +7 -0
- data/lib/cleo_quality_review.rb +23 -0
- data/prompts/agent.md +53 -0
- data/prompts/github.md +29 -0
- data/prompts/human.md +23 -0
- data/prompts/pr_review.md +62 -0
- 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
|