code_qualia 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/README.md +254 -0
- data/bin/code-qualia +12 -0
- data/bin/update_smoke_tests +107 -0
- data/lib/code_qualia/cli.rb +217 -0
- data/lib/code_qualia/complexity_analyzer.rb +115 -0
- data/lib/code_qualia/config.rb +83 -0
- data/lib/code_qualia/config_helper.rb +122 -0
- data/lib/code_qualia/config_installer.rb +170 -0
- data/lib/code_qualia/coverage_analyzer.rb +82 -0
- data/lib/code_qualia/git_analyzer.rb +69 -0
- data/lib/code_qualia/logger.rb +47 -0
- data/lib/code_qualia/pattern_expander.rb +118 -0
- data/lib/code_qualia/score_calculator.rb +143 -0
- data/lib/code_qualia.rb +68 -0
- metadata +91 -0
@@ -0,0 +1,115 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'English'
|
4
|
+
require 'json'
|
5
|
+
require 'set'
|
6
|
+
require_relative 'config_helper'
|
7
|
+
require_relative 'logger'
|
8
|
+
|
9
|
+
module CodeQualia
|
10
|
+
class ComplexityAnalyzer
|
11
|
+
def self.analyze
|
12
|
+
new.analyze
|
13
|
+
end
|
14
|
+
|
15
|
+
def analyze
|
16
|
+
Logger.log("Starting complexity analysis")
|
17
|
+
Logger.log("Running RuboCop command for complexity analysis")
|
18
|
+
|
19
|
+
rubocop_output = run_rubocop
|
20
|
+
parse_rubocop_output(rubocop_output)
|
21
|
+
rescue StandardError => e
|
22
|
+
Logger.log_error('Complexity analysis', e)
|
23
|
+
raise Error, "Failed to analyze complexity: #{e.message}"
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def get_analysis_directories
|
29
|
+
config = ConfigHelper.load_config
|
30
|
+
directories = extract_directories_from_config(config)
|
31
|
+
directories.select { |dir| Dir.exist?(dir) }
|
32
|
+
end
|
33
|
+
|
34
|
+
def extract_directories_from_config(config)
|
35
|
+
patterns = config.architectural_weights.map { |entry| entry['path'] }
|
36
|
+
directories = PatternExpander.extract_base_directories(patterns)
|
37
|
+
|
38
|
+
# Ensure directories end with '/' and exist
|
39
|
+
directories.map! { |dir| dir.end_with?('/') ? dir : "#{dir}/" }
|
40
|
+
directories.select { |dir| Dir.exist?(dir) }
|
41
|
+
end
|
42
|
+
|
43
|
+
def run_rubocop
|
44
|
+
directories = get_analysis_directories
|
45
|
+
return '' if directories.empty?
|
46
|
+
|
47
|
+
command = "bundle exec rubocop --format json --only Metrics/CyclomaticComplexity #{directories.join(' ')}"
|
48
|
+
result = `#{command} 2>/dev/null`
|
49
|
+
|
50
|
+
if last_exit_status == 127
|
51
|
+
# If bundler is not available, try without bundle exec
|
52
|
+
command = "rubocop --format json --only Metrics/CyclomaticComplexity #{directories.join(' ')}"
|
53
|
+
result = `#{command} 2>/dev/null`
|
54
|
+
end
|
55
|
+
|
56
|
+
result
|
57
|
+
end
|
58
|
+
|
59
|
+
def last_exit_status
|
60
|
+
$CHILD_STATUS.exitstatus
|
61
|
+
end
|
62
|
+
|
63
|
+
def parse_rubocop_output(output)
|
64
|
+
return {} if output.strip.empty?
|
65
|
+
|
66
|
+
# Try to find JSON portion of the output
|
67
|
+
json_start = output.index('{')
|
68
|
+
return {} unless json_start
|
69
|
+
|
70
|
+
json_output = output[json_start..]
|
71
|
+
|
72
|
+
data = JSON.parse(json_output)
|
73
|
+
results = {}
|
74
|
+
|
75
|
+
data['files'].each do |file_data|
|
76
|
+
file_path = file_data['path']
|
77
|
+
|
78
|
+
# Use relative path for consistency
|
79
|
+
relative_path = ConfigHelper.normalize_file_path(file_path)
|
80
|
+
|
81
|
+
next if relative_path.nil? || relative_path.empty?
|
82
|
+
|
83
|
+
file_data['offenses'].each do |offense|
|
84
|
+
next unless offense['cop_name'] == 'Metrics/CyclomaticComplexity'
|
85
|
+
|
86
|
+
method_info = extract_method_info(offense['message'])
|
87
|
+
next unless method_info
|
88
|
+
|
89
|
+
results[relative_path] ||= []
|
90
|
+
results[relative_path] << {
|
91
|
+
method_name: method_info[:method_name],
|
92
|
+
line_number: offense['location']['start_line'],
|
93
|
+
complexity: method_info[:complexity]
|
94
|
+
}
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
results
|
99
|
+
rescue JSON::ParserError
|
100
|
+
{}
|
101
|
+
end
|
102
|
+
|
103
|
+
def extract_method_info(message)
|
104
|
+
# Extract complexity value and method name from RuboCop message
|
105
|
+
# Example: "Cyclomatic complexity for `calculate_fee` is too high. [12/6]"
|
106
|
+
match = message.match(%r{Cyclomatic complexity for `([^`]+)` is too high\. \[(\d+)/\d+\]})
|
107
|
+
return nil unless match
|
108
|
+
|
109
|
+
{
|
110
|
+
method_name: match[1],
|
111
|
+
complexity: match[2].to_i
|
112
|
+
}
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'pattern_expander'
|
4
|
+
|
5
|
+
module CodeQualia
|
6
|
+
class Config
|
7
|
+
attr_reader :quality_weights, :importance_weights, :architectural_weights, :exclude_patterns, :git_history_days
|
8
|
+
|
9
|
+
def initialize(data = {})
|
10
|
+
@quality_weights = data['quality_weights'] || default_quality_weights
|
11
|
+
@importance_weights = data['importance_weights'] || default_importance_weights
|
12
|
+
@architectural_weights = expand_architectural_weights(data['architectural_weights'] || default_architectural_weights)
|
13
|
+
@exclude_patterns = data['exclude'] || default_exclude_patterns
|
14
|
+
@git_history_days = data['git_history_days'] || 90
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.load(file_path)
|
18
|
+
return new unless File.exist?(file_path)
|
19
|
+
|
20
|
+
data = YAML.load_file(file_path)
|
21
|
+
new(data)
|
22
|
+
rescue StandardError => e
|
23
|
+
raise Error, "Failed to load config file: #{e.message}"
|
24
|
+
end
|
25
|
+
|
26
|
+
def architectural_weight_for(file_path)
|
27
|
+
@architectural_weights.each do |entry|
|
28
|
+
return entry['weight'] if File.fnmatch(entry['path'], file_path, File::FNM_PATHNAME)
|
29
|
+
end
|
30
|
+
1.0
|
31
|
+
end
|
32
|
+
|
33
|
+
def excluded?(file_path)
|
34
|
+
@exclude_patterns.any? { |pattern| File.fnmatch(pattern, file_path, File::FNM_PATHNAME) }
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def default_quality_weights
|
40
|
+
{
|
41
|
+
'test_coverage' => 1.5,
|
42
|
+
'cyclomatic_complexity' => 1.0
|
43
|
+
}
|
44
|
+
end
|
45
|
+
|
46
|
+
def default_importance_weights
|
47
|
+
{
|
48
|
+
'change_frequency' => 0.8,
|
49
|
+
'architectural_importance' => 1.2
|
50
|
+
}
|
51
|
+
end
|
52
|
+
|
53
|
+
def default_architectural_weights
|
54
|
+
[
|
55
|
+
{ 'path' => '**/*.rb', 'weight' => 1.0 }
|
56
|
+
]
|
57
|
+
end
|
58
|
+
|
59
|
+
def default_exclude_patterns
|
60
|
+
[
|
61
|
+
'config/**/*',
|
62
|
+
'db/**/*'
|
63
|
+
]
|
64
|
+
end
|
65
|
+
|
66
|
+
def expand_architectural_weights(weights)
|
67
|
+
expanded_weights = []
|
68
|
+
weights.each do |entry|
|
69
|
+
path = entry['path']
|
70
|
+
weight = entry['weight']
|
71
|
+
if path.include?('{') && path.include?('}')
|
72
|
+
expanded_paths = PatternExpander.expand_brace_patterns(path)
|
73
|
+
expanded_paths.each do |expanded_path|
|
74
|
+
expanded_weights << { 'path' => expanded_path, 'weight' => weight }
|
75
|
+
end
|
76
|
+
else
|
77
|
+
expanded_weights << entry
|
78
|
+
end
|
79
|
+
end
|
80
|
+
expanded_weights
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,122 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'pathname'
|
4
|
+
require_relative 'pattern_expander'
|
5
|
+
require_relative 'config'
|
6
|
+
|
7
|
+
module CodeQualia
|
8
|
+
class ConfigHelper
|
9
|
+
class << self
|
10
|
+
# Gets target file patterns from configuration and expands them to glob patterns.
|
11
|
+
#
|
12
|
+
# This method reads the architectural_weights configuration, expands any brace patterns
|
13
|
+
# (e.g., 'app/{models,controllers}/**/*.rb'), and converts directory patterns to
|
14
|
+
# file patterns by appending '**/*.rb' when necessary.
|
15
|
+
#
|
16
|
+
# @return [Array<String>] Array of expanded glob patterns for target files
|
17
|
+
# @example
|
18
|
+
# ConfigHelper.get_target_patterns
|
19
|
+
# # => ["app/models/**/*.rb", "app/controllers/**/*.rb", "lib/**/*.rb"]
|
20
|
+
def get_target_patterns
|
21
|
+
config = load_config
|
22
|
+
patterns = config.architectural_weights.map { |entry| entry['path'] }
|
23
|
+
|
24
|
+
# Expand brace patterns and convert to file patterns if they're directory patterns
|
25
|
+
expanded_patterns = []
|
26
|
+
patterns.each do |pattern|
|
27
|
+
if pattern.include?('{') && pattern.include?('}')
|
28
|
+
expanded = PatternExpander.expand_brace_patterns(pattern)
|
29
|
+
expanded_patterns.concat(expanded)
|
30
|
+
else
|
31
|
+
expanded_patterns << pattern
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Convert directory patterns to file patterns
|
36
|
+
expanded_patterns.map do |pattern|
|
37
|
+
if pattern.end_with?('/')
|
38
|
+
"#{pattern}**/*.rb"
|
39
|
+
elsif pattern.end_with?('/**/*')
|
40
|
+
"#{pattern}.rb"
|
41
|
+
elsif !pattern.include?('*')
|
42
|
+
# Assume it's a directory
|
43
|
+
"#{pattern}/**/*.rb"
|
44
|
+
else
|
45
|
+
pattern
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
# Checks if a file should be included based on target patterns.
|
51
|
+
#
|
52
|
+
# This method determines whether a given file path matches any of the provided
|
53
|
+
# target patterns. Only Ruby files (.rb extension) are considered for inclusion.
|
54
|
+
#
|
55
|
+
# @param file_path [String] The file path to check
|
56
|
+
# @param target_patterns [Array<String>] Array of glob patterns to match against
|
57
|
+
# @return [Boolean] true if the file should be included, false otherwise
|
58
|
+
# @example
|
59
|
+
# patterns = ["app/models/**/*.rb", "lib/**/*.rb"]
|
60
|
+
# ConfigHelper.should_include_file?("app/models/user.rb", patterns)
|
61
|
+
# # => true
|
62
|
+
# ConfigHelper.should_include_file?("app/views/index.html.erb", patterns)
|
63
|
+
# # => false
|
64
|
+
def should_include_file?(file_path, target_patterns)
|
65
|
+
return false unless file_path.end_with?('.rb')
|
66
|
+
|
67
|
+
target_patterns.any? do |pattern|
|
68
|
+
File.fnmatch(pattern, file_path, File::FNM_PATHNAME)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
# Normalizes a file path to a relative path from the current working directory.
|
73
|
+
#
|
74
|
+
# This method takes an absolute file path and converts it to a relative path
|
75
|
+
# from the current working directory. This preserves the full directory structure
|
76
|
+
# including any intermediate directories like 'packs/users/'.
|
77
|
+
#
|
78
|
+
# @param file_path [String] The file path to normalize (can be absolute or relative)
|
79
|
+
# @return [String] Relative path from current working directory
|
80
|
+
# @example
|
81
|
+
# ConfigHelper.normalize_file_path("/full/path/to/project/packs/users/app/models/user.rb")
|
82
|
+
# # => "packs/users/app/models/user.rb"
|
83
|
+
# ConfigHelper.normalize_file_path("app/models/user.rb")
|
84
|
+
# # => "app/models/user.rb"
|
85
|
+
def normalize_file_path(file_path)
|
86
|
+
# If already a relative path, return as-is
|
87
|
+
return file_path unless Pathname.new(file_path).absolute?
|
88
|
+
|
89
|
+
# Convert absolute path to relative path from current working directory
|
90
|
+
current_dir = Dir.pwd
|
91
|
+
if file_path.start_with?(current_dir)
|
92
|
+
relative_path = file_path[(current_dir.length + 1)..-1]
|
93
|
+
return relative_path || file_path
|
94
|
+
end
|
95
|
+
|
96
|
+
# If it doesn't start with current directory, return as-is
|
97
|
+
file_path
|
98
|
+
end
|
99
|
+
|
100
|
+
# Loads the Code Qualia configuration from file or returns default configuration.
|
101
|
+
#
|
102
|
+
# This method attempts to load the configuration from the default config file
|
103
|
+
# (qualia.yml). If the file doesn't exist, it returns a new Config instance
|
104
|
+
# with default values.
|
105
|
+
#
|
106
|
+
# @return [CodeQualia::Config] Loaded configuration or default configuration
|
107
|
+
# @example
|
108
|
+
# config = ConfigHelper.load_config
|
109
|
+
# puts config.architectural_weights
|
110
|
+
# # => [{"path"=>"**/*.rb", "weight"=>1.0}]
|
111
|
+
def load_config
|
112
|
+
if File.exist?(DEFAULT_CONFIG_FILE)
|
113
|
+
Config.load(DEFAULT_CONFIG_FILE)
|
114
|
+
else
|
115
|
+
Config.new
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
private
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
@@ -0,0 +1,170 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'config_helper'
|
4
|
+
|
5
|
+
module CodeQualia
|
6
|
+
class ConfigInstaller
|
7
|
+
def initialize(directory)
|
8
|
+
@directory = File.expand_path(directory)
|
9
|
+
end
|
10
|
+
|
11
|
+
def install
|
12
|
+
if config_exists?
|
13
|
+
puts "⚠️ Configuration file '#{DEFAULT_CONFIG_FILE}' already exists."
|
14
|
+
puts ' Use --force to overwrite or remove the existing file.'
|
15
|
+
return
|
16
|
+
end
|
17
|
+
|
18
|
+
project_type = detect_project_type
|
19
|
+
puts "🔍 Detected project type: #{project_type}"
|
20
|
+
|
21
|
+
config_content = generate_config(project_type)
|
22
|
+
write_config(config_content)
|
23
|
+
|
24
|
+
puts "✅ Configuration file '#{DEFAULT_CONFIG_FILE}' created successfully!"
|
25
|
+
puts ' You can now run: code-qualia generate'
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def config_exists?
|
31
|
+
File.exist?(config_path)
|
32
|
+
end
|
33
|
+
|
34
|
+
def config_path
|
35
|
+
File.join(@directory, DEFAULT_CONFIG_FILE)
|
36
|
+
end
|
37
|
+
|
38
|
+
def detect_project_type
|
39
|
+
if rails_project?
|
40
|
+
'Rails application'
|
41
|
+
elsif gem_project?
|
42
|
+
'Ruby gem'
|
43
|
+
else
|
44
|
+
'Ruby project'
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def rails_project?
|
49
|
+
# Check for Rails indicators
|
50
|
+
gemfile_path = File.join(@directory, 'Gemfile')
|
51
|
+
return false unless File.exist?(gemfile_path)
|
52
|
+
|
53
|
+
gemfile_content = File.read(gemfile_path)
|
54
|
+
rails_in_gemfile = gemfile_content.match?(/gem\s+['"]rails['"]/)
|
55
|
+
app_directory_exists = Dir.exist?(File.join(@directory, 'app'))
|
56
|
+
|
57
|
+
rails_in_gemfile && app_directory_exists
|
58
|
+
end
|
59
|
+
|
60
|
+
def gem_project?
|
61
|
+
# Check for gem indicators
|
62
|
+
gemspec_files = Dir.glob(File.join(@directory, '*.gemspec'))
|
63
|
+
lib_directory_exists = Dir.exist?(File.join(@directory, 'lib'))
|
64
|
+
|
65
|
+
!gemspec_files.empty? && lib_directory_exists
|
66
|
+
end
|
67
|
+
|
68
|
+
def generate_config(project_type)
|
69
|
+
case project_type
|
70
|
+
when 'Rails application'
|
71
|
+
rails_config
|
72
|
+
when 'Ruby gem'
|
73
|
+
gem_config
|
74
|
+
else
|
75
|
+
default_config
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def rails_config
|
80
|
+
<<~YAML
|
81
|
+
quality_weights:
|
82
|
+
test_coverage: 1.5
|
83
|
+
cyclomatic_complexity: 1.0
|
84
|
+
|
85
|
+
importance_weights:
|
86
|
+
change_frequency: 0.8
|
87
|
+
architectural_importance: 1.2
|
88
|
+
|
89
|
+
architectural_weights:
|
90
|
+
- path: 'app/models/**/*.rb'
|
91
|
+
weight: 2.0
|
92
|
+
- path: 'app/controllers/**/*.rb'
|
93
|
+
weight: 1.5
|
94
|
+
- path: 'app/services/**/*.rb'
|
95
|
+
weight: 1.8
|
96
|
+
- path: 'app/**/*.rb'
|
97
|
+
weight: 1.0
|
98
|
+
- path: 'lib/**/*.rb'
|
99
|
+
weight: 1.0
|
100
|
+
|
101
|
+
exclude:
|
102
|
+
- 'app/channels/**/*'
|
103
|
+
- 'app/helpers/**/*'
|
104
|
+
- 'app/views/**/*'
|
105
|
+
- 'app/assets/**/*'
|
106
|
+
- 'config/**/*'
|
107
|
+
- 'db/**/*'
|
108
|
+
- 'spec/**/*'
|
109
|
+
- 'test/**/*'
|
110
|
+
|
111
|
+
git_history_days: 90
|
112
|
+
YAML
|
113
|
+
end
|
114
|
+
|
115
|
+
def gem_config
|
116
|
+
<<~YAML
|
117
|
+
quality_weights:
|
118
|
+
test_coverage: 1.5
|
119
|
+
cyclomatic_complexity: 1.0
|
120
|
+
|
121
|
+
importance_weights:
|
122
|
+
change_frequency: 0.8
|
123
|
+
architectural_importance: 1.2
|
124
|
+
|
125
|
+
architectural_weights:
|
126
|
+
- path: 'lib/**/*.rb'
|
127
|
+
weight: 1.0
|
128
|
+
- path: 'bin/**/*'
|
129
|
+
weight: 1.3
|
130
|
+
|
131
|
+
exclude:
|
132
|
+
- 'spec/**/*'
|
133
|
+
- 'test/**/*'
|
134
|
+
- 'Gemfile*'
|
135
|
+
|
136
|
+
git_history_days: 90
|
137
|
+
YAML
|
138
|
+
end
|
139
|
+
|
140
|
+
def default_config
|
141
|
+
<<~YAML
|
142
|
+
quality_weights:
|
143
|
+
test_coverage: 1.5
|
144
|
+
cyclomatic_complexity: 1.0
|
145
|
+
|
146
|
+
importance_weights:
|
147
|
+
change_frequency: 0.8
|
148
|
+
architectural_importance: 1.2
|
149
|
+
|
150
|
+
architectural_weights:
|
151
|
+
- path: 'app/**/*.rb'
|
152
|
+
weight: 1.0
|
153
|
+
- path: 'lib/**/*.rb'
|
154
|
+
weight: 1.0
|
155
|
+
|
156
|
+
exclude:
|
157
|
+
- 'config/**/*'
|
158
|
+
- 'db/**/*'
|
159
|
+
- 'spec/**/*'
|
160
|
+
- 'test/**/*'
|
161
|
+
|
162
|
+
git_history_days: 90
|
163
|
+
YAML
|
164
|
+
end
|
165
|
+
|
166
|
+
def write_config(content)
|
167
|
+
File.write(config_path, content)
|
168
|
+
end
|
169
|
+
end
|
170
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'config_helper'
|
4
|
+
require_relative 'logger'
|
5
|
+
|
6
|
+
module CodeQualia
|
7
|
+
class CoverageAnalyzer
|
8
|
+
RESULTSET_PATH = 'coverage/.resultset.json'
|
9
|
+
|
10
|
+
def self.analyze
|
11
|
+
new.analyze
|
12
|
+
end
|
13
|
+
|
14
|
+
def analyze
|
15
|
+
Logger.log("Looking for coverage data at #{RESULTSET_PATH}")
|
16
|
+
|
17
|
+
unless File.exist?(RESULTSET_PATH)
|
18
|
+
Logger.log("Coverage file not found: #{RESULTSET_PATH}")
|
19
|
+
return {}
|
20
|
+
end
|
21
|
+
|
22
|
+
Logger.log("Reading coverage data from #{RESULTSET_PATH}")
|
23
|
+
data = JSON.parse(File.read(RESULTSET_PATH))
|
24
|
+
resultset = data.values.first
|
25
|
+
|
26
|
+
unless resultset && resultset['coverage']
|
27
|
+
Logger.log("No coverage data found in resultset")
|
28
|
+
return {}
|
29
|
+
end
|
30
|
+
|
31
|
+
Logger.log("Found coverage data for #{resultset['coverage'].size} files")
|
32
|
+
parse_coverage_data(resultset['coverage'])
|
33
|
+
rescue JSON::ParserError => e
|
34
|
+
Logger.log_error('Coverage analysis', e)
|
35
|
+
raise Error, "Failed to parse coverage data: #{e.message}"
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def parse_coverage_data(coverage_data)
|
41
|
+
|
42
|
+
results = {}
|
43
|
+
target_patterns = ConfigHelper.get_target_patterns
|
44
|
+
|
45
|
+
coverage_data.each do |file_path, line_coverage|
|
46
|
+
next unless should_include_file_for_coverage?(file_path, target_patterns)
|
47
|
+
next if line_coverage.nil?
|
48
|
+
|
49
|
+
line_data = line_coverage.is_a?(Hash) ? line_coverage['lines'] : line_coverage
|
50
|
+
next if line_data.nil?
|
51
|
+
|
52
|
+
covered_lines = line_data.count { |hits| hits&.positive? }
|
53
|
+
total_lines = line_data.count { |hits| !hits.nil? }
|
54
|
+
|
55
|
+
next if total_lines.zero?
|
56
|
+
|
57
|
+
coverage_rate = covered_lines.to_f / total_lines
|
58
|
+
relative_path = ConfigHelper.normalize_file_path(file_path)
|
59
|
+
|
60
|
+
next if relative_path.nil?
|
61
|
+
|
62
|
+
results[relative_path] = {
|
63
|
+
coverage_rate: coverage_rate,
|
64
|
+
covered_lines: covered_lines,
|
65
|
+
total_lines: total_lines,
|
66
|
+
line_coverage: line_data
|
67
|
+
}
|
68
|
+
end
|
69
|
+
|
70
|
+
results
|
71
|
+
end
|
72
|
+
|
73
|
+
def should_include_file_for_coverage?(file_path, target_patterns)
|
74
|
+
return false unless file_path.end_with?('.rb')
|
75
|
+
|
76
|
+
target_patterns.any? do |pattern|
|
77
|
+
File.fnmatch(pattern, file_path, File::FNM_PATHNAME) ||
|
78
|
+
File.fnmatch("**/#{pattern}", file_path, File::FNM_PATHNAME)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'date'
|
4
|
+
require_relative 'config_helper'
|
5
|
+
require_relative 'logger'
|
6
|
+
|
7
|
+
module CodeQualia
|
8
|
+
class GitAnalyzer
|
9
|
+
def self.analyze(days = 90)
|
10
|
+
new(days).analyze
|
11
|
+
end
|
12
|
+
|
13
|
+
def initialize(days = 90)
|
14
|
+
@days = days
|
15
|
+
end
|
16
|
+
|
17
|
+
def analyze
|
18
|
+
Logger.log("Checking if current directory is a git repository")
|
19
|
+
|
20
|
+
unless git_repository?
|
21
|
+
Logger.log("Not a git repository, skipping git analysis")
|
22
|
+
return {}
|
23
|
+
end
|
24
|
+
|
25
|
+
Logger.log("Git repository detected, analyzing #{@days} days of history")
|
26
|
+
config = ConfigHelper.load_config
|
27
|
+
git_log_output = run_git_log
|
28
|
+
Logger.log("Git log retrieved, parsing file changes")
|
29
|
+
|
30
|
+
result = parse_git_log(git_log_output, config)
|
31
|
+
Logger.log("Found git history for #{result.size} files")
|
32
|
+
result
|
33
|
+
rescue StandardError => e
|
34
|
+
Logger.log_error('Git analysis', e)
|
35
|
+
raise Error, "Failed to analyze git history: #{e.message}"
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def git_repository?
|
41
|
+
system('git rev-parse --git-dir > /dev/null 2>&1')
|
42
|
+
end
|
43
|
+
|
44
|
+
def run_git_log
|
45
|
+
since_date = (Date.today - @days).strftime('%Y-%m-%d')
|
46
|
+
command = "git log --since=#{since_date} --name-only --pretty=format:"
|
47
|
+
`#{command} 2>/dev/null`
|
48
|
+
end
|
49
|
+
|
50
|
+
def parse_git_log(output, config = nil)
|
51
|
+
# For backward compatibility with tests
|
52
|
+
config = ConfigHelper.load_config if config.nil?
|
53
|
+
|
54
|
+
results = {}
|
55
|
+
target_patterns = ConfigHelper.get_target_patterns
|
56
|
+
|
57
|
+
output.split("\n").each do |line|
|
58
|
+
line = line.strip
|
59
|
+
next if line.empty?
|
60
|
+
next unless ConfigHelper.should_include_file?(line, target_patterns)
|
61
|
+
|
62
|
+
results[line] ||= 0
|
63
|
+
results[line] += 1
|
64
|
+
end
|
65
|
+
|
66
|
+
results
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module CodeQualia
|
4
|
+
class Logger
|
5
|
+
class << self
|
6
|
+
attr_accessor :verbose
|
7
|
+
|
8
|
+
def log(message)
|
9
|
+
return unless verbose
|
10
|
+
|
11
|
+
timestamp = Time.now.strftime('%H:%M:%S')
|
12
|
+
$stderr.puts "[#{timestamp}] #{message}"
|
13
|
+
end
|
14
|
+
|
15
|
+
def log_step(step_name)
|
16
|
+
return unless verbose
|
17
|
+
|
18
|
+
timestamp = Time.now.strftime('%H:%M:%S')
|
19
|
+
$stderr.puts "[#{timestamp}] 🔍 Starting #{step_name}..."
|
20
|
+
end
|
21
|
+
|
22
|
+
def log_result(step_name, result_count = nil, duration = nil)
|
23
|
+
return unless verbose
|
24
|
+
|
25
|
+
timestamp = Time.now.strftime('%H:%M:%S')
|
26
|
+
message = "[#{timestamp}] ✅ #{step_name} completed"
|
27
|
+
message += " (#{result_count} items)" if result_count
|
28
|
+
message += " in #{duration.round(2)}s" if duration
|
29
|
+
$stderr.puts message
|
30
|
+
end
|
31
|
+
|
32
|
+
def log_error(step_name, error)
|
33
|
+
return unless verbose
|
34
|
+
|
35
|
+
timestamp = Time.now.strftime('%H:%M:%S')
|
36
|
+
$stderr.puts "[#{timestamp}] ❌ #{step_name} failed: #{error.message}"
|
37
|
+
end
|
38
|
+
|
39
|
+
def log_skip(step_name, reason)
|
40
|
+
return unless verbose
|
41
|
+
|
42
|
+
timestamp = Time.now.strftime('%H:%M:%S')
|
43
|
+
$stderr.puts "[#{timestamp}] ⏭️ Skipping #{step_name}: #{reason}"
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|