diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index da597b0..6fa85b9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - ruby: [3.1, 3.2, 3.3, 3.4] + ruby: [3.3, 3.4, '4.0'] name: Ruby ${{ matrix.ruby }} steps: - uses: actions/checkout@v4 diff --git a/.rubocop.yml b/.rubocop.yml index 803d3ab..37ec132 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,4 +1,6 @@ AllCops: + NewCops: disable + SuggestExtensions: false Exclude: - spec/fixtures/**/*.rb @@ -20,18 +22,11 @@ Metrics/BlockLength: Exclude: - spec/**/*.rb -Layout/HeredocIndentation: - Exclude: - - spec/code_keeper/formatter_spec.rb - - lib/code_keeper/formatter.rb - Naming/HeredocDelimiterNaming: Enabled: false Metrics/MethodLength: - Max: 20 - Exclude: - - lib/code_keeper/formatter.rb # because uses here doc + Max: 40 # Because TargetFileNotFoundError needs to be passed an argument to initializer. Style/RaiseArgs: @@ -56,6 +51,3 @@ Metrics/PerceivedComplexity: Exclude: # It's hard to control. - lib/code_keeper/metrics/class_length.rb - -Metrics/MethodLength: - Max: 40 diff --git a/Gemfile b/Gemfile index 060b79f..fce0f85 100644 --- a/Gemfile +++ b/Gemfile @@ -8,5 +8,3 @@ gemspec gem "rake", "~> 13.0" gem "rspec", "~> 3.0" - -gem "csv" if RUBY_VERSION >= '3.4' diff --git a/Gemfile.lock b/Gemfile.lock index 959a96c..3e151b2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,27 +1,26 @@ PATH remote: . specs: - code_keeper (0.6.1) - parallel (>= 1.20.1, < 2) - rubocop (>= 1.13.0) - rubocop-ast (>= 1.4.1) + code_keeper (1.0.0) + rubocop (>= 1.88.0) GEM remote: https://rubygems.org/ specs: - ast (2.4.2) - csv (3.3.2) + ast (2.4.3) diff-lcs (1.5.1) - json (2.9.0) - language_server-protocol (3.17.0.3) - parallel (1.26.3) - parser (3.3.6.0) + json (2.20.0) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + parallel (1.28.0) + parser (3.3.11.1) ast (~> 2.4.1) racc + prism (1.9.0) racc (1.8.1) rainbow (3.1.1) rake (13.2.1) - regexp_parser (2.9.3) + regexp_parser (2.12.0) rspec (3.13.0) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) @@ -35,29 +34,30 @@ GEM diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-support (3.13.2) - rubocop (1.69.2) + rubocop (1.88.0) json (~> 2.3) - language_server-protocol (>= 3.17.0) - parallel (~> 1.10) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (>= 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.36.2, < 2.0) + rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.36.2) - parser (>= 3.3.1.0) + rubocop-ast (1.49.1) + parser (>= 3.3.7.2) + prism (~> 1.7) ruby-progressbar (1.13.0) - unicode-display_width (3.1.2) - unicode-emoji (~> 4.0, >= 4.0.4) - unicode-emoji (4.0.4) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) PLATFORMS x86_64-linux DEPENDENCIES code_keeper! - csv rake (~> 13.0) rspec (~> 3.0) diff --git a/README.md b/README.md index 635ce24..769cf38 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,29 @@ # CodeKeeper -The CodeKeeper measures metrics especially about complexity and size of Ruby files, aiming to be a Ruby version of [gmetrics](https://github.com/dx42/gmetrics) +CodeKeeper emits Ruby code metric reports for periodic code quality reviews. -Mesuring metrics leads to keep codebase simple and clean, and I name the gem CodeKeeper. +It is not a RuboCop replacement. RuboCop is excellent at reporting style and metric offenses, but offense reporting is intentionally configurable: projects can disable cops, exclude files, allow specific methods, or relax thresholds. CodeKeeper is built for a different job. It keeps measuring metric values even when RuboCop offense reporting has been silenced or loosened. -Now CodeKeeper supports the cyclomatic complexity of a file, the ABC software metric of a file, and class length. The scores are output to stdout of a json or csv format. +The intended use is recurring code quality review. Run CodeKeeper, hand the structured metric report to a human or AI agent, and use the metric values as review signals. + +## Design + +CodeKeeper uses RuboCop as a Ruby analysis foundation, not as an offense policy engine. + +- Ruby parsing is based on `RuboCop::AST::ProcessedSource`. +- Standard metric values are calculated through RuboCop metric calculators or RuboCop metric cop behavior behind CodeKeeper adapters. +- RuboCop project configuration is not applied to measurement. + +RuboCop's output is threshold-gated offense reporting, not a metric inventory. Values below configured limits are not emitted as metric facts. Even aggressive RuboCop metric settings still remain policy-filtered by inline disable comments, excludes, disabled cops, allowed methods, and relaxed thresholds. + +CodeKeeper does not read `.rubocop.yml`, `rubocop:disable`, `Max`, `AllowedMethods`, `AllowedPatterns`, or `Exclude`. Those settings control RuboCop offense reporting, but CodeKeeper's purpose is to keep the underlying measurements visible. + +Each metric is measured at its natural scope: + +- `abc_metric`: method, singleton method, and `define_method` block. +- `cyclomatic_complexity`: method, singleton method, and `define_method` block. +- `class_length`: class, module, singleton class, and class-like constant assignment. + +File paths are included as location data, but files are not the primary measurement scope. If you need file-level grouping, build it from the `measurements` array or ask an AI agent to group hotspots by file, directory, domain, or owner. ## Installation @@ -22,14 +42,46 @@ Or install it yourself as: $ gem install code_keeper ## Usage -Run CodeKeeper and you get scores of metrics from stdout like +Run CodeKeeper and you get a metric report from stdout. ```rb $ bundle exec code_keeper app/models/user.rb app/models/admin.rb > metrics.json $ cat metrics.json -{"cyclomatic_complexity":{"app/models/admin.rb":9,"app/models/user.rb":23},"class_length":{"Admin":86,"User":1475},"abc_metric":{"app/models/admin.rb":76.909,"app/models/user.rb":1546.4155}} +{ + "summary": { + "metrics": { + "abc_metric": { + "count": 2, + "max": 18.2, + "top_hotspots": [ + { + "metric": "abc_metric", + "scope_type": "method", + "scope_name": "User#save!", + "path": "app/models/user.rb", + "start_line": 42, + "end_line": 80, + "value": 18.2 + } + ] + } + } + }, + "measurements": [ + { + "metric": "abc_metric", + "scope_type": "method", + "scope_name": "User#save!", + "path": "app/models/user.rb", + "start_line": 42, + "end_line": 80, + "value": 18.2 + } + ] +} ``` -If you need a csv format, change the configuration as explained later. + +The `summary` section is intended for quick review. `top_hotspots` contains up to five measurements per metric, ordered by descending value. The `measurements` section contains the metric-native values that support deeper analysis. If you need a tabular view such as CSV, derive it from the `measurements` array. ### Run CodeKeeper To measure metrics of all the ruby files recursively in the current directory, run @@ -51,11 +103,33 @@ CodeKeeper.configure do |config| config.metrics = %i(cyclomatic_complexity abc_metric class_length) # The number of threads. The default is 2. Executed sequentially if you set 1. config.number_of_threads = 4 - # The default is json - config.format = :json end ``` +## Using CodeKeeper with AI review workflows + +CodeKeeper is designed to provide structured metric data for recurring AI-assisted reviews. A typical workflow is: + +1. Run CodeKeeper on the target codebase. +2. Save the JSON metric report. +3. Ask an AI agent to analyze hotspots, group measurements by file, directory, domain, or owner, and suggest focused refactoring candidates. +4. Use the metrics as review signals, not as automatic pass/fail thresholds. + +Example prompt: + +```text +Analyze this CodeKeeper JSON metric report. + +Focus on: +- the highest ABC and cyclomatic complexity measurements +- classes or modules with unusually large length +- files or domains that contain multiple hotspots +- refactoring candidates that reduce risk without broad rewrites + +Do not treat these metrics as hard pass/fail thresholds. +Use them as signals for code quality review. +``` + ## Development After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. diff --git a/code_keeper.gemspec b/code_keeper.gemspec index 0370d5b..acf7d38 100644 --- a/code_keeper.gemspec +++ b/code_keeper.gemspec @@ -12,7 +12,7 @@ Gem::Specification.new do |spec| spec.description = "The CodeKeeper measures metrics especially about complexity and size of Ruby files, aiming to be a Ruby version of gmetrics." spec.homepage = "https://github.com/ebihara99999/code_keeper" spec.license = "MIT" - spec.required_ruby_version = Gem::Requirement.new(">= 2.7.0") + spec.required_ruby_version = Gem::Requirement.new(">= 3.3.0") spec.metadata["homepage_uri"] = spec.homepage spec.metadata["source_code_uri"] = "https://github.com/ebihara99999/code_keeper/" @@ -28,9 +28,7 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] # Uncomment to register a new dependency of your gem - spec.add_dependency "parallel", '>= 1.20.1', '< 2' - spec.add_dependency "rubocop", '>= 1.13.0' - spec.add_dependency "rubocop-ast", '>= 1.4.1' + spec.add_dependency "rubocop", '>= 1.88.0' # For more information and examples about making a new gem, checkout our # guide at: https://bundler.io/guides/creating_gem.html diff --git a/lib/code_keeper.rb b/lib/code_keeper.rb index d4590bc..72ff127 100644 --- a/lib/code_keeper.rb +++ b/lib/code_keeper.rb @@ -2,14 +2,17 @@ require_relative "code_keeper/version" require "rubocop" -require 'code_keeper/parser' +require 'code_keeper/source_file' +require 'code_keeper/measurement' +require 'code_keeper/metric_report' require 'code_keeper/finder' require 'code_keeper/cli' require 'code_keeper/formatter' require 'code_keeper/config' require 'code_keeper/scorer' -require 'code_keeper/result' require 'code_keeper/metrics' +require 'code_keeper/metrics/scope_name' +require 'code_keeper/metrics/rubocop_metric_calculator' require 'code_keeper/metrics/abc_metric' require 'code_keeper/metrics/cyclomatic_complexity' require 'code_keeper/metrics/class_length' diff --git a/lib/code_keeper/cli.rb b/lib/code_keeper/cli.rb index 01c7505..c2d7f45 100644 --- a/lib/code_keeper/cli.rb +++ b/lib/code_keeper/cli.rb @@ -14,9 +14,9 @@ def self.run(paths) return ERROR_CODE end - result = CodeKeeper::Scorer.keep(paths) + metric_report = CodeKeeper::Scorer.keep(paths) - puts ::CodeKeeper::Formatter.format(result) + puts ::CodeKeeper::Formatter.format(metric_report) SUCCESS_CODE rescue Interrupt puts 'Exiting...' diff --git a/lib/code_keeper/config.rb b/lib/code_keeper/config.rb index feff894..e1a2537 100644 --- a/lib/code_keeper/config.rb +++ b/lib/code_keeper/config.rb @@ -3,12 +3,11 @@ module CodeKeeper # Provide configuration class Config - attr_accessor :metrics, :number_of_threads, :format + attr_accessor :metrics, :number_of_threads def initialize @metrics = %i[cyclomatic_complexity class_length abc_metric] @number_of_threads = 2 - @format = :json # json and csv are supported. end end end diff --git a/lib/code_keeper/formatter.rb b/lib/code_keeper/formatter.rb index 93cd577..d871834 100644 --- a/lib/code_keeper/formatter.rb +++ b/lib/code_keeper/formatter.rb @@ -1,25 +1,13 @@ # frozen_string_literal: true -require 'csv' +require 'json' module CodeKeeper - # Format a result and make it human-readable. + # Formats a metric report for output. class Formatter class << self - def format(result) - return result.scores.to_json if CodeKeeper.config.format == :json - - # csv is supported besides json - csv_array = [] - result.scores.each_key do |metric| - result.scores[metric].each { |k, v| csv_array << [metric, k, v] } - end - - headers = %w[metric file score] - CSV.generate(headers: true) do |csv| - csv << headers - csv_array.each { |array| csv << array } - end + def format(metric_report) + metric_report.to_h.to_json end end end diff --git a/lib/code_keeper/measurement.rb b/lib/code_keeper/measurement.rb new file mode 100644 index 0000000..3bff9a5 --- /dev/null +++ b/lib/code_keeper/measurement.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module CodeKeeper + # A metric value measured at the metric's natural source scope. + class Measurement + attr_reader :metric, :scope_type, :scope_name, :path, :start_line, :end_line, :value + + def initialize(attributes) + @metric = attributes.fetch(:metric).to_sym + @scope_type = attributes.fetch(:scope_type).to_sym + @scope_name = attributes.fetch(:scope_name).to_s + @path = attributes.fetch(:path) + @start_line = attributes.fetch(:start_line) + @end_line = attributes.fetch(:end_line) + @value = attributes.fetch(:value) + end + + def to_h + { + metric:, + scope_type:, + scope_name:, + path:, + start_line:, + end_line:, + value: + } + end + end +end diff --git a/lib/code_keeper/metric_report.rb b/lib/code_keeper/metric_report.rb new file mode 100644 index 0000000..550abac --- /dev/null +++ b/lib/code_keeper/metric_report.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module CodeKeeper + # Stores metric-native measurements and derives a compact review summary. + class MetricReport + TOP_HOTSPOTS_LIMIT = 5 + + attr_reader :measurements + + def initialize(measurements = []) + @measurements = measurements + end + + def add(measurement) + measurements << measurement + end + + def to_h + { + summary: { + metrics: summary_by_metric + }, + measurements: measurements.map(&:to_h) + } + end + + private + + def summary_by_metric + measurements.group_by(&:metric).transform_values do |group| + max_value = group.map(&:value).max + + { + count: group.size, + max: max_value, + top_hotspots: top_hotspots(group) + } + end + end + + def top_hotspots(measurements) + measurements + .sort_by { |measurement| [-measurement.value, measurement.path, measurement.start_line, measurement.scope_name] } + .first(TOP_HOTSPOTS_LIMIT) + .map(&:to_h) + end + end +end diff --git a/lib/code_keeper/metrics.rb b/lib/code_keeper/metrics.rb index 85f0c72..4a9a904 100644 --- a/lib/code_keeper/metrics.rb +++ b/lib/code_keeper/metrics.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'code_keeper/metrics/scope_name' +require 'code_keeper/metrics/rubocop_metric_calculator' require 'code_keeper/metrics/abc_metric' require 'code_keeper/metrics/cyclomatic_complexity' require 'code_keeper/metrics/class_length' diff --git a/lib/code_keeper/metrics/abc_metric.rb b/lib/code_keeper/metrics/abc_metric.rb index 3aed178..d57f291 100644 --- a/lib/code_keeper/metrics/abc_metric.rb +++ b/lib/code_keeper/metrics/abc_metric.rb @@ -2,29 +2,47 @@ module CodeKeeper module Metrics - # Caluculate cyclomatic complexity + # Calculates ABC size at the method scope. class AbcMetric - include ::RuboCop::Cop::Metrics::Utils::IteratingBlock - include ::RuboCop::Cop::Metrics::Utils::RepeatedCsendDiscount + def self.measure(source_file) + new(source_file).measure + end + + def initialize(source_file) + @source_file = source_file + @path = @source_file.path + @body = @source_file.ast + end - def initialize(file_path) - ps = Parser.parse(file_path) - @path = file_path - @body = ps.ast - @assignments = 0 - @branches = 0 - @conditionals = 0 + def measure + return [] unless @body + + method_nodes.map do |node| + Measurement.new( + metric: :abc_metric, + scope_type: :method, + scope_name: ScopeName.method_name(node), + path: @path, + start_line: node.first_line, + end_line: node.last_line, + value: calculate(node.body) + ) + end end - def score - caluculator = ::RuboCop::Cop::Metrics::Utils::AbcSizeCalculator.new(@body) - caluculator.calculate - @assignments = caluculator.instance_variable_get('@assignment') - @conditionals = caluculator.instance_variable_get('@condition') - @branches = caluculator.instance_variable_get('@branch') + private + + # Kept identical to CyclomaticComplexity#method_nodes on purpose. + # Extract a shared method-scope enumerator when a third method-scope + # metric is added. + def method_nodes + @body.each_node(:def, :defs, :block, :numblock, :itblock).select do |node| + node.def_type? || node.defs_type? || ScopeName.define_method?(node) + end + end - value = Math.sqrt(@assignments**2 + @branches**2 + @conditionals**2).round(4) - { "#{@path}": value } + def calculate(node) + RuboCopMetricCalculator.abc_size(node) end end end diff --git a/lib/code_keeper/metrics/class_length.rb b/lib/code_keeper/metrics/class_length.rb index a8e41ab..e26966c 100644 --- a/lib/code_keeper/metrics/class_length.rb +++ b/lib/code_keeper/metrics/class_length.rb @@ -2,121 +2,82 @@ module CodeKeeper module Metrics - # Caluculate Class Length. + # Calculates class-like code length at the class/module scope. class ClassLength - def initialize(file_path) - @ps = Parser.parse(file_path) - @body = @ps.ast - @score_hash = {} + def self.measure(source_file) + new(source_file).measure end - # NOTE: This doesn't exclude foldale sources like Array, Hash and Heredoc. - def score - @body.each_node(:class, :casgn, :module) do |node| - if node.class_type? || node.module_type? - @score_hash.store(build_namespace(node), calculate(node)) - elsif node.casgn_type? - parent = node.parent - - if parent&.assignment? - block_node = node.children[2] - klass = node.loc.name.source - elsif parent&.parent&.masgn_type? - # In the case where `A, B = Struct.new(:a, :b)`, - # B is always nil. - assigned = parent.loc.expression.source.split(',').first - next unless node.loc.name.source == assigned - - block_node = parent.parent.children[1] - klass = node.loc.name.source - else - _scope, klass, block_node = *node - klass = klass.to_s - end - - # This is not to raise error on dynamic assignments like `X = Y = Z = Class.new`. - # the block node is as follows if node is X: - # `s(:casgn, nil, :Y, s(:casgn, nil, :Z, s(:block, ...` - # Similarly the block node is `:X` as follows if node is Y. - next unless block_node.respond_to?(:class_definition?) && block_node.class_definition? + def initialize(source_file) + @source_file = source_file + @path = @source_file.path + @ps = @source_file.processed_source + @body = @source_file.ast + end - # NOTE: klass doesn't have a namespace. - # Only supports namepaces in `class A; end` case. - if klass - @score_hash.store(klass, calculate(block_node)) if klass - else - @score_hash.store(build_namespace(block_node), calculate(block_node)) - end - end + def measure + return [] unless @body + + classlike_nodes.map do |node, scope_type, scope_name| + Measurement.new( + metric: :class_length, + scope_type: scope_type, + scope_name: scope_name, + path: @path, + start_line: node.first_line, + end_line: node.last_line, + value: calculate(node) + ) end - @score_hash end private - def calculate(node) - # node.body.line_count doesn't include comments after definition of a class. - # Don't use nonempty_lines. Empty lines are considered on only the node. - count = node.line_count - 2 + def classlike_nodes + nodes = [] - count - line_count_of_inner_nodes(node) - comment_line_count(node) - empty_line_count(node) - end + @body.each_node(:class, :module, :sclass, :casgn) do |node| + if node.class_type? + nodes << [node, :class, ScopeName.class_name(node)] + elsif node.module_type? + nodes << [node, :module, ScopeName.class_name(node)] + elsif node.sclass_type? + next if node.each_ancestor(:class).any? - def body_lines(node) - (node.first_line..node.last_line).to_a - descendant_class_lines(node) - end + nodes << [node, :singleton_class, ScopeName.class_name(node)] + elsif node.casgn_type? + expression = class_definition_expression(node) + next unless class_definition?(expression) - def descendant_class_lines(node) - # A class may have multiple inner classes seperately. - # So it needs to store all descendant classes line ranges. - node.each_descendant(:class, :module).map do |desendant| - # To make easier to compare and consider inner nodes, change array of line range into an array of line numbers. - (desendant.first_line..desendant.last_line).to_a - end.flatten.uniq - end + nodes << [expression, :class, ScopeName.const_assignment_name(node)] + end + end - def empty_line_count(node) - empty_lines = @ps.lines.filter_map.with_index { |line, i| i + 1 if line.empty? } - (empty_lines & body_lines(node)).size + nodes end - def line_count_of_inner_nodes(node) - line_numbers = node.each_descendant(:class, :module).map do |descendant| - (descendant.first_line..descendant.last_line).to_a - end.flatten.uniq - - line_numbers.size + def calculate(node) + RuboCopMetricCalculator.class_length(node, @ps) end - # Only counts the comment of the class or module of a node. - # Because `#line_count_of_inner_nodes` only considers the first inner node, - # the second or later inner nodes' commments are not necesarry to be counted. - def comment_line_count(node) - node_range = node.first_line...node.last_line - comment_lines = @ps.comments.map { |comment| comment.loc.line } - # The latter condition considers a class ouside or above the node. - comment_lines.select { |cl| !descendant_class_lines(node).include?(cl) && node_range.include?(cl) }.count + def class_definition_expression(node) + if node.respond_to?(:expression) && node.expression + node.expression + else + find_expression_within_parent(node.parent) + end end - def build_namespace(node) - self_name = name_with_ns(node) - - return self_name if node.each_ancestor(:class, :module).to_a.empty? - - full_name = self_name.dup - node.each_ancestor(:class, :module) do |ancestor| - full_name = "#{name_with_ns(ancestor)}::#{full_name}" + def find_expression_within_parent(parent) + if parent&.assignment? + parent.expression + elsif parent&.parent&.masgn_type? + parent.parent.expression end - full_name end - def name_with_ns(node) - ns = node.children.first&.namespace&.source - if ns.nil? - node.children.first&.short_name.to_s - else - node.children.first.namespace.source + "::#{node.children.first.short_name}" - end + def class_definition?(node) + node.respond_to?(:class_definition?) && node.class_definition? end end end diff --git a/lib/code_keeper/metrics/cyclomatic_complexity.rb b/lib/code_keeper/metrics/cyclomatic_complexity.rb index bc7fad1..349cf5b 100644 --- a/lib/code_keeper/metrics/cyclomatic_complexity.rb +++ b/lib/code_keeper/metrics/cyclomatic_complexity.rb @@ -2,28 +2,47 @@ module CodeKeeper module Metrics - # Caluculate cyclomatic complexity + # Calculates cyclomatic complexity at the method scope. class CyclomaticComplexity - include ::RuboCop::Cop::Metrics::Utils::IteratingBlock - include ::RuboCop::Cop::Metrics::Utils::RepeatedCsendDiscount + def self.measure(source_file) + new(source_file).measure + end + + def initialize(source_file) + @source_file = source_file + @path = @source_file.path + @body = @source_file.ast + end - CONSIDERED_NODES = %i[if while until for csend block block_pass rescue when and or or_asgnand_asgn].freeze + def measure + return [] unless @body - def initialize(file_path) - @path = file_path - ps = Parser.parse(@path) - @body = ps.ast + method_nodes.map do |node| + Measurement.new( + metric: :cyclomatic_complexity, + scope_type: :method, + scope_name: ScopeName.method_name(node), + path: @path, + start_line: node.first_line, + end_line: node.last_line, + value: calculate(node.body) + ) + end end - # returns score of cyclomatic complexity - def score - final_score = @body.each_node(:lvasgn, *CONSIDERED_NODES).reduce(1) do |score, node| - next score if !iterating_block?(node) || node.lvasgn_type? - next score if node.csend_type? && discount_for_repeated_csend?(node) + private - next 1 + score + # Kept identical to AbcMetric#method_nodes on purpose. + # Extract a shared method-scope enumerator when a third method-scope + # metric is added. + def method_nodes + @body.each_node(:def, :defs, :block, :numblock, :itblock).select do |node| + node.def_type? || node.defs_type? || ScopeName.define_method?(node) end - { "#{@path}": final_score } + end + + def calculate(body) + RuboCopMetricCalculator.cyclomatic_complexity(body) end end end diff --git a/lib/code_keeper/metrics/rubocop_metric_calculator.rb b/lib/code_keeper/metrics/rubocop_metric_calculator.rb new file mode 100644 index 0000000..a662a7c --- /dev/null +++ b/lib/code_keeper/metrics/rubocop_metric_calculator.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module CodeKeeper + module Metrics + # Centralizes RuboCop metric calculation details. + module RuboCopMetricCalculator + module_function + + def abc_size(node) + return 0 unless node + + value, = ::RuboCop::Cop::Metrics::Utils::AbcSizeCalculator.calculate( + node, + discount_repeated_attributes: false + ) + value + end + + def cyclomatic_complexity(node) + return 1 unless node + + cop = ::RuboCop::Cop::Metrics::CyclomaticComplexity.new + cop.send(:reset_repeated_csend) + cop.send(:complexity, node) + end + + def class_length(node, processed_source) + ::RuboCop::Cop::Metrics::Utils::CodeLengthCalculator.new( + node, + processed_source, + count_comments: false, + foldable_types: [] + ).calculate + end + end + end +end diff --git a/lib/code_keeper/metrics/scope_name.rb b/lib/code_keeper/metrics/scope_name.rb new file mode 100644 index 0000000..7ed6572 --- /dev/null +++ b/lib/code_keeper/metrics/scope_name.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +module CodeKeeper + module Metrics + # Builds human-readable source scope names from RuboCop AST nodes. + module ScopeName + module_function + + def method_name(node) + case node.type + when :def + sclass = enclosing_sclass(node) + return join_singleton_method(sclass_receiver_name(sclass), node.method_name) if sclass + + join_instance_method(owner_name(node), node.method_name) + when :defs + join_singleton_method(singleton_receiver_name(node), node.method_name) + else + define_method_name(node) + end + end + + def class_name(node) + case node.type + when :sclass + "class << #{sclass_receiver_name(node)}" + else + join_const_name(owner_name(node), node.children.first&.source) + end + end + + def const_assignment_name(node) + scope, name = *node + const_name = join_const_name(scope&.source, name) + return const_name unless scope.nil? + + join_const_name(owner_name(node), const_name) + end + + def define_method?(node) + return false unless %i[block numblock itblock].include?(node.type) + + send_node = node.children.first + return false unless send_node&.send_type? + return false unless send_node.method_name == :define_method + return false unless send_node.receiver.nil? + + literal_name?(send_node.arguments.first) + end + + def define_method_name(node) + send_node = node.children.first + name = literal_value(send_node.arguments.first) + + sclass = enclosing_sclass(node) + return join_singleton_method(sclass_receiver_name(sclass), name) if sclass + + join_instance_method(owner_name(node), name) + end + + # A def or define_method belongs to the singleton class only when it sits + # directly in the sclass body; a def/defs/block in between changes self. + def enclosing_sclass(node) + scope = node.each_ancestor(:def, :defs, :block, :numblock, :itblock, :sclass, :class, :module).first + scope if scope&.sclass_type? + end + + def sclass_receiver_name(node) + receiver = node.children.first + return receiver.source unless receiver.self_type? + return 'self' unless static_self_scope?(node) + + owner = owner_name(node) + owner.empty? ? 'self' : owner + end + + # In method bodies and blocks, self is the runtime receiver, not the + # lexically enclosing constant, so it must not be resolved statically. + def static_self_scope?(node) + scope = node.each_ancestor(:def, :defs, :block, :numblock, :itblock, :sclass, :class, :module).first + scope.nil? || scope.class_type? || scope.module_type? + end + + def owner_name(node) + node.each_ancestor(:class, :module).to_a.reverse.filter_map do |ancestor| + ancestor.children.first&.source + end.join('::') + end + + def join_instance_method(owner, name) + return name.to_s if owner.nil? || owner.empty? + + "#{owner}##{name}" + end + + def join_singleton_method(receiver, name) + return name.to_s if receiver.nil? || receiver.empty? + + "#{receiver}.#{name}" + end + + def join_const_name(namespace, name) + return name.to_s if namespace.nil? || namespace.empty? + return namespace.to_s if name.nil? || name.to_s.empty? + + "#{namespace}::#{name}" + end + + def singleton_receiver_name(node) + receiver = node.children.first + return owner_name(node) if receiver&.self_type? && !owner_name(node).empty? + + receiver&.source + end + + def literal_name?(node) + node&.sym_type? || node&.str_type? + end + + def literal_value(node) + if node.respond_to?(:value) + node.value + else + node.children.first + end + end + end + end +end diff --git a/lib/code_keeper/parser.rb b/lib/code_keeper/parser.rb deleted file mode 100644 index 797048b..0000000 --- a/lib/code_keeper/parser.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module CodeKeeper - # Search and parse ruby file - class Parser - attr_reader :processed_source - - def initialize(file_path) - source = File.read(File.expand_path(file_path)) - @processed_source = ::RuboCop::AST::ProcessedSource.new(source, RUBY_VERSION.to_f) - rescue Errno::ENOENT - raise TargetFileNotFoundError, "The target file does not exist. Check the file path: #{file_path}." - end - - class << self - def parse(file_path) - parser = new(file_path) - parser.processed_source - end - end - end -end diff --git a/lib/code_keeper/result.rb b/lib/code_keeper/result.rb deleted file mode 100644 index 436d31b..0000000 --- a/lib/code_keeper/result.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -module CodeKeeper - # Store results of each score. - class Result - attr_reader :scores - - def initialize - @scores = CodeKeeper.config.metrics.map { |key| [key, {}] }.to_h - end - - def add(metric, klass_or_path, score) - scores[:"#{metric}"].store(klass_or_path, score) - end - end -end diff --git a/lib/code_keeper/scorer.rb b/lib/code_keeper/scorer.rb index 4e74148..a1d1a3b 100644 --- a/lib/code_keeper/scorer.rb +++ b/lib/code_keeper/scorer.rb @@ -1,38 +1,59 @@ # frozen_string_literal: true module CodeKeeper - # Run and store score of metrics. + # Runs metrics and builds the metric report. class Scorer class << self def keep(paths) - result = CodeKeeper::Result.new - metrics = result.scores.keys + metrics = CodeKeeper.config.metrics.uniq ruby_file_paths = Finder.new(paths).file_paths num_threads = CodeKeeper.config.number_of_threads - # NOTE: If the configuration says no concurrent execution, the parallel gem is not used. - # `in_threads: 1` makes 2 threads, a sleep_forever thread and the main thread. - if num_threads == 1 - ruby_file_paths.each do |path| - metrics.each { |metric| calculate_score(metric, path, result) } - end + MetricReport.new(measure_files(ruby_file_paths, metrics, num_threads)) + end + + private + + def measure_files(ruby_file_paths, metrics, num_threads) + if num_threads == 1 || ruby_file_paths.one? + ruby_file_paths.flat_map { |path| measure_file(path, metrics) } else - Parallel.map(ruby_file_paths, in_threads: num_threads) do |path| - metrics.each { |metric| calculate_score(metric, path, result) } + parallel_map(ruby_file_paths, num_threads) do |path| + measure_file(path, metrics) + end.flatten + end + end + + def parallel_map(items, num_threads) + worker_count = [[num_threads.to_i, 1].max, items.size].min + jobs = Queue.new + results = Array.new(items.size) + + items.each_with_index { |item, index| jobs << [index, item] } + + threads = worker_count.times.map do + Thread.new do + loop do + begin + index, item = jobs.pop(true) + rescue ThreadError + break + end + + results[index] = yield item + end end end - result + threads.each(&:value) + results end - private - - def calculate_score(metric, path, result) - score = ::CodeKeeper::Metrics::MAPPINGS[metric].new(path).score + def measure_file(path, metrics) + source_file = SourceFile.new(path) - # The class length metric's score has multiple classes. - score.each do |k, v| - result.add(metric, k.to_s, v) + metrics.flat_map do |metric| + ::CodeKeeper::Metrics::MAPPINGS[metric].measure(source_file) end end end diff --git a/lib/code_keeper/source_file.rb b/lib/code_keeper/source_file.rb new file mode 100644 index 0000000..e87b628 --- /dev/null +++ b/lib/code_keeper/source_file.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module CodeKeeper + # Holds a parsed Ruby source file and the information needed by metrics. + class SourceFile + attr_reader :path, :source, :processed_source + + def initialize(path) + @path = path + @source = File.read(File.expand_path(path)) + @processed_source = build_processed_source + rescue Errno::ENOENT + raise TargetFileNotFoundError, "The target file does not exist. Check the file path: #{path}." + end + + def ast + processed_source.ast + end + + def comments + processed_source.comments + end + + def lines + processed_source.lines + end + + private + + def build_processed_source + ::RuboCop::AST::ProcessedSource.new(source, ruby_version, path) + end + + def ruby_version + RUBY_VERSION.split('.').first(2).join('.').to_f + end + end +end diff --git a/lib/code_keeper/version.rb b/lib/code_keeper/version.rb index 4afcfac..26ab113 100644 --- a/lib/code_keeper/version.rb +++ b/lib/code_keeper/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module CodeKeeper - VERSION = "0.6.2" + VERSION = "1.0.0" end diff --git a/spec/code_keeper/cli_spec.rb b/spec/code_keeper/cli_spec.rb index 4b760bb..9055621 100644 --- a/spec/code_keeper/cli_spec.rb +++ b/spec/code_keeper/cli_spec.rb @@ -1,32 +1,38 @@ # frozen_string_literal: true +require 'stringio' + RSpec.describe CodeKeeper::Cli do + def run_silently(paths) + original_stdout = $stdout + $stdout = StringIO.new + CodeKeeper::Cli.run(paths) + ensure + $stdout = original_stdout + end + describe '.run' do before do CodeKeeper.configure do |config| config.metrics = [:cyclomatic_complexity] - config.format = :json end end context 'normal cases' do - it 'outputs scores to stdout' do - expected_output = %({"cyclomatic_complexity":{"./spec/fixtures/branch_in_loop.rb":2}}\n) - + it 'outputs metric report to stdout' do expect do CodeKeeper::Cli.run(['./spec/fixtures/branch_in_loop.rb']) - end.to output(expected_output).to_stdout + end.to output(/"summary"/).to_stdout end it 'returns 0' do - ret = CodeKeeper::Cli.run(['./spec/fixtures/branch_in_loop.rb']) - expect(ret).to eq 0 + expect(run_silently(['./spec/fixtures/branch_in_loop.rb'])).to eq 0 end end context 'No argument is specified' do it 'returns 2' do - expect(CodeKeeper::Cli.run([])).to eq 2 + expect(run_silently([])).to eq 2 end it 'outputs an error message' do @@ -53,8 +59,7 @@ end it 'returns 2' do - ret = CodeKeeper::Cli.run(['./spec/fixtures/branch_in_loop.rb']) - expect(ret).to eq 2 + expect(run_silently(['./spec/fixtures/branch_in_loop.rb'])).to eq 2 end end @@ -71,8 +76,7 @@ end it 'returns 1' do - ret = CodeKeeper::Cli.run(['./spec/fixtures/branch_in_loop.rb']) - expect(ret).to eq 1 + expect(run_silently(['./spec/fixtures/branch_in_loop.rb'])).to eq 1 end end end diff --git a/spec/code_keeper/formatter_spec.rb b/spec/code_keeper/formatter_spec.rb index 55c3f6e..a985efd 100644 --- a/spec/code_keeper/formatter_spec.rb +++ b/spec/code_keeper/formatter_spec.rb @@ -2,50 +2,23 @@ RSpec.describe CodeKeeper::Formatter do describe '.format' do - before do - CodeKeeper.configure do |config| - config.metrics = [:cyclomatic_complexity] - end - - @result = CodeKeeper::Result.new - @result.add(:cyclomatic_complexity, '/foo/bar/code_keeper/spec/fixtures/branch_in_loop.rb', 2) - @result.add(:cyclomatic_complexity, '/foo/bar/code_keeper/spec/fixtures/target_sample.rb', 1) - end - - context 'csv format' do - before do - CodeKeeper.configure do |config| - config.format = :csv - end - end - - let(:expected_string) do - <<~EOS - metric,file,score - cyclomatic_complexity,/foo/bar/code_keeper/spec/fixtures/branch_in_loop.rb,2 - cyclomatic_complexity,/foo/bar/code_keeper/spec/fixtures/target_sample.rb,1 - EOS - end - - it 'returns an csv string' do - expect(CodeKeeper::Formatter.format(@result)).to eq expected_string - end - end - - context 'json format' do - before do - CodeKeeper.configure do |config| - config.format = :json - end - end - - let(:expected_string) do - %({\"cyclomatic_complexity\":{\"/foo/bar/code_keeper/spec/fixtures/branch_in_loop.rb\":2,\"/foo/bar/code_keeper/spec/fixtures/target_sample.rb\":1}}) - end - - it 'returns an json string' do - expect(CodeKeeper::Formatter.format(@result)).to eq expected_string - end + it 'returns metric report json' do + metric_report = CodeKeeper::MetricReport.new( + [ + CodeKeeper::Measurement.new( + metric: :cyclomatic_complexity, + scope_type: :method, + scope_name: 'Sample#hello', + path: './spec/fixtures/target_sample.rb', + start_line: 2, + end_line: 4, + value: 1 + ) + ] + ) + json = JSON.parse(CodeKeeper::Formatter.format(metric_report)) + + expect(json.dig('summary', 'metrics', 'cyclomatic_complexity', 'max')).to eq 1 end end end diff --git a/spec/code_keeper/metric_report_spec.rb b/spec/code_keeper/metric_report_spec.rb new file mode 100644 index 0000000..9852a13 --- /dev/null +++ b/spec/code_keeper/metric_report_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +RSpec.describe CodeKeeper::MetricReport do + describe '#to_h' do + it 'summarizes measurements by metric' do + metric_report = CodeKeeper::MetricReport.new + metric_report.add( + CodeKeeper::Measurement.new( + metric: :abc_metric, + scope_type: :method, + scope_name: 'A#b', + path: 'a.rb', + start_line: 1, + end_line: 3, + value: 2.0 + ) + ) + + expect(metric_report.to_h[:summary][:metrics][:abc_metric][:max]).to eq 2.0 + end + + it 'returns the top five hotspots by descending value' do + metric_report = CodeKeeper::MetricReport.new( + [1, 6, 3, 5, 2, 4].map.with_index do |value, index| + measurement(value: value, path: "#{index}.rb", start_line: index + 1) + end + ) + + expect(metric_report.to_h[:summary][:metrics][:abc_metric][:top_hotspots].map { |hotspot| hotspot[:value] }).to eq [6, 5, 4, 3, 2] + end + + it 'orders hotspot ties by path, start line, and scope name' do + metric_report = CodeKeeper::MetricReport.new( + [ + measurement(path: 'b.rb', start_line: 1, scope_name: 'B#b'), + measurement(path: 'a.rb', start_line: 2, scope_name: 'A#b'), + measurement(path: 'a.rb', start_line: 1, scope_name: 'A#b'), + measurement(path: 'a.rb', start_line: 1, scope_name: 'A#a') + ] + ) + + expect(metric_report.to_h[:summary][:metrics][:abc_metric][:top_hotspots].map do |hotspot| + [hotspot[:path], hotspot[:start_line], hotspot[:scope_name]] + end).to eq( + [ + ['a.rb', 1, 'A#a'], + ['a.rb', 1, 'A#b'], + ['a.rb', 2, 'A#b'], + ['b.rb', 1, 'B#b'] + ] + ) + end + end + + def measurement(value: 2.0, path: 'a.rb', start_line: 1, scope_name: 'A#b') + CodeKeeper::Measurement.new( + metric: :abc_metric, + scope_type: :method, + scope_name: scope_name, + path: path, + start_line: start_line, + end_line: start_line + 2, + value: value + ) + end +end diff --git a/spec/code_keeper/metrics/abc_metric_spec.rb b/spec/code_keeper/metrics/abc_metric_spec.rb index 2875308..261f882 100644 --- a/spec/code_keeper/metrics/abc_metric_spec.rb +++ b/spec/code_keeper/metrics/abc_metric_spec.rb @@ -1,11 +1,30 @@ # frozen_string_literal: true RSpec.describe CodeKeeper::Metrics::AbcMetric do - describe "#score" do - it "returns a hash of a filename and a score, 3.7417, which is the four decimal point" do - expected_hash = { 'spec/fixtures/branch_in_loop.rb': 3.7417 } - abc_metric = CodeKeeper::Metrics::AbcMetric.new('spec/fixtures/branch_in_loop.rb') - expect(abc_metric.score).to eq expected_hash + describe '.measure' do + it 'returns measurements by method scope' do + source_file = CodeKeeper::SourceFile.new('spec/fixtures/branch_in_loop.rb') + measurement = CodeKeeper::Metrics::AbcMetric.measure(source_file).first + + expect(measurement.scope_name).to eq 'two_hundred' + end + + it 'matches RuboCop ABC calculation' do + source_file = CodeKeeper::SourceFile.new('spec/fixtures/branch_in_loop.rb') + method_node = source_file.ast.each_node(:def).first + rubocop_value, = RuboCop::Cop::Metrics::Utils::AbcSizeCalculator.calculate( + method_node.body, + discount_repeated_attributes: false + ) + measurement = CodeKeeper::Metrics::AbcMetric.measure(source_file).first + + expect(measurement.value).to eq rubocop_value + end + + it 'does not suppress measurements with RuboCop comments or config' do + source_file = CodeKeeper::SourceFile.new('spec/fixtures/rubocop_config/sample.rb') + + expect(CodeKeeper::Metrics::AbcMetric.measure(source_file).map(&:scope_name)).to eq ['ConfigIgnoredSample#complex_method'] end end end diff --git a/spec/code_keeper/metrics/class_length_spec.rb b/spec/code_keeper/metrics/class_length_spec.rb index a0595c6..6918117 100644 --- a/spec/code_keeper/metrics/class_length_spec.rb +++ b/spec/code_keeper/metrics/class_length_spec.rb @@ -1,60 +1,56 @@ # frozen_string_literal: true RSpec.describe CodeKeeper::Metrics::ClassLength do - describe "#score" do - # This context also has a view of testing counting a comment just after class definition precisely. - context 'A file has one class, in which there is a comment, an empty line' do - it 'returns a hash with the value 1' do - expected = {} - expected.store('SimpleClass', 2) - cl = CodeKeeper::Metrics::ClassLength.new('spec/fixtures/class_samples/simple_class.rb') - expect(cl.score).to eq(expected) - end - end + describe '.measure' do + it 'returns measurements by class scope' do + source_file = CodeKeeper::SourceFile.new('spec/fixtures/class_samples/simple_class.rb') + measurement = CodeKeeper::Metrics::ClassLength.measure(source_file).first - context 'A file has 3 classes, which are a namespace class and a inner class, and a class in a namespace module' do - it 'returns a hash with scores of 3 classes' do - expected = {} - expected.store('RootClass', 4) - expected.store('RootClass::NameSpaceClass', 3) - expected.store('RootClass::NameSpaceClass::A', 2) - expected.store('RootClass::SeperateClass', 1) - expected.store('RootModule', 3) - expected.store('RootModule::NameSpaceModule', 2) - expected.store('RootModule::NameSpaceModule::B', 4) - expected.store('C::D::E', 2) - cl = CodeKeeper::Metrics::ClassLength.new('spec/fixtures/class_samples/namespace.rb') - expect(cl.score).to eq(expected) - end + expect(measurement.scope_name).to eq 'SimpleClass' end - context 'A file has a class defined by overlapping constant assignments' do - it 'returns a hash with a score of the class C' do - expected = {} - expected.store('C', 2) - cl = CodeKeeper::Metrics::ClassLength.new('spec/fixtures/class_samples/overlapping_const_assignments.rb') - expect(cl.score).to eq(expected) - end + it 'matches RuboCop code length calculation' do + source_file = CodeKeeper::SourceFile.new('spec/fixtures/class_samples/simple_class.rb') + class_node = source_file.ast.each_node(:class).first + rubocop_value = RuboCop::Cop::Metrics::Utils::CodeLengthCalculator.new( + class_node, + source_file.processed_source, + count_comments: false, + foldable_types: [] + ).calculate + measurement = CodeKeeper::Metrics::ClassLength.measure(source_file).first + + expect(measurement.value).to eq rubocop_value end - context 'A file has 2 classes defined by a singleton class' do - it 'returns a hash with scores of 2 classes' do - expected = {} - expected.store('A', 3) - expected.store('B', 2) - cl = CodeKeeper::Metrics::ClassLength.new('spec/fixtures/class_samples/singleton.rb') - expect(cl.score).to eq(expected) - end + it 'names nested scopes with their full namespace' do + source_file = CodeKeeper::SourceFile.new('spec/fixtures/class_samples/namespace.rb') + + expect(CodeKeeper::Metrics::ClassLength.measure(source_file).map(&:scope_name)).to eq( + [ + 'RootClass', + 'RootClass::NameSpaceClass', + 'RootClass::NameSpaceClass::A', + 'RootClass::SeperateClass', + 'RootModule', + 'RootModule::NameSpaceModule', + 'RootModule::NameSpaceModule::B', + 'C::D::E' + ] + ) end - context 'A file has 2 classes defined by a Struct object, one of which is a multiple assignment' do - it 'returns a hash with scores of 2 classes' do - expected = {} - expected.store('A', 2) - expected.store('B', 3) - cl = CodeKeeper::Metrics::ClassLength.new('spec/fixtures/class_samples/struct.rb') - expect(cl.score).to eq(expected) - end + it 'names singleton class scopes with the resolved owner' do + source_file = CodeKeeper::SourceFile.new('spec/fixtures/class_samples/singleton_scope.rb') + + expect(CodeKeeper::Metrics::ClassLength.measure(source_file).map { |measurement| [measurement.scope_type, measurement.scope_name] }).to eq( + [ + [:module, 'SingletonScopeSample'], + [:singleton_class, 'class << SingletonScopeSample'], + [:class, 'SingletonScopeOwner'], + [:class, 'SingletonScopeRuntime'] + ] + ) end end end diff --git a/spec/code_keeper/metrics/cyclomatic_complexity_spec.rb b/spec/code_keeper/metrics/cyclomatic_complexity_spec.rb index 40b9227..77c7ff2 100644 --- a/spec/code_keeper/metrics/cyclomatic_complexity_spec.rb +++ b/spec/code_keeper/metrics/cyclomatic_complexity_spec.rb @@ -1,11 +1,37 @@ # frozen_string_literal: true RSpec.describe CodeKeeper::Metrics::CyclomaticComplexity do - describe "#score" do - it 'returns a hash with score of a file' do - expected_hash = { 'spec/fixtures/branch_in_loop.rb': 2 } - complexity = CodeKeeper::Metrics::CyclomaticComplexity.new('spec/fixtures/branch_in_loop.rb') - expect(complexity.score).to eq expected_hash + describe '.measure' do + it 'matches RuboCop cyclomatic complexity calculation' do + source_file = CodeKeeper::SourceFile.new('spec/fixtures/branch_in_loop.rb') + method_node = source_file.ast.each_node(:def).first + rubocop_cop = RuboCop::Cop::Metrics::CyclomaticComplexity.new + rubocop_cop.send(:reset_repeated_csend) + measurement = CodeKeeper::Metrics::CyclomaticComplexity.measure(source_file).first + + expect(measurement.value).to eq rubocop_cop.send(:complexity, method_node.body) + end + + it 'does not suppress measurements with RuboCop comments or config' do + source_file = CodeKeeper::SourceFile.new('spec/fixtures/rubocop_config/sample.rb') + + expect(CodeKeeper::Metrics::CyclomaticComplexity.measure(source_file).map(&:scope_name)).to eq ['ConfigIgnoredSample#complex_method'] + end + + it 'names methods in singleton class scopes as singleton methods' do + source_file = CodeKeeper::SourceFile.new('spec/fixtures/class_samples/singleton_scope.rb') + + expect(CodeKeeper::Metrics::CyclomaticComplexity.measure(source_file).map(&:scope_name)).to eq( + [ + 'SingletonScopeSample.module_singleton_method', + 'SingletonScopeOwner.class_singleton_method', + 'SingletonScopeOwner.defined_singleton_method', + 'SingletonScopeOwner.build_helper', + 'SingletonScopeOwner#built_instance_method', + 'SingletonScopeRuntime#attach', + 'self.tag' + ] + ) end end end diff --git a/spec/code_keeper/parser_spec.rb b/spec/code_keeper/parser_spec.rb deleted file mode 100644 index 363ba76..0000000 --- a/spec/code_keeper/parser_spec.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe CodeKeeper::Parser do - describe '#initialize' do - context 'file path is absolute' do - it 'stores processed_source' do - parser = CodeKeeper::Parser.new('./spec/fixtures/target_sample.rb') - expect(parser.processed_source).to be_a(::RuboCop::AST::ProcessedSource) - end - end - - context 'file path is relative' do - it 'stores processed_source' do - absolute_spec_dir = File.join(Dir.pwd, 'spec') - parser = CodeKeeper::Parser.new("#{absolute_spec_dir}/fixtures/target_sample.rb") - expect(parser.processed_source).to be_a(::RuboCop::AST::ProcessedSource) - end - end - - context 'File does not exsit' do - it 'raises TargetFileNotFoundError' do - expect do - CodeKeeper::Parser.new('./spec/fixtures/no_file.rb') - end.to raise_error CodeKeeper::TargetFileNotFoundError - end - end - end - - describe '.parse' do - it 'returns RuboCop::AST::ProcessedSource instance' do - expect(CodeKeeper::Parser.parse('./spec/fixtures/target_sample.rb')).to be_a(::RuboCop::AST::ProcessedSource) - end - end -end diff --git a/spec/code_keeper/result_spec.rb b/spec/code_keeper/result_spec.rb deleted file mode 100644 index fe0115c..0000000 --- a/spec/code_keeper/result_spec.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe CodeKeeper::Result do - describe '#add' do - before do - CodeKeeper.configure do |config| - config.metrics = [:cyclomatic_complexity] - end - - @result = CodeKeeper::Result.new - @result.add(:cyclomatic_complexity, './spec/fixtures/branch_in_loop.rb', 2) - end - - it 'stores path and score' do - # HACK: if you dont use store, a colon is stick to the key if it starts with a dot. - # > hoge = { hoge: { 'fuga': 2 } } - # => {:hoge=>{:fuga=>2}} - # > hoge = { hoge: { './fuga': 2 } } - # => {:hoge=>{:"./fuga"=>2}} - expected_hash = { cyclomatic_complexity: {} } - expected_hash[:cyclomatic_complexity].store('./spec/fixtures/branch_in_loop.rb', 2) - - expect(@result.scores).to eq expected_hash - end - end -end diff --git a/spec/code_keeper/scorer_spec.rb b/spec/code_keeper/scorer_spec.rb index 604fd7e..6624f89 100644 --- a/spec/code_keeper/scorer_spec.rb +++ b/spec/code_keeper/scorer_spec.rb @@ -2,8 +2,40 @@ RSpec.describe CodeKeeper::Scorer do describe '.keep' do - it 'returns an instance of CodeKeeper::Result' do - expect(CodeKeeper::Scorer.keep(['./spec/fixtures/branch_in_loop.rb'])).to be_a CodeKeeper::Result + it 'returns an instance of CodeKeeper::MetricReport' do + expect(CodeKeeper::Scorer.keep(['./spec/fixtures/branch_in_loop.rb'])).to be_a CodeKeeper::MetricReport + end + + it 'stores metric measurements' do + CodeKeeper.configure do |config| + config.metrics = [:cyclomatic_complexity] + end + + expect(CodeKeeper::Scorer.keep(['./spec/fixtures/branch_in_loop.rb']).measurements.size).to eq 1 + end + + it 'measures duplicated configured metrics once' do + CodeKeeper.configure do |config| + config.metrics = %i[cyclomatic_complexity cyclomatic_complexity] + end + + expect(CodeKeeper::Scorer.keep(['./spec/fixtures/branch_in_loop.rb']).measurements.size).to eq 1 + end + + it 'stores parallel measurements in input order' do + CodeKeeper.configure do |config| + config.metrics = [:cyclomatic_complexity] + config.number_of_threads = 2 + end + + metric_report = CodeKeeper::Scorer.keep(['./spec/fixtures/branch_in_loop.rb', './spec/fixtures/target_sample.rb']) + + expect(metric_report.measurements.map { |measurement| [measurement.path, measurement.scope_name] }).to eq( + [ + ['./spec/fixtures/branch_in_loop.rb', 'two_hundred'], + ['./spec/fixtures/target_sample.rb', 'TargetSample#hello'] + ] + ) end end end diff --git a/spec/code_keeper/source_file_spec.rb b/spec/code_keeper/source_file_spec.rb new file mode 100644 index 0000000..308f23b --- /dev/null +++ b/spec/code_keeper/source_file_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +RSpec.describe CodeKeeper::SourceFile do + describe '#initialize' do + it 'stores path' do + expect(CodeKeeper::SourceFile.new('./spec/fixtures/target_sample.rb').path).to eq './spec/fixtures/target_sample.rb' + end + + it 'stores ast' do + expect(CodeKeeper::SourceFile.new('./spec/fixtures/target_sample.rb').ast).not_to be_nil + end + + it 'stores comments' do + expect(CodeKeeper::SourceFile.new('./spec/fixtures/target_sample.rb').comments).to eq [] + end + + it 'stores lines' do + expect(CodeKeeper::SourceFile.new('./spec/fixtures/target_sample.rb').lines).to include('class TargetSample') + end + + it 'stores processed_source' do + expect(CodeKeeper::SourceFile.new('./spec/fixtures/target_sample.rb').processed_source).to be_a(::RuboCop::AST::ProcessedSource) + end + + context 'when the file path is absolute' do + it 'stores processed_source' do + absolute_path = File.join(Dir.pwd, 'spec/fixtures/target_sample.rb') + expect(CodeKeeper::SourceFile.new(absolute_path).processed_source).to be_a(::RuboCop::AST::ProcessedSource) + end + end + + context 'when the file does not exist' do + it 'raises TargetFileNotFoundError' do + expect do + CodeKeeper::SourceFile.new('./spec/fixtures/no_file.rb') + end.to raise_error CodeKeeper::TargetFileNotFoundError + end + end + end +end diff --git a/spec/fixtures/class_samples/singleton_scope.rb b/spec/fixtures/class_samples/singleton_scope.rb new file mode 100644 index 0000000..c767dcd --- /dev/null +++ b/spec/fixtures/class_samples/singleton_scope.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module SingletonScopeSample + class << self + def module_singleton_method + :a + end + end +end + +class SingletonScopeOwner + class << self + def class_singleton_method + :b + end + + define_method :defined_singleton_method do + :c + end + + def build_helper + define_method :built_instance_method do + :e + end + end + end +end + +class SingletonScopeRuntime + def attach + class << self + def tag + :d + end + end + end +end diff --git a/spec/fixtures/rubocop_config/.rubocop.yml b/spec/fixtures/rubocop_config/.rubocop.yml new file mode 100644 index 0000000..379b914 --- /dev/null +++ b/spec/fixtures/rubocop_config/.rubocop.yml @@ -0,0 +1,13 @@ +Metrics/AbcSize: + AllowedMethods: + - complex_method + Max: 999 + +Metrics/CyclomaticComplexity: + AllowedMethods: + - complex_method + Max: 999 + +AllCops: + Exclude: + - sample.rb diff --git a/spec/fixtures/rubocop_config/sample.rb b/spec/fixtures/rubocop_config/sample.rb new file mode 100644 index 0000000..be2d8ee --- /dev/null +++ b/spec/fixtures/rubocop_config/sample.rb @@ -0,0 +1,11 @@ +# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity +class ConfigIgnoredSample + def complex_method(value) + if value + puts value + else + puts :none + end + end +end +# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 8808a24..f4a6af9 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -12,4 +12,8 @@ config.expect_with :rspec do |c| c.syntax = :expect end + + config.before do + CodeKeeper.instance_variable_set(:@config, CodeKeeper::Config.new) + end end