From e082f187ae2566d892a813ad044e3429d1294ae4 Mon Sep 17 00:00:00 2001 From: ebihara99999 Date: Wed, 24 Jun 2026 23:14:00 +0900 Subject: [PATCH 01/15] feat: add metric snapshot reporting - Add metric-native measurements and snapshot summaries - Keep legacy JSON and CSV output paths for existing integrations - Document AI-assisted review workflows --- .rubocop.yml | 7 +- Gemfile.lock | 40 +++-- README.md | 66 +++++++- code_keeper.gemspec | 4 +- lib/code_keeper.rb | 5 + lib/code_keeper/config.rb | 3 +- lib/code_keeper/formatter.rb | 15 +- lib/code_keeper/measurement.rb | 36 ++++ lib/code_keeper/metrics.rb | 2 + lib/code_keeper/metrics/abc_metric.rb | 74 ++++++-- lib/code_keeper/metrics/class_length.rb | 159 ++++++++---------- .../metrics/cyclomatic_complexity.rb | 77 ++++++++- .../metrics/legacy_class_length.rb | 112 ++++++++++++ lib/code_keeper/metrics/scope_name.rb | 100 +++++++++++ lib/code_keeper/parser.rb | 10 +- lib/code_keeper/result.rb | 8 +- lib/code_keeper/scorer.rb | 52 +++++- lib/code_keeper/snapshot.rb | 39 +++++ lib/code_keeper/source_file.rb | 38 +++++ spec/code_keeper/cli_spec.rb | 27 +-- spec/code_keeper/formatter_spec.rb | 43 ++++- spec/code_keeper/metrics/abc_metric_spec.rb | 21 +++ spec/code_keeper/metrics/class_length_spec.rb | 22 +++ .../metrics/cyclomatic_complexity_spec.rb | 21 +++ spec/code_keeper/parser_spec.rb | 6 + spec/code_keeper/result_spec.rb | 19 +++ spec/code_keeper/scorer_spec.rb | 17 ++ spec/code_keeper/snapshot_spec.rb | 22 +++ spec/code_keeper/source_file_spec.rb | 21 +++ spec/code_keeper_spec.rb | 4 + spec/fixtures/rubocop_config/.rubocop.yml | 13 ++ spec/fixtures/rubocop_config/sample.rb | 11 ++ spec/spec_helper.rb | 4 + 33 files changed, 919 insertions(+), 179 deletions(-) create mode 100644 lib/code_keeper/measurement.rb create mode 100644 lib/code_keeper/metrics/legacy_class_length.rb create mode 100644 lib/code_keeper/metrics/scope_name.rb create mode 100644 lib/code_keeper/snapshot.rb create mode 100644 lib/code_keeper/source_file.rb create mode 100644 spec/code_keeper/snapshot_spec.rb create mode 100644 spec/code_keeper/source_file_spec.rb create mode 100644 spec/fixtures/rubocop_config/.rubocop.yml create mode 100644 spec/fixtures/rubocop_config/sample.rb diff --git a/.rubocop.yml b/.rubocop.yml index 803d3ab..65fbad3 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,4 +1,6 @@ AllCops: + NewCops: disable + SuggestExtensions: false Exclude: - spec/fixtures/**/*.rb @@ -29,7 +31,7 @@ Naming/HeredocDelimiterNaming: Enabled: false Metrics/MethodLength: - Max: 20 + Max: 40 Exclude: - lib/code_keeper/formatter.rb # because uses here doc @@ -56,6 +58,3 @@ Metrics/PerceivedComplexity: Exclude: # It's hard to control. - lib/code_keeper/metrics/class_length.rb - -Metrics/MethodLength: - Max: 40 diff --git a/Gemfile.lock b/Gemfile.lock index 959a96c..e8b734f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,27 +1,29 @@ PATH remote: . specs: - code_keeper (0.6.1) + code_keeper (0.6.2) parallel (>= 1.20.1, < 2) - rubocop (>= 1.13.0) - rubocop-ast (>= 1.4.1) + rubocop (>= 1.88.0) + rubocop-ast (>= 1.49.1) GEM remote: https://rubygems.org/ specs: - ast (2.4.2) + ast (2.4.3) csv (3.3.2) 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,22 +37,24 @@ 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 diff --git a/README.md b/README.md index 635ce24..3acb3eb 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,15 @@ # 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 snapshots for periodic code quality reviews. -Mesuring metrics leads to keep codebase simple and clean, and I name the gem CodeKeeper. +RuboCop is excellent at reporting offenses, but teams can silence offenses, exclude files, or relax thresholds. CodeKeeper is built for a different job: it keeps measuring code metrics independently from RuboCop offense configuration so humans and AI agents can use the numbers as review signals. -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. +CodeKeeper currently supports ABC size, cyclomatic complexity, and class length. 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 + +CodeKeeper does not read `.rubocop.yml`, `rubocop:disable`, `Max`, `AllowedMethods`, `AllowedPatterns`, or `Exclude`. RuboCop is used for Ruby parsing and metric calculation behavior, not for offense filtering. ## Installation @@ -22,14 +28,15 @@ 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 snapshot 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. The `measurements` section keeps the metric-native values that support deeper analysis. ### Run CodeKeeper To measure metrics of all the ruby files recursively in the current directory, run @@ -51,11 +58,58 @@ 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 engine uses RuboCop-standard metric definitions and ignores RuboCop offense config. + config.metrics_engine = :rubocop_standard # The default is json config.format = :json end ``` +### Output formats + +The default `json` format returns the new snapshot schema. + +```rb +CodeKeeper.configure do |config| + config.format = :json +end +``` + +For existing integrations, legacy formats remain available. + +```rb +CodeKeeper.configure do |config| + config.metrics_engine = :legacy + config.format = :legacy_json +end +``` + +`config.format = :csv` keeps the existing CSV format. `config.format = :legacy_csv` is an explicit alias for that behavior. + +## 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 snapshot. +3. Ask an AI agent to analyze hotspots, group measurements by file or domain, 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 snapshot. + +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..e02549d 100644 --- a/code_keeper.gemspec +++ b/code_keeper.gemspec @@ -29,8 +29,8 @@ Gem::Specification.new do |spec| # 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' + spec.add_dependency "rubocop-ast", '>= 1.49.1' # 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..4190fd3 100644 --- a/lib/code_keeper.rb +++ b/lib/code_keeper.rb @@ -2,6 +2,9 @@ require_relative "code_keeper/version" require "rubocop" +require 'code_keeper/source_file' +require 'code_keeper/measurement' +require 'code_keeper/snapshot' require 'code_keeper/parser' require 'code_keeper/finder' require 'code_keeper/cli' @@ -10,6 +13,8 @@ require 'code_keeper/scorer' require 'code_keeper/result' require 'code_keeper/metrics' +require 'code_keeper/metrics/scope_name' +require 'code_keeper/metrics/legacy_class_length' require 'code_keeper/metrics/abc_metric' require 'code_keeper/metrics/cyclomatic_complexity' require 'code_keeper/metrics/class_length' diff --git a/lib/code_keeper/config.rb b/lib/code_keeper/config.rb index feff894..5542dfc 100644 --- a/lib/code_keeper/config.rb +++ b/lib/code_keeper/config.rb @@ -3,12 +3,13 @@ module CodeKeeper # Provide configuration class Config - attr_accessor :metrics, :number_of_threads, :format + attr_accessor :metrics, :number_of_threads, :format, :metrics_engine def initialize @metrics = %i[cyclomatic_complexity class_length abc_metric] @number_of_threads = 2 @format = :json # json and csv are supported. + @metrics_engine = :rubocop_standard end end end diff --git a/lib/code_keeper/formatter.rb b/lib/code_keeper/formatter.rb index 93cd577..a303345 100644 --- a/lib/code_keeper/formatter.rb +++ b/lib/code_keeper/formatter.rb @@ -1,15 +1,26 @@ # frozen_string_literal: true require 'csv' +require 'json' module CodeKeeper # Format a result and make it human-readable. class Formatter class << self def format(result) - return result.scores.to_json if CodeKeeper.config.format == :json + case CodeKeeper.config.format + when :json + result.snapshot.to_h.to_json + when :legacy_json + result.scores.to_json + when :csv, :legacy_csv + legacy_csv(result) + end + end + + private - # csv is supported besides json + def legacy_csv(result) csv_array = [] result.scores.each_key do |metric| result.scores[metric].each { |k, v| csv_array << [metric, k, v] } diff --git a/lib/code_keeper/measurement.rb b/lib/code_keeper/measurement.rb new file mode 100644 index 0000000..a8f5645 --- /dev/null +++ b/lib/code_keeper/measurement.rb @@ -0,0 +1,36 @@ +# 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 legacy_key + return scope_name if %i[class module singleton_class].include?(scope_type) + + "#{path}:#{scope_name}" + end + + def to_h + { + metric: metric, + scope_type: scope_type, + scope_name: scope_name, + path: path, + start_line: start_line, + end_line: end_line, + value: value + } + end + end +end diff --git a/lib/code_keeper/metrics.rb b/lib/code_keeper/metrics.rb index 85f0c72..a10fe8a 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/legacy_class_length' 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..bd70e29 100644 --- a/lib/code_keeper/metrics/abc_metric.rb +++ b/lib/code_keeper/metrics/abc_metric.rb @@ -2,28 +2,68 @@ 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 initialize(file_path) - ps = Parser.parse(file_path) - @path = file_path - @body = ps.ast - @assignments = 0 - @branches = 0 - @conditionals = 0 + def self.measure(source_file) + new(source_file, engine: :rubocop_standard).measure + end + + def initialize(source_or_path, engine: CodeKeeper.config.metrics_engine) + @source_file = source_or_path.is_a?(SourceFile) ? source_or_path : Parser.source_file(source_or_path) + @path = @source_file.path + @body = @source_file.ast + @engine = engine 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') + return legacy_score if @engine == :legacy + + measure.to_h { |measurement| [measurement.legacy_key, measurement.value] } + end + + 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 + + private + + 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 + + def calculate(node) + return 0 unless node + + calculator = ::RuboCop::Cop::Metrics::Utils::AbcSizeCalculator + value, = calculator.calculate(node, discount_repeated_attributes: false) + value + rescue ArgumentError + value, = calculator.calculate(node) + value + end + + def legacy_score + calculator = ::RuboCop::Cop::Metrics::Utils::AbcSizeCalculator.new(@body) + calculator.calculate + assignments = calculator.instance_variable_get('@assignment') + conditionals = calculator.instance_variable_get('@condition') + branches = calculator.instance_variable_get('@branch') - value = Math.sqrt(@assignments**2 + @branches**2 + @conditionals**2).round(4) + value = Math.sqrt(assignments**2 + branches**2 + conditionals**2).round(4) { "#{@path}": value } end end diff --git a/lib/code_keeper/metrics/class_length.rb b/lib/code_keeper/metrics/class_length.rb index a8e41ab..52cd122 100644 --- a/lib/code_keeper/metrics/class_length.rb +++ b/lib/code_keeper/metrics/class_length.rb @@ -2,121 +2,94 @@ 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, engine: :rubocop_standard).measure + end + + def initialize(source_or_path, engine: CodeKeeper.config.metrics_engine) + @source_file = source_or_path.is_a?(SourceFile) ? source_or_path : Parser.source_file(source_or_path) + @path = @source_file.path + @ps = @source_file.processed_source + @body = @source_file.ast + @engine = engine 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? - - # 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 + return LegacyClassLength.new(@source_file).score if @engine == :legacy + + measure.to_h { |measurement| [measurement.legacy_key, measurement.value] } + 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) + ::RuboCop::Cop::Metrics::Utils::CodeLengthCalculator.new( + node, + @ps, + count_comments: false, + foldable_types: [] + ).calculate 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..ce3ce00 100644 --- a/lib/code_keeper/metrics/cyclomatic_complexity.rb +++ b/lib/code_keeper/metrics/cyclomatic_complexity.rb @@ -2,22 +2,83 @@ 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 - CONSIDERED_NODES = %i[if while until for csend block block_pass rescue when and or or_asgnand_asgn].freeze + COUNTED_NODES = + if defined?(::RuboCop::Cop::Metrics::CyclomaticComplexity::COUNTED_NODES) + ::RuboCop::Cop::Metrics::CyclomaticComplexity::COUNTED_NODES + else + %i[if while until for csend block block_pass rescue when and or or_asgn and_asgn].freeze + end + LEGACY_CONSIDERED_NODES = %i[if while until for csend block block_pass rescue when and or or_asgnand_asgn].freeze + + def self.measure(source_file) + new(source_file, engine: :rubocop_standard).measure + end - def initialize(file_path) - @path = file_path - ps = Parser.parse(@path) - @body = ps.ast + def initialize(source_or_path, engine: CodeKeeper.config.metrics_engine) + @source_file = source_or_path.is_a?(SourceFile) ? source_or_path : Parser.source_file(source_or_path) + @path = @source_file.path + @body = @source_file.ast + @engine = engine end - # returns score of cyclomatic complexity def score - final_score = @body.each_node(:lvasgn, *CONSIDERED_NODES).reduce(1) do |score, node| + return legacy_score if @engine == :legacy + + measure.to_h { |measurement| [measurement.legacy_key, measurement.value] } + end + + def measure + return [] unless @body + + 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 + + private + + 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 + + def calculate(body) + reset_repeated_csend + return 1 unless body + + body.each_node(:lvasgn, *COUNTED_NODES).reduce(1) do |score, node| + if node.lvasgn_type? + reset_on_lvasgn(node) + score + else + score + complexity_score_for(node) + end + end + end + + def complexity_score_for(node) + return 0 if iterating_block?(node) == false + return 0 if node.csend_type? && discount_for_repeated_csend?(node) + + 1 + end + + def legacy_score + final_score = @body.each_node(:lvasgn, *LEGACY_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) diff --git a/lib/code_keeper/metrics/legacy_class_length.rb b/lib/code_keeper/metrics/legacy_class_length.rb new file mode 100644 index 0000000..0efdadb --- /dev/null +++ b/lib/code_keeper/metrics/legacy_class_length.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +module CodeKeeper + module Metrics + # Keeps the historical CodeKeeper class length calculation for migration. + class LegacyClassLength + def initialize(source_file) + @ps = source_file.processed_source + @body = source_file.ast + end + + def score + score_hash = {} + + @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? + score_for_const_assignment(score_hash, node) + end + end + + score_hash + end + + private + + def score_for_const_assignment(score_hash, node) + klass, block_node = const_assignment_parts(node) + return unless class_definition?(block_node) + + score_hash.store(klass || build_namespace(block_node), calculate(block_node)) + end + + def const_assignment_parts(node) + parent = node.parent + return [node.loc.name.source, node.children[2]] if parent&.assignment? + return multiple_const_assignment_parts(node, parent) if parent&.parent&.masgn_type? + + _scope, klass, block_node = *node + [klass.to_s, block_node] + end + + def multiple_const_assignment_parts(node, parent) + assigned = parent.loc.expression.source.split(',').first + return unless node.loc.name.source == assigned + + [node.loc.name.source, parent.parent.children[1]] + end + + def class_definition?(node) + node.respond_to?(:class_definition?) && node.class_definition? + end + + def calculate(node) + count = node.line_count - 2 + + count - line_count_of_inner_nodes(node) - comment_line_count(node) - empty_line_count(node) + end + + def body_lines(node) + (node.first_line..node.last_line).to_a - descendant_class_lines(node) + end + + def descendant_class_lines(node) + node.each_descendant(:class, :module).map do |descendant| + (descendant.first_line..descendant.last_line).to_a + end.flatten.uniq + 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 + 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 + end + + def comment_line_count(node) + node_range = node.first_line...node.last_line + comment_lines = @ps.comments.map { |comment| comment.loc.line } + comment_lines.select { |cl| !descendant_class_lines(node).include?(cl) && node_range.include?(cl) }.count + 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}" + 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 + 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..e429d73 --- /dev/null +++ b/lib/code_keeper/metrics/scope_name.rb @@ -0,0 +1,100 @@ +# 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 + 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 << #{node.children.first.source}" + 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) + + join_instance_method(owner_name(node), name) + end + + def owner_name(node) + node.each_ancestor(:class, :module).to_a.reverse.filter_map do |ancestor| + class_name(ancestor) + 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 index 797048b..bd91aaf 100644 --- a/lib/code_keeper/parser.rb +++ b/lib/code_keeper/parser.rb @@ -6,10 +6,8 @@ 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}." + @source_file = SourceFile.new(file_path) + @processed_source = @source_file.processed_source end class << self @@ -17,6 +15,10 @@ def parse(file_path) parser = new(file_path) parser.processed_source end + + def source_file(file_path) + SourceFile.new(file_path) + end end end end diff --git a/lib/code_keeper/result.rb b/lib/code_keeper/result.rb index 436d31b..092ea33 100644 --- a/lib/code_keeper/result.rb +++ b/lib/code_keeper/result.rb @@ -3,14 +3,20 @@ module CodeKeeper # Store results of each score. class Result - attr_reader :scores + attr_reader :scores, :snapshot def initialize @scores = CodeKeeper.config.metrics.map { |key| [key, {}] }.to_h + @snapshot = Snapshot.new end def add(metric, klass_or_path, score) scores[:"#{metric}"].store(klass_or_path, score) end + + def add_measurement(measurement) + snapshot.add(measurement) + add(measurement.metric, measurement.legacy_key, measurement.value) + end end end diff --git a/lib/code_keeper/scorer.rb b/lib/code_keeper/scorer.rb index 4e74148..3bd609a 100644 --- a/lib/code_keeper/scorer.rb +++ b/lib/code_keeper/scorer.rb @@ -10,27 +10,61 @@ def keep(paths) 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. + return keep_legacy(ruby_file_paths, metrics, result, num_threads) if CodeKeeper.config.metrics_engine == :legacy + + measurements = measure_files(ruby_file_paths, metrics, num_threads) + measurements.each { |measurement| result.add_measurement(measurement) } + + result + end + + private + + def keep_legacy(ruby_file_paths, metrics, result, num_threads) if num_threads == 1 ruby_file_paths.each do |path| - metrics.each { |metric| calculate_score(metric, path, result) } + metrics.each { |metric| calculate_legacy_score(metric, path, result) } end else - Parallel.map(ruby_file_paths, in_threads: num_threads) do |path| - metrics.each { |metric| calculate_score(metric, path, result) } + legacy_scores = Parallel.map(ruby_file_paths, in_threads: num_threads) do |path| + metrics.each_with_object([]) do |metric, scores| + scores << [metric, ::CodeKeeper::Metrics::MAPPINGS[metric].new(path, engine: :legacy).score] + end + end + + legacy_scores.flatten(1).each do |metric, score| + add_score(metric, score, result) end end result end - private + def measure_files(ruby_file_paths, metrics, num_threads) + if num_threads == 1 + ruby_file_paths.flat_map { |path| measure_file(path, metrics) } + else + Parallel.map(ruby_file_paths, in_threads: num_threads) do |path| + measure_file(path, metrics) + end.flatten + end + end + + def measure_file(path, metrics) + source_file = Parser.source_file(path) - def calculate_score(metric, path, result) - score = ::CodeKeeper::Metrics::MAPPINGS[metric].new(path).score + metrics.flat_map do |metric| + ::CodeKeeper::Metrics::MAPPINGS[metric].measure(source_file) + end + end + + def calculate_legacy_score(metric, path, result) + score = ::CodeKeeper::Metrics::MAPPINGS[metric].new(path, engine: :legacy).score + + add_score(metric, score, result) + end - # The class length metric's score has multiple classes. + def add_score(metric, score, result) score.each do |k, v| result.add(metric, k.to_s, v) end diff --git a/lib/code_keeper/snapshot.rb b/lib/code_keeper/snapshot.rb new file mode 100644 index 0000000..8934fc0 --- /dev/null +++ b/lib/code_keeper/snapshot.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module CodeKeeper + # Stores metric-native measurements and derives a compact review summary. + class Snapshot + 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: group.select { |measurement| measurement.value == max_value }.map(&:to_h) + } + end + 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/spec/code_keeper/cli_spec.rb b/spec/code_keeper/cli_spec.rb index 4b760bb..ca6cc9c 100644 --- a/spec/code_keeper/cli_spec.rb +++ b/spec/code_keeper/cli_spec.rb @@ -1,6 +1,16 @@ # 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| @@ -10,23 +20,20 @@ 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 snapshot 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 +60,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 +77,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..f156926 100644 --- a/spec/code_keeper/formatter_spec.rb +++ b/spec/code_keeper/formatter_spec.rb @@ -32,10 +32,22 @@ end end - context 'json format' do + context 'legacy csv format' do before do CodeKeeper.configure do |config| - config.format = :json + config.format = :legacy_csv + end + end + + it 'returns a csv string' do + expect(CodeKeeper::Formatter.format(@result)).to start_with "metric,file,score\n" + end + end + + context 'legacy json format' do + before do + CodeKeeper.configure do |config| + config.format = :legacy_json end end @@ -43,9 +55,34 @@ %({\"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 + it 'returns a legacy json 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 + + it 'returns snapshot json' do + @result.add_measurement( + CodeKeeper::Measurement.new( + metric: :cyclomatic_complexity, + scope_type: :method, + scope_name: 'Sample#hello', + path: '/foo/bar/code_keeper/spec/fixtures/target_sample.rb', + start_line: 2, + end_line: 4, + value: 1 + ) + ) + json = JSON.parse(CodeKeeper::Formatter.format(@result)) + + expect(json.dig('summary', 'metrics', 'cyclomatic_complexity', 'max')).to eq 1 + end + end end end diff --git a/spec/code_keeper/metrics/abc_metric_spec.rb b/spec/code_keeper/metrics/abc_metric_spec.rb index 2875308..25eaf47 100644 --- a/spec/code_keeper/metrics/abc_metric_spec.rb +++ b/spec/code_keeper/metrics/abc_metric_spec.rb @@ -2,10 +2,31 @@ RSpec.describe CodeKeeper::Metrics::AbcMetric do describe "#score" do + before do + CodeKeeper.configure do |config| + config.metrics_engine = :legacy + end + end + 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 end end + + describe '.measure' do + it 'returns measurements by method scope' do + source_file = CodeKeeper::Parser.source_file('spec/fixtures/branch_in_loop.rb') + measurement = CodeKeeper::Metrics::AbcMetric.measure(source_file).first + + expect(measurement.scope_name).to eq 'two_hundred' + end + + it 'does not suppress measurements with RuboCop comments or config' do + source_file = CodeKeeper::Parser.source_file('spec/fixtures/rubocop_config/sample.rb') + + expect(CodeKeeper::Metrics::AbcMetric.measure(source_file).size).to eq 1 + 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..61739d5 100644 --- a/spec/code_keeper/metrics/class_length_spec.rb +++ b/spec/code_keeper/metrics/class_length_spec.rb @@ -2,6 +2,12 @@ RSpec.describe CodeKeeper::Metrics::ClassLength do describe "#score" do + before do + CodeKeeper.configure do |config| + config.metrics_engine = :legacy + end + end + # 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 @@ -57,4 +63,20 @@ end end end + + describe '.measure' do + it 'returns measurements by class scope' do + source_file = CodeKeeper::Parser.source_file('spec/fixtures/class_samples/simple_class.rb') + measurement = CodeKeeper::Metrics::ClassLength.measure(source_file).first + + expect(measurement.scope_name).to eq 'SimpleClass' + end + + it 'uses RuboCop-style code length calculation' do + source_file = CodeKeeper::Parser.source_file('spec/fixtures/class_samples/simple_class.rb') + measurement = CodeKeeper::Metrics::ClassLength.measure(source_file).first + + expect(measurement.value).to eq 3 + 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..3dc2ca6 100644 --- a/spec/code_keeper/metrics/cyclomatic_complexity_spec.rb +++ b/spec/code_keeper/metrics/cyclomatic_complexity_spec.rb @@ -2,10 +2,31 @@ RSpec.describe CodeKeeper::Metrics::CyclomaticComplexity do describe "#score" do + before do + CodeKeeper.configure do |config| + config.metrics_engine = :legacy + end + end + 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 end end + + describe '.measure' do + it 'returns RuboCop-style method complexity' do + source_file = CodeKeeper::Parser.source_file('spec/fixtures/branch_in_loop.rb') + measurement = CodeKeeper::Metrics::CyclomaticComplexity.measure(source_file).first + + expect(measurement.value).to eq 3 + end + + it 'does not suppress measurements with RuboCop comments or config' do + source_file = CodeKeeper::Parser.source_file('spec/fixtures/rubocop_config/sample.rb') + + expect(CodeKeeper::Metrics::CyclomaticComplexity.measure(source_file).size).to eq 1 + end + end end diff --git a/spec/code_keeper/parser_spec.rb b/spec/code_keeper/parser_spec.rb index 363ba76..8387d21 100644 --- a/spec/code_keeper/parser_spec.rb +++ b/spec/code_keeper/parser_spec.rb @@ -31,4 +31,10 @@ expect(CodeKeeper::Parser.parse('./spec/fixtures/target_sample.rb')).to be_a(::RuboCop::AST::ProcessedSource) end end + + describe '.source_file' do + it 'returns CodeKeeper::SourceFile instance' do + expect(CodeKeeper::Parser.source_file('./spec/fixtures/target_sample.rb')).to be_a(CodeKeeper::SourceFile) + end + end end diff --git a/spec/code_keeper/result_spec.rb b/spec/code_keeper/result_spec.rb index fe0115c..406149b 100644 --- a/spec/code_keeper/result_spec.rb +++ b/spec/code_keeper/result_spec.rb @@ -23,4 +23,23 @@ expect(@result.scores).to eq expected_hash end end + + describe '#add_measurement' do + it 'stores measurement in snapshot' do + result = CodeKeeper::Result.new + measurement = 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 + ) + + result.add_measurement(measurement) + + expect(result.snapshot.measurements).to eq [measurement] + end + end end diff --git a/spec/code_keeper/scorer_spec.rb b/spec/code_keeper/scorer_spec.rb index 604fd7e..a99e99c 100644 --- a/spec/code_keeper/scorer_spec.rb +++ b/spec/code_keeper/scorer_spec.rb @@ -5,5 +5,22 @@ it 'returns an instance of CodeKeeper::Result' do expect(CodeKeeper::Scorer.keep(['./spec/fixtures/branch_in_loop.rb'])).to be_a CodeKeeper::Result end + + it 'stores measurements with the standard engine' do + CodeKeeper.configure do |config| + config.metrics = [:cyclomatic_complexity] + end + + expect(CodeKeeper::Scorer.keep(['./spec/fixtures/branch_in_loop.rb']).snapshot.measurements.size).to eq 1 + end + + it 'stores legacy scores with the legacy engine' do + CodeKeeper.configure do |config| + config.metrics = [:cyclomatic_complexity] + config.metrics_engine = :legacy + end + + expect(CodeKeeper::Scorer.keep(['./spec/fixtures/branch_in_loop.rb']).scores[:cyclomatic_complexity].values).to eq [2] + end end end diff --git a/spec/code_keeper/snapshot_spec.rb b/spec/code_keeper/snapshot_spec.rb new file mode 100644 index 0000000..7f18eeb --- /dev/null +++ b/spec/code_keeper/snapshot_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +RSpec.describe CodeKeeper::Snapshot do + describe '#to_h' do + it 'summarizes measurements by metric' do + snapshot = CodeKeeper::Snapshot.new + snapshot.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(snapshot.to_h[:summary][:metrics][:abc_metric][:max]).to eq 2.0 + 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..3cb25f7 --- /dev/null +++ b/spec/code_keeper/source_file_spec.rb @@ -0,0 +1,21 @@ +# 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 + end +end diff --git a/spec/code_keeper_spec.rb b/spec/code_keeper_spec.rb index e9dcc08..dcc9fbf 100644 --- a/spec/code_keeper_spec.rb +++ b/spec/code_keeper_spec.rb @@ -9,5 +9,9 @@ it 'set the default value to metrics' do expect(CodeKeeper.configure { |config| config }).to be_a CodeKeeper::Config end + + it 'sets rubocop standard engine by default' do + expect(CodeKeeper.config.metrics_engine).to eq :rubocop_standard + 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 From 8c926b3d8b51eae660120cf86d45889936c2ecf3 Mon Sep 17 00:00:00 2001 From: ebihara99999 Date: Thu, 25 Jun 2026 00:03:16 +0900 Subject: [PATCH 02/15] refactor: centralize RuboCop metric calculations - Move RuboCop calculator calls behind a standard engine adapter - Keep metric classes focused on scope extraction and measurement shaping --- lib/code_keeper.rb | 1 + lib/code_keeper/metrics.rb | 1 + lib/code_keeper/metrics/abc_metric.rb | 9 +--- lib/code_keeper/metrics/class_length.rb | 7 +--- .../metrics/cyclomatic_complexity.rb | 25 +---------- .../metrics/rubocop_metric_calculator.rb | 41 +++++++++++++++++++ 6 files changed, 46 insertions(+), 38 deletions(-) create mode 100644 lib/code_keeper/metrics/rubocop_metric_calculator.rb diff --git a/lib/code_keeper.rb b/lib/code_keeper.rb index 4190fd3..176bd4f 100644 --- a/lib/code_keeper.rb +++ b/lib/code_keeper.rb @@ -14,6 +14,7 @@ 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/legacy_class_length' require 'code_keeper/metrics/abc_metric' require 'code_keeper/metrics/cyclomatic_complexity' diff --git a/lib/code_keeper/metrics.rb b/lib/code_keeper/metrics.rb index a10fe8a..4c2ba3c 100644 --- a/lib/code_keeper/metrics.rb +++ b/lib/code_keeper/metrics.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'code_keeper/metrics/scope_name' +require 'code_keeper/metrics/rubocop_metric_calculator' require 'code_keeper/metrics/legacy_class_length' require 'code_keeper/metrics/abc_metric' require 'code_keeper/metrics/cyclomatic_complexity' diff --git a/lib/code_keeper/metrics/abc_metric.rb b/lib/code_keeper/metrics/abc_metric.rb index bd70e29..f9a79d7 100644 --- a/lib/code_keeper/metrics/abc_metric.rb +++ b/lib/code_keeper/metrics/abc_metric.rb @@ -46,14 +46,7 @@ def method_nodes end def calculate(node) - return 0 unless node - - calculator = ::RuboCop::Cop::Metrics::Utils::AbcSizeCalculator - value, = calculator.calculate(node, discount_repeated_attributes: false) - value - rescue ArgumentError - value, = calculator.calculate(node) - value + RuboCopMetricCalculator.abc_size(node) end def legacy_score diff --git a/lib/code_keeper/metrics/class_length.rb b/lib/code_keeper/metrics/class_length.rb index 52cd122..7108353 100644 --- a/lib/code_keeper/metrics/class_length.rb +++ b/lib/code_keeper/metrics/class_length.rb @@ -64,12 +64,7 @@ def classlike_nodes end def calculate(node) - ::RuboCop::Cop::Metrics::Utils::CodeLengthCalculator.new( - node, - @ps, - count_comments: false, - foldable_types: [] - ).calculate + RuboCopMetricCalculator.class_length(node, @ps) end def class_definition_expression(node) diff --git a/lib/code_keeper/metrics/cyclomatic_complexity.rb b/lib/code_keeper/metrics/cyclomatic_complexity.rb index ce3ce00..59a0301 100644 --- a/lib/code_keeper/metrics/cyclomatic_complexity.rb +++ b/lib/code_keeper/metrics/cyclomatic_complexity.rb @@ -7,12 +7,6 @@ class CyclomaticComplexity include ::RuboCop::Cop::Metrics::Utils::IteratingBlock include ::RuboCop::Cop::Metrics::Utils::RepeatedCsendDiscount - COUNTED_NODES = - if defined?(::RuboCop::Cop::Metrics::CyclomaticComplexity::COUNTED_NODES) - ::RuboCop::Cop::Metrics::CyclomaticComplexity::COUNTED_NODES - else - %i[if while until for csend block block_pass rescue when and or or_asgn and_asgn].freeze - end LEGACY_CONSIDERED_NODES = %i[if while until for csend block block_pass rescue when and or or_asgnand_asgn].freeze def self.measure(source_file) @@ -57,24 +51,7 @@ def method_nodes end def calculate(body) - reset_repeated_csend - return 1 unless body - - body.each_node(:lvasgn, *COUNTED_NODES).reduce(1) do |score, node| - if node.lvasgn_type? - reset_on_lvasgn(node) - score - else - score + complexity_score_for(node) - end - end - end - - def complexity_score_for(node) - return 0 if iterating_block?(node) == false - return 0 if node.csend_type? && discount_for_repeated_csend?(node) - - 1 + RuboCopMetricCalculator.cyclomatic_complexity(body) end def legacy_score 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..0e77e7c --- /dev/null +++ b/lib/code_keeper/metrics/rubocop_metric_calculator.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module CodeKeeper + module Metrics + # Centralizes RuboCop metric calculation details used by the standard engine. + module RuboCopMetricCalculator + module_function + + def abc_size(node) + return 0 unless node + + value, = abc_calculator.calculate(node, discount_repeated_attributes: false) + value + rescue ArgumentError + value, = abc_calculator.calculate(node) + 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 + + def abc_calculator + ::RuboCop::Cop::Metrics::Utils::AbcSizeCalculator + end + end + end +end From d89d740256cea97cf442d72be742c6c5baba3416 Mon Sep 17 00:00:00 2001 From: ebihara99999 Date: Thu, 25 Jun 2026 00:05:23 +0900 Subject: [PATCH 03/15] docs: clarify metric snapshot design - Explain how CodeKeeper uses RuboCop analysis without applying offense policy - Document metric-native scopes and AI review usage --- README.md | 65 ++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 55 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 3acb3eb..09cb26e 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,27 @@ # CodeKeeper CodeKeeper emits Ruby code metric snapshots for periodic code quality reviews. -RuboCop is excellent at reporting offenses, but teams can silence offenses, exclude files, or relax thresholds. CodeKeeper is built for a different job: it keeps measuring code metrics independently from RuboCop offense configuration so humans and AI agents can use the numbers as review signals. +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. -CodeKeeper currently supports ABC size, cyclomatic complexity, and class length. Each metric is measured at its natural scope: +The intended use is recurring code quality review. Run CodeKeeper, hand the structured snapshot to a human or AI agent, and use the metric values as review signals. -- `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 +## Design -CodeKeeper does not read `.rubocop.yml`, `rubocop:disable`, `Max`, `AllowedMethods`, `AllowedPatterns`, or `Exclude`. RuboCop is used for Ruby parsing and metric calculation behavior, not for offense filtering. +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. + +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 @@ -33,10 +45,41 @@ Run CodeKeeper and you get a metric snapshot from stdout. ```rb $ bundle exec code_keeper app/models/user.rb app/models/admin.rb > metrics.json $ cat metrics.json -{"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}]} +{ + "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 + } + ] +} ``` -The `summary` section is intended for quick review. The `measurements` section keeps the metric-native values that support deeper analysis. +The `summary` section is intended for quick review. The `measurements` section contains the metric-native values that support deeper analysis. ### Run CodeKeeper To measure metrics of all the ruby files recursively in the current directory, run @@ -58,7 +101,7 @@ 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 engine uses RuboCop-standard metric definitions and ignores RuboCop offense config. + # The default engine uses RuboCop-standard metric definitions through CodeKeeper adapters. config.metrics_engine = :rubocop_standard # The default is json config.format = :json @@ -86,13 +129,15 @@ end `config.format = :csv` keeps the existing CSV format. `config.format = :legacy_csv` is an explicit alias for that behavior. +The legacy engine keeps historical CodeKeeper behavior for migration. New integrations should use the default `:rubocop_standard` engine. + ## 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 snapshot. -3. Ask an AI agent to analyze hotspots, group measurements by file or domain, and suggest focused refactoring candidates. +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: From f697410e2065dae7c3161b53f80eca5097d735db Mon Sep 17 00:00:00 2001 From: ebihara99999 Date: Thu, 25 Jun 2026 00:25:29 +0900 Subject: [PATCH 04/15] refactor: remove legacy metric paths for 1.0 - Drop legacy metric engine and legacy output aliases - Keep score compatibility views derived from metric measurements - Remove redundant direct dependencies and align Ruby support with maintained versions --- .github/workflows/main.yml | 2 +- CHANGELOG.md | 9 ++ Gemfile.lock | 4 +- README.md | 15 +-- code_keeper.gemspec | 4 +- lib/code_keeper.rb | 1 - lib/code_keeper/config.rb | 3 +- lib/code_keeper/formatter.rb | 8 +- lib/code_keeper/measurement.rb | 2 +- lib/code_keeper/metrics.rb | 1 - lib/code_keeper/metrics/abc_metric.rb | 20 +--- lib/code_keeper/metrics/class_length.rb | 9 +- .../metrics/cyclomatic_complexity.rb | 21 +--- .../metrics/legacy_class_length.rb | 112 ------------------ .../metrics/rubocop_metric_calculator.rb | 2 +- lib/code_keeper/result.rb | 2 +- lib/code_keeper/scorer.rb | 63 +++++----- lib/code_keeper/version.rb | 2 +- spec/code_keeper/formatter_spec.rb | 28 ----- spec/code_keeper/metrics/abc_metric_spec.rb | 30 +++-- spec/code_keeper/metrics/class_length_spec.rb | 80 ++++--------- .../metrics/cyclomatic_complexity_spec.rb | 25 ++-- spec/code_keeper/scorer_spec.rb | 15 ++- spec/code_keeper_spec.rb | 4 - 24 files changed, 123 insertions(+), 339 deletions(-) delete mode 100644 lib/code_keeper/metrics/legacy_class_length.rb 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/CHANGELOG.md b/CHANGELOG.md index 439b3bf..c693968 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,13 @@ # Change log +## 1.0.0 (Unreleased) +### Changes +- Make JSON output a metric snapshot with summaries and measurements. +- Measure metrics at their natural scope instead of treating files as the primary scope. +- Use RuboCop metric calculation behavior through CodeKeeper adapters. +- Remove legacy metric engine and legacy output format aliases. +- Remove direct `parallel` and `rubocop-ast` runtime dependencies. +- Support Ruby 3.3 and later. + ## 0.6.2 (2025-01-11) ### Changes - [Support Ruby 3.4.](https://github.com/ebihara99999/code_keeper/pull/38) diff --git a/Gemfile.lock b/Gemfile.lock index e8b734f..2ed56a5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,10 +1,8 @@ PATH remote: . specs: - code_keeper (0.6.2) - parallel (>= 1.20.1, < 2) + code_keeper (1.0.0) rubocop (>= 1.88.0) - rubocop-ast (>= 1.49.1) GEM remote: https://rubygems.org/ diff --git a/README.md b/README.md index 09cb26e..665c312 100644 --- a/README.md +++ b/README.md @@ -101,8 +101,6 @@ 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 engine uses RuboCop-standard metric definitions through CodeKeeper adapters. - config.metrics_engine = :rubocop_standard # The default is json config.format = :json end @@ -118,18 +116,7 @@ CodeKeeper.configure do |config| end ``` -For existing integrations, legacy formats remain available. - -```rb -CodeKeeper.configure do |config| - config.metrics_engine = :legacy - config.format = :legacy_json -end -``` - -`config.format = :csv` keeps the existing CSV format. `config.format = :legacy_csv` is an explicit alias for that behavior. - -The legacy engine keeps historical CodeKeeper behavior for migration. New integrations should use the default `:rubocop_standard` engine. +`config.format = :csv` returns a compatibility-oriented table derived from the same measurements. ## Using CodeKeeper with AI review workflows diff --git a/code_keeper.gemspec b/code_keeper.gemspec index e02549d..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.88.0' - spec.add_dependency "rubocop-ast", '>= 1.49.1' # 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 176bd4f..4ccd423 100644 --- a/lib/code_keeper.rb +++ b/lib/code_keeper.rb @@ -15,7 +15,6 @@ require 'code_keeper/metrics' require 'code_keeper/metrics/scope_name' require 'code_keeper/metrics/rubocop_metric_calculator' -require 'code_keeper/metrics/legacy_class_length' require 'code_keeper/metrics/abc_metric' require 'code_keeper/metrics/cyclomatic_complexity' require 'code_keeper/metrics/class_length' diff --git a/lib/code_keeper/config.rb b/lib/code_keeper/config.rb index 5542dfc..feff894 100644 --- a/lib/code_keeper/config.rb +++ b/lib/code_keeper/config.rb @@ -3,13 +3,12 @@ module CodeKeeper # Provide configuration class Config - attr_accessor :metrics, :number_of_threads, :format, :metrics_engine + attr_accessor :metrics, :number_of_threads, :format def initialize @metrics = %i[cyclomatic_complexity class_length abc_metric] @number_of_threads = 2 @format = :json # json and csv are supported. - @metrics_engine = :rubocop_standard end end end diff --git a/lib/code_keeper/formatter.rb b/lib/code_keeper/formatter.rb index a303345..b90771e 100644 --- a/lib/code_keeper/formatter.rb +++ b/lib/code_keeper/formatter.rb @@ -11,16 +11,14 @@ def format(result) case CodeKeeper.config.format when :json result.snapshot.to_h.to_json - when :legacy_json - result.scores.to_json - when :csv, :legacy_csv - legacy_csv(result) + when :csv + csv(result) end end private - def legacy_csv(result) + def csv(result) csv_array = [] result.scores.each_key do |metric| result.scores[metric].each { |k, v| csv_array << [metric, k, v] } diff --git a/lib/code_keeper/measurement.rb b/lib/code_keeper/measurement.rb index a8f5645..80d2f70 100644 --- a/lib/code_keeper/measurement.rb +++ b/lib/code_keeper/measurement.rb @@ -15,7 +15,7 @@ def initialize(attributes) @value = attributes.fetch(:value) end - def legacy_key + def score_key return scope_name if %i[class module singleton_class].include?(scope_type) "#{path}:#{scope_name}" diff --git a/lib/code_keeper/metrics.rb b/lib/code_keeper/metrics.rb index 4c2ba3c..4a9a904 100644 --- a/lib/code_keeper/metrics.rb +++ b/lib/code_keeper/metrics.rb @@ -2,7 +2,6 @@ require 'code_keeper/metrics/scope_name' require 'code_keeper/metrics/rubocop_metric_calculator' -require 'code_keeper/metrics/legacy_class_length' 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 f9a79d7..e0c99bf 100644 --- a/lib/code_keeper/metrics/abc_metric.rb +++ b/lib/code_keeper/metrics/abc_metric.rb @@ -5,20 +5,17 @@ module Metrics # Calculates ABC size at the method scope. class AbcMetric def self.measure(source_file) - new(source_file, engine: :rubocop_standard).measure + new(source_file).measure end - def initialize(source_or_path, engine: CodeKeeper.config.metrics_engine) + def initialize(source_or_path) @source_file = source_or_path.is_a?(SourceFile) ? source_or_path : Parser.source_file(source_or_path) @path = @source_file.path @body = @source_file.ast - @engine = engine end def score - return legacy_score if @engine == :legacy - - measure.to_h { |measurement| [measurement.legacy_key, measurement.value] } + measure.to_h { |measurement| [measurement.score_key, measurement.value] } end def measure @@ -48,17 +45,6 @@ def method_nodes def calculate(node) RuboCopMetricCalculator.abc_size(node) end - - def legacy_score - calculator = ::RuboCop::Cop::Metrics::Utils::AbcSizeCalculator.new(@body) - calculator.calculate - assignments = calculator.instance_variable_get('@assignment') - conditionals = calculator.instance_variable_get('@condition') - branches = calculator.instance_variable_get('@branch') - - value = Math.sqrt(assignments**2 + branches**2 + conditionals**2).round(4) - { "#{@path}": value } - end end end end diff --git a/lib/code_keeper/metrics/class_length.rb b/lib/code_keeper/metrics/class_length.rb index 7108353..f1b0073 100644 --- a/lib/code_keeper/metrics/class_length.rb +++ b/lib/code_keeper/metrics/class_length.rb @@ -5,21 +5,18 @@ module Metrics # Calculates class-like code length at the class/module scope. class ClassLength def self.measure(source_file) - new(source_file, engine: :rubocop_standard).measure + new(source_file).measure end - def initialize(source_or_path, engine: CodeKeeper.config.metrics_engine) + def initialize(source_or_path) @source_file = source_or_path.is_a?(SourceFile) ? source_or_path : Parser.source_file(source_or_path) @path = @source_file.path @ps = @source_file.processed_source @body = @source_file.ast - @engine = engine end def score - return LegacyClassLength.new(@source_file).score if @engine == :legacy - - measure.to_h { |measurement| [measurement.legacy_key, measurement.value] } + measure.to_h { |measurement| [measurement.score_key, measurement.value] } end def measure diff --git a/lib/code_keeper/metrics/cyclomatic_complexity.rb b/lib/code_keeper/metrics/cyclomatic_complexity.rb index 59a0301..510cbf8 100644 --- a/lib/code_keeper/metrics/cyclomatic_complexity.rb +++ b/lib/code_keeper/metrics/cyclomatic_complexity.rb @@ -7,23 +7,18 @@ class CyclomaticComplexity include ::RuboCop::Cop::Metrics::Utils::IteratingBlock include ::RuboCop::Cop::Metrics::Utils::RepeatedCsendDiscount - LEGACY_CONSIDERED_NODES = %i[if while until for csend block block_pass rescue when and or or_asgnand_asgn].freeze - def self.measure(source_file) - new(source_file, engine: :rubocop_standard).measure + new(source_file).measure end - def initialize(source_or_path, engine: CodeKeeper.config.metrics_engine) + def initialize(source_or_path) @source_file = source_or_path.is_a?(SourceFile) ? source_or_path : Parser.source_file(source_or_path) @path = @source_file.path @body = @source_file.ast - @engine = engine end def score - return legacy_score if @engine == :legacy - - measure.to_h { |measurement| [measurement.legacy_key, measurement.value] } + measure.to_h { |measurement| [measurement.score_key, measurement.value] } end def measure @@ -53,16 +48,6 @@ def method_nodes def calculate(body) RuboCopMetricCalculator.cyclomatic_complexity(body) end - - def legacy_score - final_score = @body.each_node(:lvasgn, *LEGACY_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) - - next 1 + score - end - { "#{@path}": final_score } - end end end end diff --git a/lib/code_keeper/metrics/legacy_class_length.rb b/lib/code_keeper/metrics/legacy_class_length.rb deleted file mode 100644 index 0efdadb..0000000 --- a/lib/code_keeper/metrics/legacy_class_length.rb +++ /dev/null @@ -1,112 +0,0 @@ -# frozen_string_literal: true - -module CodeKeeper - module Metrics - # Keeps the historical CodeKeeper class length calculation for migration. - class LegacyClassLength - def initialize(source_file) - @ps = source_file.processed_source - @body = source_file.ast - end - - def score - score_hash = {} - - @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? - score_for_const_assignment(score_hash, node) - end - end - - score_hash - end - - private - - def score_for_const_assignment(score_hash, node) - klass, block_node = const_assignment_parts(node) - return unless class_definition?(block_node) - - score_hash.store(klass || build_namespace(block_node), calculate(block_node)) - end - - def const_assignment_parts(node) - parent = node.parent - return [node.loc.name.source, node.children[2]] if parent&.assignment? - return multiple_const_assignment_parts(node, parent) if parent&.parent&.masgn_type? - - _scope, klass, block_node = *node - [klass.to_s, block_node] - end - - def multiple_const_assignment_parts(node, parent) - assigned = parent.loc.expression.source.split(',').first - return unless node.loc.name.source == assigned - - [node.loc.name.source, parent.parent.children[1]] - end - - def class_definition?(node) - node.respond_to?(:class_definition?) && node.class_definition? - end - - def calculate(node) - count = node.line_count - 2 - - count - line_count_of_inner_nodes(node) - comment_line_count(node) - empty_line_count(node) - end - - def body_lines(node) - (node.first_line..node.last_line).to_a - descendant_class_lines(node) - end - - def descendant_class_lines(node) - node.each_descendant(:class, :module).map do |descendant| - (descendant.first_line..descendant.last_line).to_a - end.flatten.uniq - 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 - 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 - end - - def comment_line_count(node) - node_range = node.first_line...node.last_line - comment_lines = @ps.comments.map { |comment| comment.loc.line } - comment_lines.select { |cl| !descendant_class_lines(node).include?(cl) && node_range.include?(cl) }.count - 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}" - 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 - end - end - end -end diff --git a/lib/code_keeper/metrics/rubocop_metric_calculator.rb b/lib/code_keeper/metrics/rubocop_metric_calculator.rb index 0e77e7c..26bbb3d 100644 --- a/lib/code_keeper/metrics/rubocop_metric_calculator.rb +++ b/lib/code_keeper/metrics/rubocop_metric_calculator.rb @@ -2,7 +2,7 @@ module CodeKeeper module Metrics - # Centralizes RuboCop metric calculation details used by the standard engine. + # Centralizes RuboCop metric calculation details. module RuboCopMetricCalculator module_function diff --git a/lib/code_keeper/result.rb b/lib/code_keeper/result.rb index 092ea33..8cc7760 100644 --- a/lib/code_keeper/result.rb +++ b/lib/code_keeper/result.rb @@ -16,7 +16,7 @@ def add(metric, klass_or_path, score) def add_measurement(measurement) snapshot.add(measurement) - add(measurement.metric, measurement.legacy_key, measurement.value) + add(measurement.metric, measurement.score_key, measurement.value) end end end diff --git a/lib/code_keeper/scorer.rb b/lib/code_keeper/scorer.rb index 3bd609a..f6d2711 100644 --- a/lib/code_keeper/scorer.rb +++ b/lib/code_keeper/scorer.rb @@ -10,8 +10,6 @@ def keep(paths) ruby_file_paths = Finder.new(paths).file_paths num_threads = CodeKeeper.config.number_of_threads - return keep_legacy(ruby_file_paths, metrics, result, num_threads) if CodeKeeper.config.metrics_engine == :legacy - measurements = measure_files(ruby_file_paths, metrics, num_threads) measurements.each { |measurement| result.add_measurement(measurement) } @@ -20,53 +18,46 @@ def keep(paths) private - def keep_legacy(ruby_file_paths, metrics, result, num_threads) - if num_threads == 1 - ruby_file_paths.each do |path| - metrics.each { |metric| calculate_legacy_score(metric, path, result) } - end - else - legacy_scores = Parallel.map(ruby_file_paths, in_threads: num_threads) do |path| - metrics.each_with_object([]) do |metric, scores| - scores << [metric, ::CodeKeeper::Metrics::MAPPINGS[metric].new(path, engine: :legacy).score] - end - end - - legacy_scores.flatten(1).each do |metric, score| - add_score(metric, score, result) - end - end - - result - end - def measure_files(ruby_file_paths, metrics, num_threads) - if num_threads == 1 + 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| + parallel_map(ruby_file_paths, num_threads) do |path| measure_file(path, metrics) end.flatten end end - def measure_file(path, metrics) - source_file = Parser.source_file(path) + 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) - metrics.flat_map do |metric| - ::CodeKeeper::Metrics::MAPPINGS[metric].measure(source_file) - end - end + items.each_with_index { |item, index| jobs << [index, item] } - def calculate_legacy_score(metric, path, result) - score = ::CodeKeeper::Metrics::MAPPINGS[metric].new(path, engine: :legacy).score + threads = worker_count.times.map do + Thread.new do + loop do + begin + index, item = jobs.pop(true) + rescue ThreadError + break + end - add_score(metric, score, result) + results[index] = yield item + end + end + end + + threads.each(&:value) + results end - def add_score(metric, score, result) - score.each do |k, v| - result.add(metric, k.to_s, v) + def measure_file(path, metrics) + source_file = Parser.source_file(path) + + metrics.flat_map do |metric| + ::CodeKeeper::Metrics::MAPPINGS[metric].measure(source_file) 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/formatter_spec.rb b/spec/code_keeper/formatter_spec.rb index f156926..7c2030d 100644 --- a/spec/code_keeper/formatter_spec.rb +++ b/spec/code_keeper/formatter_spec.rb @@ -32,34 +32,6 @@ end end - context 'legacy csv format' do - before do - CodeKeeper.configure do |config| - config.format = :legacy_csv - end - end - - it 'returns a csv string' do - expect(CodeKeeper::Formatter.format(@result)).to start_with "metric,file,score\n" - end - end - - context 'legacy json format' do - before do - CodeKeeper.configure do |config| - config.format = :legacy_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 a legacy json string' do - expect(CodeKeeper::Formatter.format(@result)).to eq expected_string - end - end - context 'json format' do before do CodeKeeper.configure do |config| diff --git a/spec/code_keeper/metrics/abc_metric_spec.rb b/spec/code_keeper/metrics/abc_metric_spec.rb index 25eaf47..f45fa7d 100644 --- a/spec/code_keeper/metrics/abc_metric_spec.rb +++ b/spec/code_keeper/metrics/abc_metric_spec.rb @@ -2,16 +2,16 @@ RSpec.describe CodeKeeper::Metrics::AbcMetric do describe "#score" do - before do - CodeKeeper.configure do |config| - config.metrics_engine = :legacy - end - end - - 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 } + it 'returns RuboCop ABC values by method scope' do + source_file = CodeKeeper::Parser.source_file('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 + ) abc_metric = CodeKeeper::Metrics::AbcMetric.new('spec/fixtures/branch_in_loop.rb') - expect(abc_metric.score).to eq expected_hash + + expect(abc_metric.score).to eq('spec/fixtures/branch_in_loop.rb:two_hundred' => rubocop_value) end end @@ -23,6 +23,18 @@ expect(measurement.scope_name).to eq 'two_hundred' end + it 'matches RuboCop ABC calculation' do + source_file = CodeKeeper::Parser.source_file('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::Parser.source_file('spec/fixtures/rubocop_config/sample.rb') diff --git a/spec/code_keeper/metrics/class_length_spec.rb b/spec/code_keeper/metrics/class_length_spec.rb index 61739d5..ac082d7 100644 --- a/spec/code_keeper/metrics/class_length_spec.rb +++ b/spec/code_keeper/metrics/class_length_spec.rb @@ -2,65 +2,18 @@ RSpec.describe CodeKeeper::Metrics::ClassLength do describe "#score" do - before do - CodeKeeper.configure do |config| - config.metrics_engine = :legacy - end - end - - # 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 - - 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 - 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 - 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 - end + it 'returns RuboCop class length values by class scope' do + source_file = CodeKeeper::Parser.source_file('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 + class_length = CodeKeeper::Metrics::ClassLength.new('spec/fixtures/class_samples/simple_class.rb') - 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 + expect(class_length.score).to eq('SimpleClass' => rubocop_value) end end @@ -72,11 +25,18 @@ expect(measurement.scope_name).to eq 'SimpleClass' end - it 'uses RuboCop-style code length calculation' do + it 'matches RuboCop code length calculation' do source_file = CodeKeeper::Parser.source_file('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 3 + expect(measurement.value).to eq rubocop_value end end end diff --git a/spec/code_keeper/metrics/cyclomatic_complexity_spec.rb b/spec/code_keeper/metrics/cyclomatic_complexity_spec.rb index 3dc2ca6..3553013 100644 --- a/spec/code_keeper/metrics/cyclomatic_complexity_spec.rb +++ b/spec/code_keeper/metrics/cyclomatic_complexity_spec.rb @@ -2,25 +2,28 @@ RSpec.describe CodeKeeper::Metrics::CyclomaticComplexity do describe "#score" do - before do - CodeKeeper.configure do |config| - config.metrics_engine = :legacy - end - end - - it 'returns a hash with score of a file' do - expected_hash = { 'spec/fixtures/branch_in_loop.rb': 2 } + it 'returns RuboCop cyclomatic complexity values by method scope' do + source_file = CodeKeeper::Parser.source_file('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) complexity = CodeKeeper::Metrics::CyclomaticComplexity.new('spec/fixtures/branch_in_loop.rb') - expect(complexity.score).to eq expected_hash + + expect(complexity.score).to eq( + 'spec/fixtures/branch_in_loop.rb:two_hundred' => rubocop_cop.send(:complexity, method_node.body) + ) end end describe '.measure' do - it 'returns RuboCop-style method complexity' do + it 'matches RuboCop cyclomatic complexity calculation' do source_file = CodeKeeper::Parser.source_file('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 3 + expect(measurement.value).to eq rubocop_cop.send(:complexity, method_node.body) end it 'does not suppress measurements with RuboCop comments or config' do diff --git a/spec/code_keeper/scorer_spec.rb b/spec/code_keeper/scorer_spec.rb index a99e99c..16aae68 100644 --- a/spec/code_keeper/scorer_spec.rb +++ b/spec/code_keeper/scorer_spec.rb @@ -6,7 +6,7 @@ expect(CodeKeeper::Scorer.keep(['./spec/fixtures/branch_in_loop.rb'])).to be_a CodeKeeper::Result end - it 'stores measurements with the standard engine' do + it 'stores metric measurements' do CodeKeeper.configure do |config| config.metrics = [:cyclomatic_complexity] end @@ -14,13 +14,20 @@ expect(CodeKeeper::Scorer.keep(['./spec/fixtures/branch_in_loop.rb']).snapshot.measurements.size).to eq 1 end - it 'stores legacy scores with the legacy engine' do + it 'stores parallel measurements in input order' do CodeKeeper.configure do |config| config.metrics = [:cyclomatic_complexity] - config.metrics_engine = :legacy + config.number_of_threads = 2 end - expect(CodeKeeper::Scorer.keep(['./spec/fixtures/branch_in_loop.rb']).scores[:cyclomatic_complexity].values).to eq [2] + result = CodeKeeper::Scorer.keep(['./spec/fixtures/branch_in_loop.rb', './spec/fixtures/target_sample.rb']) + + expect(result.snapshot.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_spec.rb b/spec/code_keeper_spec.rb index dcc9fbf..e9dcc08 100644 --- a/spec/code_keeper_spec.rb +++ b/spec/code_keeper_spec.rb @@ -9,9 +9,5 @@ it 'set the default value to metrics' do expect(CodeKeeper.configure { |config| config }).to be_a CodeKeeper::Config end - - it 'sets rubocop standard engine by default' do - expect(CodeKeeper.config.metrics_engine).to eq :rubocop_standard - end end end From 9b11bb284249aa7357628217d69733d149549c5b Mon Sep 17 00:00:00 2001 From: ebihara99999 Date: Thu, 25 Jun 2026 09:37:04 +0900 Subject: [PATCH 05/15] style: use hash value omission in measurement --- lib/code_keeper/measurement.rb | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/code_keeper/measurement.rb b/lib/code_keeper/measurement.rb index 80d2f70..8a47bcf 100644 --- a/lib/code_keeper/measurement.rb +++ b/lib/code_keeper/measurement.rb @@ -23,13 +23,13 @@ def score_key def to_h { - metric: metric, - scope_type: scope_type, - scope_name: scope_name, - path: path, - start_line: start_line, - end_line: end_line, - value: value + metric:, + scope_type:, + scope_name:, + path:, + start_line:, + end_line:, + value: } end end From 87b866d84c657acf9f9cb002e10fcfbb046a38d0 Mon Sep 17 00:00:00 2001 From: ebihara99999 Date: Thu, 25 Jun 2026 09:39:00 +0900 Subject: [PATCH 06/15] docs: remove unreleased changelog entry --- CHANGELOG.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c693968..439b3bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,4 @@ # Change log -## 1.0.0 (Unreleased) -### Changes -- Make JSON output a metric snapshot with summaries and measurements. -- Measure metrics at their natural scope instead of treating files as the primary scope. -- Use RuboCop metric calculation behavior through CodeKeeper adapters. -- Remove legacy metric engine and legacy output format aliases. -- Remove direct `parallel` and `rubocop-ast` runtime dependencies. -- Support Ruby 3.3 and later. - ## 0.6.2 (2025-01-11) ### Changes - [Support Ruby 3.4.](https://github.com/ebihara99999/code_keeper/pull/38) From b0fdb01fa11ba45beb6e965177d8f01c8a746816 Mon Sep 17 00:00:00 2001 From: ebihara99999 Date: Thu, 25 Jun 2026 09:49:56 +0900 Subject: [PATCH 07/15] refactor: rename snapshot to metric report Use MetricReport for the full metrics output object while keeping summary as the derived aggregate section. --- README.md | 12 ++++++------ lib/code_keeper.rb | 2 +- lib/code_keeper/formatter.rb | 2 +- lib/code_keeper/{snapshot.rb => metric_report.rb} | 2 +- lib/code_keeper/result.rb | 6 +++--- spec/code_keeper/cli_spec.rb | 2 +- spec/code_keeper/formatter_spec.rb | 2 +- .../{snapshot_spec.rb => metric_report_spec.rb} | 8 ++++---- spec/code_keeper/result_spec.rb | 4 ++-- spec/code_keeper/scorer_spec.rb | 4 ++-- 10 files changed, 22 insertions(+), 22 deletions(-) rename lib/code_keeper/{snapshot.rb => metric_report.rb} (97%) rename spec/code_keeper/{snapshot_spec.rb => metric_report_spec.rb} (63%) diff --git a/README.md b/README.md index 665c312..442a373 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # CodeKeeper -CodeKeeper emits Ruby code metric snapshots for periodic code quality reviews. +CodeKeeper emits Ruby code metric reports for periodic code quality reviews. 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. -The intended use is recurring code quality review. Run CodeKeeper, hand the structured snapshot to a human or AI agent, and use the metric values as review signals. +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 @@ -40,7 +40,7 @@ Or install it yourself as: $ gem install code_keeper ## Usage -Run CodeKeeper and you get a metric snapshot from stdout. +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 @@ -108,7 +108,7 @@ end ### Output formats -The default `json` format returns the new snapshot schema. +The default `json` format returns the metric report schema. ```rb CodeKeeper.configure do |config| @@ -123,14 +123,14 @@ end 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 snapshot. +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 snapshot. +Analyze this CodeKeeper JSON metric report. Focus on: - the highest ABC and cyclomatic complexity measurements diff --git a/lib/code_keeper.rb b/lib/code_keeper.rb index 4ccd423..032e6cf 100644 --- a/lib/code_keeper.rb +++ b/lib/code_keeper.rb @@ -4,7 +4,7 @@ require "rubocop" require 'code_keeper/source_file' require 'code_keeper/measurement' -require 'code_keeper/snapshot' +require 'code_keeper/metric_report' require 'code_keeper/parser' require 'code_keeper/finder' require 'code_keeper/cli' diff --git a/lib/code_keeper/formatter.rb b/lib/code_keeper/formatter.rb index b90771e..0c01361 100644 --- a/lib/code_keeper/formatter.rb +++ b/lib/code_keeper/formatter.rb @@ -10,7 +10,7 @@ class << self def format(result) case CodeKeeper.config.format when :json - result.snapshot.to_h.to_json + result.metric_report.to_h.to_json when :csv csv(result) end diff --git a/lib/code_keeper/snapshot.rb b/lib/code_keeper/metric_report.rb similarity index 97% rename from lib/code_keeper/snapshot.rb rename to lib/code_keeper/metric_report.rb index 8934fc0..cc59743 100644 --- a/lib/code_keeper/snapshot.rb +++ b/lib/code_keeper/metric_report.rb @@ -2,7 +2,7 @@ module CodeKeeper # Stores metric-native measurements and derives a compact review summary. - class Snapshot + class MetricReport attr_reader :measurements def initialize(measurements = []) diff --git a/lib/code_keeper/result.rb b/lib/code_keeper/result.rb index 8cc7760..e1a6e46 100644 --- a/lib/code_keeper/result.rb +++ b/lib/code_keeper/result.rb @@ -3,11 +3,11 @@ module CodeKeeper # Store results of each score. class Result - attr_reader :scores, :snapshot + attr_reader :scores, :metric_report def initialize @scores = CodeKeeper.config.metrics.map { |key| [key, {}] }.to_h - @snapshot = Snapshot.new + @metric_report = MetricReport.new end def add(metric, klass_or_path, score) @@ -15,7 +15,7 @@ def add(metric, klass_or_path, score) end def add_measurement(measurement) - snapshot.add(measurement) + metric_report.add(measurement) add(measurement.metric, measurement.score_key, measurement.value) end end diff --git a/spec/code_keeper/cli_spec.rb b/spec/code_keeper/cli_spec.rb index ca6cc9c..0fab18d 100644 --- a/spec/code_keeper/cli_spec.rb +++ b/spec/code_keeper/cli_spec.rb @@ -20,7 +20,7 @@ def run_silently(paths) end context 'normal cases' do - it 'outputs snapshot to stdout' do + it 'outputs metric report to stdout' do expect do CodeKeeper::Cli.run(['./spec/fixtures/branch_in_loop.rb']) end.to output(/"summary"/).to_stdout diff --git a/spec/code_keeper/formatter_spec.rb b/spec/code_keeper/formatter_spec.rb index 7c2030d..a300ff5 100644 --- a/spec/code_keeper/formatter_spec.rb +++ b/spec/code_keeper/formatter_spec.rb @@ -39,7 +39,7 @@ end end - it 'returns snapshot json' do + it 'returns metric report json' do @result.add_measurement( CodeKeeper::Measurement.new( metric: :cyclomatic_complexity, diff --git a/spec/code_keeper/snapshot_spec.rb b/spec/code_keeper/metric_report_spec.rb similarity index 63% rename from spec/code_keeper/snapshot_spec.rb rename to spec/code_keeper/metric_report_spec.rb index 7f18eeb..3e3c8ca 100644 --- a/spec/code_keeper/snapshot_spec.rb +++ b/spec/code_keeper/metric_report_spec.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true -RSpec.describe CodeKeeper::Snapshot do +RSpec.describe CodeKeeper::MetricReport do describe '#to_h' do it 'summarizes measurements by metric' do - snapshot = CodeKeeper::Snapshot.new - snapshot.add( + metric_report = CodeKeeper::MetricReport.new + metric_report.add( CodeKeeper::Measurement.new( metric: :abc_metric, scope_type: :method, @@ -16,7 +16,7 @@ ) ) - expect(snapshot.to_h[:summary][:metrics][:abc_metric][:max]).to eq 2.0 + expect(metric_report.to_h[:summary][:metrics][:abc_metric][:max]).to eq 2.0 end end end diff --git a/spec/code_keeper/result_spec.rb b/spec/code_keeper/result_spec.rb index 406149b..e9e14da 100644 --- a/spec/code_keeper/result_spec.rb +++ b/spec/code_keeper/result_spec.rb @@ -25,7 +25,7 @@ end describe '#add_measurement' do - it 'stores measurement in snapshot' do + it 'stores measurement in the metric report' do result = CodeKeeper::Result.new measurement = CodeKeeper::Measurement.new( metric: :abc_metric, @@ -39,7 +39,7 @@ result.add_measurement(measurement) - expect(result.snapshot.measurements).to eq [measurement] + expect(result.metric_report.measurements).to eq [measurement] end end end diff --git a/spec/code_keeper/scorer_spec.rb b/spec/code_keeper/scorer_spec.rb index 16aae68..e1d75ee 100644 --- a/spec/code_keeper/scorer_spec.rb +++ b/spec/code_keeper/scorer_spec.rb @@ -11,7 +11,7 @@ config.metrics = [:cyclomatic_complexity] end - expect(CodeKeeper::Scorer.keep(['./spec/fixtures/branch_in_loop.rb']).snapshot.measurements.size).to eq 1 + expect(CodeKeeper::Scorer.keep(['./spec/fixtures/branch_in_loop.rb']).metric_report.measurements.size).to eq 1 end it 'stores parallel measurements in input order' do @@ -22,7 +22,7 @@ result = CodeKeeper::Scorer.keep(['./spec/fixtures/branch_in_loop.rb', './spec/fixtures/target_sample.rb']) - expect(result.snapshot.measurements.map { |measurement| [measurement.path, measurement.scope_name] }).to eq( + expect(result.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'] From fb0516c025b4781f004df3d306319eaa1281aacd Mon Sep 17 00:00:00 2001 From: ebihara99999 Date: Fri, 3 Jul 2026 15:30:13 +0900 Subject: [PATCH 08/15] refactor: keep metric parsing at source boundaries Require metric classes to receive SourceFile instances and document that RuboCop output is threshold-gated and policy-filtered. --- README.md | 2 ++ lib/code_keeper/metrics/abc_metric.rb | 4 ++-- lib/code_keeper/metrics/class_length.rb | 4 ++-- lib/code_keeper/metrics/cyclomatic_complexity.rb | 4 ++-- spec/code_keeper/metrics/abc_metric_spec.rb | 4 ++-- spec/code_keeper/metrics/class_length_spec.rb | 2 +- spec/code_keeper/metrics/cyclomatic_complexity_spec.rb | 4 ++-- 7 files changed, 13 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 442a373..9563860 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ CodeKeeper uses RuboCop as a Ruby analysis foundation, not as an offense policy - 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: diff --git a/lib/code_keeper/metrics/abc_metric.rb b/lib/code_keeper/metrics/abc_metric.rb index e0c99bf..e8eede3 100644 --- a/lib/code_keeper/metrics/abc_metric.rb +++ b/lib/code_keeper/metrics/abc_metric.rb @@ -8,8 +8,8 @@ def self.measure(source_file) new(source_file).measure end - def initialize(source_or_path) - @source_file = source_or_path.is_a?(SourceFile) ? source_or_path : Parser.source_file(source_or_path) + def initialize(source_file) + @source_file = source_file @path = @source_file.path @body = @source_file.ast end diff --git a/lib/code_keeper/metrics/class_length.rb b/lib/code_keeper/metrics/class_length.rb index f1b0073..0b4d806 100644 --- a/lib/code_keeper/metrics/class_length.rb +++ b/lib/code_keeper/metrics/class_length.rb @@ -8,8 +8,8 @@ def self.measure(source_file) new(source_file).measure end - def initialize(source_or_path) - @source_file = source_or_path.is_a?(SourceFile) ? source_or_path : Parser.source_file(source_or_path) + def initialize(source_file) + @source_file = source_file @path = @source_file.path @ps = @source_file.processed_source @body = @source_file.ast diff --git a/lib/code_keeper/metrics/cyclomatic_complexity.rb b/lib/code_keeper/metrics/cyclomatic_complexity.rb index 510cbf8..a84ac49 100644 --- a/lib/code_keeper/metrics/cyclomatic_complexity.rb +++ b/lib/code_keeper/metrics/cyclomatic_complexity.rb @@ -11,8 +11,8 @@ def self.measure(source_file) new(source_file).measure end - def initialize(source_or_path) - @source_file = source_or_path.is_a?(SourceFile) ? source_or_path : Parser.source_file(source_or_path) + def initialize(source_file) + @source_file = source_file @path = @source_file.path @body = @source_file.ast end diff --git a/spec/code_keeper/metrics/abc_metric_spec.rb b/spec/code_keeper/metrics/abc_metric_spec.rb index f45fa7d..ff92468 100644 --- a/spec/code_keeper/metrics/abc_metric_spec.rb +++ b/spec/code_keeper/metrics/abc_metric_spec.rb @@ -9,7 +9,7 @@ method_node.body, discount_repeated_attributes: false ) - abc_metric = CodeKeeper::Metrics::AbcMetric.new('spec/fixtures/branch_in_loop.rb') + abc_metric = CodeKeeper::Metrics::AbcMetric.new(source_file) expect(abc_metric.score).to eq('spec/fixtures/branch_in_loop.rb:two_hundred' => rubocop_value) end @@ -38,7 +38,7 @@ it 'does not suppress measurements with RuboCop comments or config' do source_file = CodeKeeper::Parser.source_file('spec/fixtures/rubocop_config/sample.rb') - expect(CodeKeeper::Metrics::AbcMetric.measure(source_file).size).to eq 1 + 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 ac082d7..06609eb 100644 --- a/spec/code_keeper/metrics/class_length_spec.rb +++ b/spec/code_keeper/metrics/class_length_spec.rb @@ -11,7 +11,7 @@ count_comments: false, foldable_types: [] ).calculate - class_length = CodeKeeper::Metrics::ClassLength.new('spec/fixtures/class_samples/simple_class.rb') + class_length = CodeKeeper::Metrics::ClassLength.new(source_file) expect(class_length.score).to eq('SimpleClass' => rubocop_value) end diff --git a/spec/code_keeper/metrics/cyclomatic_complexity_spec.rb b/spec/code_keeper/metrics/cyclomatic_complexity_spec.rb index 3553013..4f9d63c 100644 --- a/spec/code_keeper/metrics/cyclomatic_complexity_spec.rb +++ b/spec/code_keeper/metrics/cyclomatic_complexity_spec.rb @@ -7,7 +7,7 @@ method_node = source_file.ast.each_node(:def).first rubocop_cop = RuboCop::Cop::Metrics::CyclomaticComplexity.new rubocop_cop.send(:reset_repeated_csend) - complexity = CodeKeeper::Metrics::CyclomaticComplexity.new('spec/fixtures/branch_in_loop.rb') + complexity = CodeKeeper::Metrics::CyclomaticComplexity.new(source_file) expect(complexity.score).to eq( 'spec/fixtures/branch_in_loop.rb:two_hundred' => rubocop_cop.send(:complexity, method_node.body) @@ -29,7 +29,7 @@ it 'does not suppress measurements with RuboCop comments or config' do source_file = CodeKeeper::Parser.source_file('spec/fixtures/rubocop_config/sample.rb') - expect(CodeKeeper::Metrics::CyclomaticComplexity.measure(source_file).size).to eq 1 + expect(CodeKeeper::Metrics::CyclomaticComplexity.measure(source_file).map(&:scope_name)).to eq ['ConfigIgnoredSample#complex_method'] end end end From a0c1ecedcceaaaa34f8a290c29ac3f7389110955 Mon Sep 17 00:00:00 2001 From: ebihara99999 Date: Fri, 3 Jul 2026 22:19:36 +0900 Subject: [PATCH 09/15] update: make top hotspots deterministic Limit each metric summary to the top five measurements by value and make tie ordering stable for repeatable reports. --- README.md | 2 +- lib/code_keeper/metric_report.rb | 11 +++++++- spec/code_keeper/metric_report_spec.rb | 36 ++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9563860..c648837 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ $ cat metrics.json } ``` -The `summary` section is intended for quick review. The `measurements` section contains the metric-native values that support deeper analysis. +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. ### Run CodeKeeper To measure metrics of all the ruby files recursively in the current directory, run diff --git a/lib/code_keeper/metric_report.rb b/lib/code_keeper/metric_report.rb index cc59743..550abac 100644 --- a/lib/code_keeper/metric_report.rb +++ b/lib/code_keeper/metric_report.rb @@ -3,6 +3,8 @@ module CodeKeeper # Stores metric-native measurements and derives a compact review summary. class MetricReport + TOP_HOTSPOTS_LIMIT = 5 + attr_reader :measurements def initialize(measurements = []) @@ -31,9 +33,16 @@ def summary_by_metric { count: group.size, max: max_value, - top_hotspots: group.select { |measurement| measurement.value == max_value }.map(&:to_h) + 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/spec/code_keeper/metric_report_spec.rb b/spec/code_keeper/metric_report_spec.rb index 3e3c8ca..8375926 100644 --- a/spec/code_keeper/metric_report_spec.rb +++ b/spec/code_keeper/metric_report_spec.rb @@ -18,5 +18,41 @@ 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 and start line' 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#a') + ] + ) + + expect(metric_report.to_h[:summary][:metrics][:abc_metric][:top_hotspots].map { |hotspot| [hotspot[:path], hotspot[:start_line]] }).to eq( + [['a.rb', 1], ['a.rb', 2], ['b.rb', 1]] + ) + 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 From 1d260785812aa798cd525279697cbff74d7e702f Mon Sep 17 00:00:00 2001 From: ebihara99999 Date: Fri, 3 Jul 2026 22:32:03 +0900 Subject: [PATCH 10/15] fix: declare csv runtime dependency Add csv as a runtime dependency and align hotspot tie-break tests with the documented ordering. --- Gemfile.lock | 1 + code_keeper.gemspec | 1 + spec/code_keeper/metric_report_spec.rb | 14 +++++++++++--- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 2ed56a5..893082f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,6 +2,7 @@ PATH remote: . specs: code_keeper (1.0.0) + csv rubocop (>= 1.88.0) GEM diff --git a/code_keeper.gemspec b/code_keeper.gemspec index acf7d38..ea9cfd4 100644 --- a/code_keeper.gemspec +++ b/code_keeper.gemspec @@ -28,6 +28,7 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] # Uncomment to register a new dependency of your gem + spec.add_dependency "csv" spec.add_dependency "rubocop", '>= 1.88.0' # For more information and examples about making a new gem, checkout our diff --git a/spec/code_keeper/metric_report_spec.rb b/spec/code_keeper/metric_report_spec.rb index 8375926..9852a13 100644 --- a/spec/code_keeper/metric_report_spec.rb +++ b/spec/code_keeper/metric_report_spec.rb @@ -29,17 +29,25 @@ 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 and start line' do + 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 { |hotspot| [hotspot[:path], hotspot[:start_line]] }).to eq( - [['a.rb', 1], ['a.rb', 2], ['b.rb', 1]] + 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 From e257e659a9087e59172e5985981d5eeb2760e26d Mon Sep 17 00:00:00 2001 From: ebihara99999 Date: Fri, 3 Jul 2026 22:44:06 +0900 Subject: [PATCH 11/15] chore: remove redundant csv Gemfile entry The gemspec now declares csv as a runtime dependency, which the gemspec directive already brings into the bundle on every Ruby version, so the conditional entry duplicated it. Co-Authored-By: Claude Fable 5 --- Gemfile | 2 -- Gemfile.lock | 1 - lib/code_keeper/parser.rb | 24 -------------------- spec/code_keeper/parser_spec.rb | 40 --------------------------------- 4 files changed, 67 deletions(-) delete mode 100644 lib/code_keeper/parser.rb delete mode 100644 spec/code_keeper/parser_spec.rb 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 893082f..d091e7d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -60,7 +60,6 @@ PLATFORMS DEPENDENCIES code_keeper! - csv rake (~> 13.0) rspec (~> 3.0) diff --git a/lib/code_keeper/parser.rb b/lib/code_keeper/parser.rb deleted file mode 100644 index bd91aaf..0000000 --- a/lib/code_keeper/parser.rb +++ /dev/null @@ -1,24 +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 = SourceFile.new(file_path) - @processed_source = @source_file.processed_source - end - - class << self - def parse(file_path) - parser = new(file_path) - parser.processed_source - end - - def source_file(file_path) - SourceFile.new(file_path) - end - end - end -end diff --git a/spec/code_keeper/parser_spec.rb b/spec/code_keeper/parser_spec.rb deleted file mode 100644 index 8387d21..0000000 --- a/spec/code_keeper/parser_spec.rb +++ /dev/null @@ -1,40 +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 - - describe '.source_file' do - it 'returns CodeKeeper::SourceFile instance' do - expect(CodeKeeper::Parser.source_file('./spec/fixtures/target_sample.rb')).to be_a(CodeKeeper::SourceFile) - end - end -end From 39bdca15006adcc0a8b0c78b50bac699fe7b0384 Mon Sep 17 00:00:00 2001 From: ebihara99999 Date: Fri, 3 Jul 2026 22:44:06 +0900 Subject: [PATCH 12/15] refactor: remove legacy parser and score interfaces Drop the Parser facade and the per-metric score hash interface, which predate the metric report model. SourceFile is now the single parsing boundary and Measurement the only metric output. Also remove RuboCop mixins and a version fallback made unreachable by the centralized calculator and the rubocop >= 1.88 requirement, and move file loading coverage from the parser specs to the SourceFile specs. Co-Authored-By: Claude Fable 5 --- lib/code_keeper.rb | 1 - lib/code_keeper/metrics/abc_metric.rb | 4 ---- lib/code_keeper/metrics/class_length.rb | 4 ---- .../metrics/cyclomatic_complexity.rb | 7 ------- .../metrics/rubocop_metric_calculator.rb | 12 ++++------- lib/code_keeper/scorer.rb | 2 +- spec/code_keeper/metrics/abc_metric_spec.rb | 20 +++---------------- spec/code_keeper/metrics/class_length_spec.rb | 20 ++----------------- .../metrics/cyclomatic_complexity_spec.rb | 18 ++--------------- spec/code_keeper/source_file_spec.rb | 19 ++++++++++++++++++ 10 files changed, 31 insertions(+), 76 deletions(-) diff --git a/lib/code_keeper.rb b/lib/code_keeper.rb index 032e6cf..7da762f 100644 --- a/lib/code_keeper.rb +++ b/lib/code_keeper.rb @@ -5,7 +5,6 @@ require 'code_keeper/source_file' require 'code_keeper/measurement' require 'code_keeper/metric_report' -require 'code_keeper/parser' require 'code_keeper/finder' require 'code_keeper/cli' require 'code_keeper/formatter' diff --git a/lib/code_keeper/metrics/abc_metric.rb b/lib/code_keeper/metrics/abc_metric.rb index e8eede3..02eab7e 100644 --- a/lib/code_keeper/metrics/abc_metric.rb +++ b/lib/code_keeper/metrics/abc_metric.rb @@ -14,10 +14,6 @@ def initialize(source_file) @body = @source_file.ast end - def score - measure.to_h { |measurement| [measurement.score_key, measurement.value] } - end - def measure return [] unless @body diff --git a/lib/code_keeper/metrics/class_length.rb b/lib/code_keeper/metrics/class_length.rb index 0b4d806..e26966c 100644 --- a/lib/code_keeper/metrics/class_length.rb +++ b/lib/code_keeper/metrics/class_length.rb @@ -15,10 +15,6 @@ def initialize(source_file) @body = @source_file.ast end - def score - measure.to_h { |measurement| [measurement.score_key, measurement.value] } - end - def measure return [] unless @body diff --git a/lib/code_keeper/metrics/cyclomatic_complexity.rb b/lib/code_keeper/metrics/cyclomatic_complexity.rb index a84ac49..4888c87 100644 --- a/lib/code_keeper/metrics/cyclomatic_complexity.rb +++ b/lib/code_keeper/metrics/cyclomatic_complexity.rb @@ -4,9 +4,6 @@ module CodeKeeper module Metrics # 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 @@ -17,10 +14,6 @@ def initialize(source_file) @body = @source_file.ast end - def score - measure.to_h { |measurement| [measurement.score_key, measurement.value] } - end - def measure return [] unless @body diff --git a/lib/code_keeper/metrics/rubocop_metric_calculator.rb b/lib/code_keeper/metrics/rubocop_metric_calculator.rb index 26bbb3d..a662a7c 100644 --- a/lib/code_keeper/metrics/rubocop_metric_calculator.rb +++ b/lib/code_keeper/metrics/rubocop_metric_calculator.rb @@ -9,10 +9,10 @@ module RuboCopMetricCalculator def abc_size(node) return 0 unless node - value, = abc_calculator.calculate(node, discount_repeated_attributes: false) - value - rescue ArgumentError - value, = abc_calculator.calculate(node) + value, = ::RuboCop::Cop::Metrics::Utils::AbcSizeCalculator.calculate( + node, + discount_repeated_attributes: false + ) value end @@ -32,10 +32,6 @@ def class_length(node, processed_source) foldable_types: [] ).calculate end - - def abc_calculator - ::RuboCop::Cop::Metrics::Utils::AbcSizeCalculator - end end end end diff --git a/lib/code_keeper/scorer.rb b/lib/code_keeper/scorer.rb index f6d2711..5a53ce1 100644 --- a/lib/code_keeper/scorer.rb +++ b/lib/code_keeper/scorer.rb @@ -54,7 +54,7 @@ def parallel_map(items, num_threads) end def measure_file(path, metrics) - source_file = Parser.source_file(path) + source_file = SourceFile.new(path) metrics.flat_map do |metric| ::CodeKeeper::Metrics::MAPPINGS[metric].measure(source_file) diff --git a/spec/code_keeper/metrics/abc_metric_spec.rb b/spec/code_keeper/metrics/abc_metric_spec.rb index ff92468..261f882 100644 --- a/spec/code_keeper/metrics/abc_metric_spec.rb +++ b/spec/code_keeper/metrics/abc_metric_spec.rb @@ -1,30 +1,16 @@ # frozen_string_literal: true RSpec.describe CodeKeeper::Metrics::AbcMetric do - describe "#score" do - it 'returns RuboCop ABC values by method scope' do - source_file = CodeKeeper::Parser.source_file('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 - ) - abc_metric = CodeKeeper::Metrics::AbcMetric.new(source_file) - - expect(abc_metric.score).to eq('spec/fixtures/branch_in_loop.rb:two_hundred' => rubocop_value) - end - end - describe '.measure' do it 'returns measurements by method scope' do - source_file = CodeKeeper::Parser.source_file('spec/fixtures/branch_in_loop.rb') + 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::Parser.source_file('spec/fixtures/branch_in_loop.rb') + 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, @@ -36,7 +22,7 @@ end it 'does not suppress measurements with RuboCop comments or config' do - source_file = CodeKeeper::Parser.source_file('spec/fixtures/rubocop_config/sample.rb') + 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 diff --git a/spec/code_keeper/metrics/class_length_spec.rb b/spec/code_keeper/metrics/class_length_spec.rb index 06609eb..47b12df 100644 --- a/spec/code_keeper/metrics/class_length_spec.rb +++ b/spec/code_keeper/metrics/class_length_spec.rb @@ -1,32 +1,16 @@ # frozen_string_literal: true RSpec.describe CodeKeeper::Metrics::ClassLength do - describe "#score" do - it 'returns RuboCop class length values by class scope' do - source_file = CodeKeeper::Parser.source_file('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 - class_length = CodeKeeper::Metrics::ClassLength.new(source_file) - - expect(class_length.score).to eq('SimpleClass' => rubocop_value) - end - end - describe '.measure' do it 'returns measurements by class scope' do - source_file = CodeKeeper::Parser.source_file('spec/fixtures/class_samples/simple_class.rb') + source_file = CodeKeeper::SourceFile.new('spec/fixtures/class_samples/simple_class.rb') measurement = CodeKeeper::Metrics::ClassLength.measure(source_file).first expect(measurement.scope_name).to eq 'SimpleClass' end it 'matches RuboCop code length calculation' do - source_file = CodeKeeper::Parser.source_file('spec/fixtures/class_samples/simple_class.rb') + 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, diff --git a/spec/code_keeper/metrics/cyclomatic_complexity_spec.rb b/spec/code_keeper/metrics/cyclomatic_complexity_spec.rb index 4f9d63c..6a05a5b 100644 --- a/spec/code_keeper/metrics/cyclomatic_complexity_spec.rb +++ b/spec/code_keeper/metrics/cyclomatic_complexity_spec.rb @@ -1,23 +1,9 @@ # frozen_string_literal: true RSpec.describe CodeKeeper::Metrics::CyclomaticComplexity do - describe "#score" do - it 'returns RuboCop cyclomatic complexity values by method scope' do - source_file = CodeKeeper::Parser.source_file('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) - complexity = CodeKeeper::Metrics::CyclomaticComplexity.new(source_file) - - expect(complexity.score).to eq( - 'spec/fixtures/branch_in_loop.rb:two_hundred' => rubocop_cop.send(:complexity, method_node.body) - ) - end - end - describe '.measure' do it 'matches RuboCop cyclomatic complexity calculation' do - source_file = CodeKeeper::Parser.source_file('spec/fixtures/branch_in_loop.rb') + 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) @@ -27,7 +13,7 @@ end it 'does not suppress measurements with RuboCop comments or config' do - source_file = CodeKeeper::Parser.source_file('spec/fixtures/rubocop_config/sample.rb') + 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 diff --git a/spec/code_keeper/source_file_spec.rb b/spec/code_keeper/source_file_spec.rb index 3cb25f7..308f23b 100644 --- a/spec/code_keeper/source_file_spec.rb +++ b/spec/code_keeper/source_file_spec.rb @@ -17,5 +17,24 @@ 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 From 3e257e6dfb5ba26040d2298fdf6874dad5180b52 Mon Sep 17 00:00:00 2001 From: ebihara99999 Date: Fri, 3 Jul 2026 23:14:20 +0900 Subject: [PATCH 13/15] docs: note deliberate method_nodes duplication AbcMetric and CyclomaticComplexity intentionally share an identical method-scope enumerator. Record that the duplication is deliberate and that a shared enumerator should be extracted when a third method-scope metric is added, so the decision survives beyond the current work. Co-Authored-By: Claude Fable 5 --- lib/code_keeper/metrics/abc_metric.rb | 3 +++ lib/code_keeper/metrics/cyclomatic_complexity.rb | 3 +++ 2 files changed, 6 insertions(+) diff --git a/lib/code_keeper/metrics/abc_metric.rb b/lib/code_keeper/metrics/abc_metric.rb index 02eab7e..d57f291 100644 --- a/lib/code_keeper/metrics/abc_metric.rb +++ b/lib/code_keeper/metrics/abc_metric.rb @@ -32,6 +32,9 @@ def measure 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) diff --git a/lib/code_keeper/metrics/cyclomatic_complexity.rb b/lib/code_keeper/metrics/cyclomatic_complexity.rb index 4888c87..349cf5b 100644 --- a/lib/code_keeper/metrics/cyclomatic_complexity.rb +++ b/lib/code_keeper/metrics/cyclomatic_complexity.rb @@ -32,6 +32,9 @@ def measure private + # 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) From 3a37e7d6d3832f40115a73937291c8ab1717f39f Mon Sep 17 00:00:00 2001 From: ebihara99999 Date: Fri, 3 Jul 2026 23:51:58 +0900 Subject: [PATCH 14/15] refactor: remove csv output for 1.0 Measurements are the source of truth, and the CSV view flattened them into metric/key/value rows that silently overwrote measurements sharing a key, such as classes reopened across files. It was also the only reason for the csv runtime dependency and the score key rules on Measurement. Tabular views can be derived from the measurements array downstream, so drop the csv format together with the format setting, the Result wrapper, and the csv dependency. Scorer now dedupes configured metrics itself, which the removed scores hash did implicitly. Co-Authored-By: Claude Fable 5 --- .rubocop.yml | 7 ---- Gemfile.lock | 2 -- README.md | 16 +-------- code_keeper.gemspec | 1 - lib/code_keeper.rb | 1 - lib/code_keeper/cli.rb | 4 +-- lib/code_keeper/config.rb | 3 +- lib/code_keeper/formatter.rb | 27 ++-------------- lib/code_keeper/measurement.rb | 6 ---- lib/code_keeper/result.rb | 22 ------------- lib/code_keeper/scorer.rb | 10 ++---- spec/code_keeper/cli_spec.rb | 1 - spec/code_keeper/formatter_spec.rb | 52 +++++------------------------- spec/code_keeper/result_spec.rb | 45 -------------------------- spec/code_keeper/scorer_spec.rb | 18 ++++++++--- 15 files changed, 31 insertions(+), 184 deletions(-) delete mode 100644 lib/code_keeper/result.rb delete mode 100644 spec/code_keeper/result_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index 65fbad3..37ec132 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -22,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: 40 - Exclude: - - lib/code_keeper/formatter.rb # because uses here doc # Because TargetFileNotFoundError needs to be passed an argument to initializer. Style/RaiseArgs: diff --git a/Gemfile.lock b/Gemfile.lock index d091e7d..3e151b2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,14 +2,12 @@ PATH remote: . specs: code_keeper (1.0.0) - csv rubocop (>= 1.88.0) GEM remote: https://rubygems.org/ specs: ast (2.4.3) - csv (3.3.2) diff-lcs (1.5.1) json (2.20.0) language_server-protocol (3.17.0.5) diff --git a/README.md b/README.md index c648837..769cf38 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ $ cat metrics.json } ``` -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. +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 @@ -103,23 +103,9 @@ 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 ``` -### Output formats - -The default `json` format returns the metric report schema. - -```rb -CodeKeeper.configure do |config| - config.format = :json -end -``` - -`config.format = :csv` returns a compatibility-oriented table derived from the same measurements. - ## Using CodeKeeper with AI review workflows CodeKeeper is designed to provide structured metric data for recurring AI-assisted reviews. A typical workflow is: diff --git a/code_keeper.gemspec b/code_keeper.gemspec index ea9cfd4..acf7d38 100644 --- a/code_keeper.gemspec +++ b/code_keeper.gemspec @@ -28,7 +28,6 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] # Uncomment to register a new dependency of your gem - spec.add_dependency "csv" spec.add_dependency "rubocop", '>= 1.88.0' # For more information and examples about making a new gem, checkout our diff --git a/lib/code_keeper.rb b/lib/code_keeper.rb index 7da762f..72ff127 100644 --- a/lib/code_keeper.rb +++ b/lib/code_keeper.rb @@ -10,7 +10,6 @@ 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' 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 0c01361..d871834 100644 --- a/lib/code_keeper/formatter.rb +++ b/lib/code_keeper/formatter.rb @@ -1,34 +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) - case CodeKeeper.config.format - when :json - result.metric_report.to_h.to_json - when :csv - csv(result) - end - end - - private - - def csv(result) - 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 index 8a47bcf..3bff9a5 100644 --- a/lib/code_keeper/measurement.rb +++ b/lib/code_keeper/measurement.rb @@ -15,12 +15,6 @@ def initialize(attributes) @value = attributes.fetch(:value) end - def score_key - return scope_name if %i[class module singleton_class].include?(scope_type) - - "#{path}:#{scope_name}" - end - def to_h { metric:, diff --git a/lib/code_keeper/result.rb b/lib/code_keeper/result.rb deleted file mode 100644 index e1a6e46..0000000 --- a/lib/code_keeper/result.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module CodeKeeper - # Store results of each score. - class Result - attr_reader :scores, :metric_report - - def initialize - @scores = CodeKeeper.config.metrics.map { |key| [key, {}] }.to_h - @metric_report = MetricReport.new - end - - def add(metric, klass_or_path, score) - scores[:"#{metric}"].store(klass_or_path, score) - end - - def add_measurement(measurement) - metric_report.add(measurement) - add(measurement.metric, measurement.score_key, measurement.value) - end - end -end diff --git a/lib/code_keeper/scorer.rb b/lib/code_keeper/scorer.rb index 5a53ce1..a1d1a3b 100644 --- a/lib/code_keeper/scorer.rb +++ b/lib/code_keeper/scorer.rb @@ -1,19 +1,15 @@ # 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 - measurements = measure_files(ruby_file_paths, metrics, num_threads) - measurements.each { |measurement| result.add_measurement(measurement) } - - result + MetricReport.new(measure_files(ruby_file_paths, metrics, num_threads)) end private diff --git a/spec/code_keeper/cli_spec.rb b/spec/code_keeper/cli_spec.rb index 0fab18d..9055621 100644 --- a/spec/code_keeper/cli_spec.rb +++ b/spec/code_keeper/cli_spec.rb @@ -15,7 +15,6 @@ def run_silently(paths) before do CodeKeeper.configure do |config| config.metrics = [:cyclomatic_complexity] - config.format = :json end end diff --git a/spec/code_keeper/formatter_spec.rb b/spec/code_keeper/formatter_spec.rb index a300ff5..a985efd 100644 --- a/spec/code_keeper/formatter_spec.rb +++ b/spec/code_keeper/formatter_spec.rb @@ -2,59 +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 - - it 'returns metric report json' do - @result.add_measurement( + 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: '/foo/bar/code_keeper/spec/fixtures/target_sample.rb', + path: './spec/fixtures/target_sample.rb', start_line: 2, end_line: 4, value: 1 ) - ) - json = JSON.parse(CodeKeeper::Formatter.format(@result)) + ] + ) + json = JSON.parse(CodeKeeper::Formatter.format(metric_report)) - expect(json.dig('summary', 'metrics', 'cyclomatic_complexity', 'max')).to eq 1 - end + expect(json.dig('summary', 'metrics', 'cyclomatic_complexity', 'max')).to eq 1 end end end diff --git a/spec/code_keeper/result_spec.rb b/spec/code_keeper/result_spec.rb deleted file mode 100644 index e9e14da..0000000 --- a/spec/code_keeper/result_spec.rb +++ /dev/null @@ -1,45 +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 - - describe '#add_measurement' do - it 'stores measurement in the metric report' do - result = CodeKeeper::Result.new - measurement = 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 - ) - - result.add_measurement(measurement) - - expect(result.metric_report.measurements).to eq [measurement] - end - end -end diff --git a/spec/code_keeper/scorer_spec.rb b/spec/code_keeper/scorer_spec.rb index e1d75ee..6624f89 100644 --- a/spec/code_keeper/scorer_spec.rb +++ b/spec/code_keeper/scorer_spec.rb @@ -2,8 +2,8 @@ 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 @@ -11,7 +11,15 @@ config.metrics = [:cyclomatic_complexity] end - expect(CodeKeeper::Scorer.keep(['./spec/fixtures/branch_in_loop.rb']).metric_report.measurements.size).to eq 1 + 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 @@ -20,9 +28,9 @@ config.number_of_threads = 2 end - result = CodeKeeper::Scorer.keep(['./spec/fixtures/branch_in_loop.rb', './spec/fixtures/target_sample.rb']) + metric_report = CodeKeeper::Scorer.keep(['./spec/fixtures/branch_in_loop.rb', './spec/fixtures/target_sample.rb']) - expect(result.metric_report.measurements.map { |measurement| [measurement.path, measurement.scope_name] }).to eq( + 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'] From ce344e659198feb7ec03deb7dbe1be23c9f6f989 Mon Sep 17 00:00:00 2001 From: ebihara99999 Date: Sat, 4 Jul 2026 12:23:46 +0900 Subject: [PATCH 15/15] fix: correct scope name resolution for singleton and nested scopes Scope names are the identity of a measurement across runs, so they must name the owner precisely. Two defects broke that. class << self and the defs inside it were reported as "class << self" and instance-style names, hiding the owner and colliding across files. Resolve self to the enclosing constant, but only when the singleton class sits directly in a class or module body; inside method bodies and blocks self is the runtime receiver and stays unresolved. defs and literal define_methods directly in a singleton class body are named as singleton methods (Owner.method), matching def self.method. owner_name qualified each ancestor recursively before joining, so nested namespaces duplicated exponentially (Gitlab::Gitlab::Ci::... for lib/gitlab/ci files). Join the raw constant sources instead. Co-Authored-By: Claude Fable 5 --- lib/code_keeper/metrics/scope_name.rb | 33 ++++++++++++++++- spec/code_keeper/metrics/class_length_spec.rb | 30 +++++++++++++++ .../metrics/cyclomatic_complexity_spec.rb | 16 ++++++++ .../fixtures/class_samples/singleton_scope.rb | 37 +++++++++++++++++++ 4 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 spec/fixtures/class_samples/singleton_scope.rb diff --git a/lib/code_keeper/metrics/scope_name.rb b/lib/code_keeper/metrics/scope_name.rb index e429d73..7ed6572 100644 --- a/lib/code_keeper/metrics/scope_name.rb +++ b/lib/code_keeper/metrics/scope_name.rb @@ -9,6 +9,9 @@ module ScopeName 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) @@ -20,7 +23,7 @@ def method_name(node) def class_name(node) case node.type when :sclass - "class << #{node.children.first.source}" + "class << #{sclass_receiver_name(node)}" else join_const_name(owner_name(node), node.children.first&.source) end @@ -49,12 +52,38 @@ 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| - class_name(ancestor) + ancestor.children.first&.source end.join('::') end diff --git a/spec/code_keeper/metrics/class_length_spec.rb b/spec/code_keeper/metrics/class_length_spec.rb index 47b12df..6918117 100644 --- a/spec/code_keeper/metrics/class_length_spec.rb +++ b/spec/code_keeper/metrics/class_length_spec.rb @@ -22,5 +22,35 @@ expect(measurement.value).to eq rubocop_value 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 + + 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 6a05a5b..77c7ff2 100644 --- a/spec/code_keeper/metrics/cyclomatic_complexity_spec.rb +++ b/spec/code_keeper/metrics/cyclomatic_complexity_spec.rb @@ -17,5 +17,21 @@ 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/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