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.
@@ -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