From ee42c030c4744261e6e18f8451d4bca6bd41f24b Mon Sep 17 00:00:00 2001 From: Vladislav Trotsenko Date: Fri, 8 Nov 2024 12:10:45 +0100 Subject: [PATCH] Feature/RSpec Mock migration analytics (#7) * Added Flexmock/RSpec tracers, tests * Added FileAnalyzer, tests * Added CLI, tests * Updated gem version, changelog --- .circleci/gemspecs/compatible | 2 + .circleci/gemspecs/latest | 2 + .circleci/linter_configs/.commitspell.yml | 1 + .circleci/linter_configs/.cspell.yml | 1 + .circleci/linter_configs/.fasterer.yml | 3 + .reek.yml | 7 + CHANGELOG.md | 6 + README.md | 41 +++ lib/rspec/mock/core.rb | 11 + lib/rspec/mock/migration_analytics/cli.rb | 185 ++++++++++++++ .../mock/migration_analytics/file_analyzer.rb | 58 +++++ .../mock/migration_analytics/tracker/base.rb | 24 ++ .../migration_analytics/tracker/flexmock.rb | 155 ++++++++++++ .../mock/migration_analytics/tracker/rspec.rb | 88 +++++++ lib/rspec/mock/version.rb | 2 +- rspec-mock.gemspec | 2 + .../mock/migration_analytics/cli_spec.rb | 237 ++++++++++++++++++ .../migration_analytics/file_analyzer_spec.rb | 148 +++++++++++ .../tracker/flexmock_spec.rb | 150 +++++++++++ .../migration_analytics/tracker/rspec_spec.rb | 97 +++++++ spec/spec_helper.rb | 1 + spec/support/helpers/context_helper.rb | 14 ++ 22 files changed, 1234 insertions(+), 1 deletion(-) create mode 100755 lib/rspec/mock/migration_analytics/cli.rb create mode 100644 lib/rspec/mock/migration_analytics/file_analyzer.rb create mode 100644 lib/rspec/mock/migration_analytics/tracker/base.rb create mode 100644 lib/rspec/mock/migration_analytics/tracker/flexmock.rb create mode 100644 lib/rspec/mock/migration_analytics/tracker/rspec.rb create mode 100644 spec/rspec/mock/migration_analytics/cli_spec.rb create mode 100644 spec/rspec/mock/migration_analytics/file_analyzer_spec.rb create mode 100644 spec/rspec/mock/migration_analytics/tracker/flexmock_spec.rb create mode 100644 spec/rspec/mock/migration_analytics/tracker/rspec_spec.rb create mode 100644 spec/support/helpers/context_helper.rb diff --git a/.circleci/gemspecs/compatible b/.circleci/gemspecs/compatible index c885f3f..a589134 100644 --- a/.circleci/gemspecs/compatible +++ b/.circleci/gemspecs/compatible @@ -16,8 +16,10 @@ Gem::Specification.new do |spec| spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } spec.require_paths = %w[lib] + spec.add_runtime_dependency 'colorize', '>= 0.8.1' spec.add_runtime_dependency 'rspec-core', '~> 3.10' spec.add_runtime_dependency 'rspec-mocks', '~> 3.10' + spec.add_runtime_dependency 'terminal-table', '~> 3.0' spec.add_development_dependency 'rspec', '~> 3.13' end diff --git a/.circleci/gemspecs/latest b/.circleci/gemspecs/latest index 0766547..c90a7a9 100644 --- a/.circleci/gemspecs/latest +++ b/.circleci/gemspecs/latest @@ -16,8 +16,10 @@ Gem::Specification.new do |spec| spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } spec.require_paths = %w[lib] + spec.add_runtime_dependency 'colorize', '>= 0.8.1' spec.add_runtime_dependency 'rspec-core', '~> 3.10' spec.add_runtime_dependency 'rspec-mocks', '~> 3.10' + spec.add_runtime_dependency 'terminal-table', '~> 3.0' spec.add_development_dependency 'bundler-audit', '~> 0.9.2' spec.add_development_dependency 'fasterer', '~> 0.11.0' diff --git a/.circleci/linter_configs/.commitspell.yml b/.circleci/linter_configs/.commitspell.yml index 6be3613..d107a1b 100644 --- a/.circleci/linter_configs/.commitspell.yml +++ b/.circleci/linter_configs/.commitspell.yml @@ -13,6 +13,7 @@ languageSettings: - GithubUser words: + - Flexmock - bagage - bagages - bestwebua diff --git a/.circleci/linter_configs/.cspell.yml b/.circleci/linter_configs/.cspell.yml index c89c861..53b7273 100644 --- a/.circleci/linter_configs/.cspell.yml +++ b/.circleci/linter_configs/.cspell.yml @@ -20,6 +20,7 @@ languageSettings: words: - Commiting + - Flexmock - Trotsenko - Vladislav - bestwebua diff --git a/.circleci/linter_configs/.fasterer.yml b/.circleci/linter_configs/.fasterer.yml index 8c1976b..79681c9 100644 --- a/.circleci/linter_configs/.fasterer.yml +++ b/.circleci/linter_configs/.fasterer.yml @@ -2,3 +2,6 @@ exclude_paths: - '.circleci/**/*.rb' + +speedups: + each_with_index_vs_while: false diff --git a/.reek.yml b/.reek.yml index 86e855b..2f39a90 100644 --- a/.reek.yml +++ b/.reek.yml @@ -8,7 +8,14 @@ detectors: exclude: - RSpec::Mock::Context#respond_to_missing? + UtilityFunction: + exclude: + - ContextHelper#create_file + ManualDispatch: exclude: - RSpec::Mock::Context#method_missing - RSpec::Mock::Context#respond_to_missing? + +exclude_paths: + - lib/rspec/mock/migration_analytics/ diff --git a/CHANGELOG.md b/CHANGELOG.md index bf95ee9..4baaf95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.0] - 2024-11-08 + +### Added + +- Added CLI to analyze Flexmock usage and track migration progress to RSpec mocks. + ## [0.2.0] - 2024-11-04 ### Added diff --git a/README.md b/README.md index 0e28115..1c1318e 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ - [Usage](#usage) - [Configuration](#configuration) - [Integration](#integration) + - [Migration Analytics](#migration-analytics) - [Contributing](#contributing) - [License](#license) - [Code of Conduct](#code-of-conduct) @@ -127,6 +128,46 @@ RSpec.describe Sandbox do end ``` +### Migration Analytics + +You can create a Rake task to analyze Flexmock usage and track migration progress to RSpec mocks. Or use the CLI directly. + +Example of the Rake task: + +```ruby +namespace :rspec_mock do + namespace :migration_analytics do + desc 'Analyze Flexmock usage and track migration progress to RSpec mocks' + task :flexmock, %i[path] do |_, args| + require 'rspec/mock/migration_analytics/cli' + + path = args[:path] || 'spec' + puts("\nšŸ” Analyzing Flexmock usage in: #{path}") + RSpec::Mock::MigrationAnalytics::Cli.verify_path(path) + end + end +end +``` + +```bash +# Analyze entire spec directory (default) +rake rspec_mock:migration_analytics:flexmock + +# Analyze specific directory +rake rspec_mock:migration_analytics:flexmock spec/services + +# Analyze specific file +rake rspec_mock:migration_analytics:flexmock spec/services/sandbox_service_spec.rb +``` + +Example of the CLI usage: + +```bash +ruby cli.rb spec +ruby cli.rb spec/services +ruby cli.rb spec/services/sandbox_service_spec.rb +``` + ## Contributing Bug reports and pull requests are welcome on GitHub at . This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. Please check the [open tickets](https://github.com/mocktools/ruby-rspec-mock/issues). Be sure to follow Contributor Code of Conduct below and our [Contributing Guidelines](CONTRIBUTING.md). diff --git a/lib/rspec/mock/core.rb b/lib/rspec/mock/core.rb index 1a5b0ee..59f0084 100644 --- a/lib/rspec/mock/core.rb +++ b/lib/rspec/mock/core.rb @@ -5,6 +5,17 @@ module RSpec module Mock + module MigrationAnalytics + module Tracker + require_relative 'migration_analytics/tracker/base' + require_relative 'migration_analytics/tracker/flexmock' + require_relative 'migration_analytics/tracker/rspec' + end + + require_relative 'migration_analytics/file_analyzer' + require_relative 'migration_analytics/cli' + end + require_relative 'configuration' require_relative 'context' require_relative 'methods' diff --git a/lib/rspec/mock/migration_analytics/cli.rb b/lib/rspec/mock/migration_analytics/cli.rb new file mode 100755 index 0000000..b901bc5 --- /dev/null +++ b/lib/rspec/mock/migration_analytics/cli.rb @@ -0,0 +1,185 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'colorize' +require 'terminal-table' + +module RSpec + module Mock + module MigrationAnalytics + class Cli + class << self + def call + if ::ARGV.empty? + print_usage + exit 1 + end + + begin + verify_path(::ARGV[0]) + rescue => error + puts("\nāŒ Error: #{error.message}".red) + puts(error.backtrace) if ENV['DEBUG'] + end + end + + def verify_path(path) + case + when ::File.directory?(path) then verify_directory(path) + else verify_file(path) + end + end + + private + + def print_usage + puts('Usage: ruby cli.rb '.yellow) + puts("\nExamples:".blue) + puts(' ruby cli.rb spec/models/user_spec.rb') + puts(' ruby cli.rb spec/models/') + puts(' ruby cli.rb spec/') + end + + def verify_directory(dir_path) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + results = [] + stats = { + total_files: 0, + files_with_mocks: 0, + total_flexmock_occurrences: 0, + total_rspec_mock_occurrences: 0, + files_with_mixed_usage: 0 + } + + ::Dir.glob("#{dir_path}/**/*_spec.rb").each do |file| + stats[:total_files] += 1 + result = RSpec::Mock::MigrationAnalytics::FileAnalyzer.call(file) + + next unless result[:has_mocks] + stats[:files_with_mocks] += 1 + stats[:total_flexmock_occurrences] += result[:flexmock_count] + stats[:total_rspec_mock_occurrences] += result[:rspec_mock_count] + stats[:files_with_mixed_usage] += 1 if result[:has_mixed_usage] + results << result + end + + print_summary(results, stats) + end + + def verify_file(file_path) + return puts("File not found: #{file_path}".red) unless ::File.exist?(file_path) + return puts("Not a Ruby spec file: #{file_path}".yellow) unless file_path.end_with?('_spec.rb') + + print_file_result(RSpec::Mock::MigrationAnalytics::FileAnalyzer.call(file_path)) + end + + def print_file_result(result) + puts("\n=== Mock Usage Analysis: #{result[:file_path]} ===".blue) + + if result[:has_mocks] + print_mock_statistics(result) + print_locations_table('Flexmock Usage', result[:flexmock_locations]) if result[:flexmock_locations].any? + print_locations_table('RSpec Mock Usage', result[:rspec_mock_locations]) if result[:rspec_mock_locations].any? + else + puts('āœ… No mocking usage found'.green) + end + end + + def print_summary(results, stats) + puts("\n=== Migration Status Report ===".blue) + + total_mocks = stats[:total_flexmock_occurrences] + stats[:total_rspec_mock_occurrences] + migration_progress = + total_mocks.zero? ? 100 : (stats[:total_rspec_mock_occurrences].to_f / total_mocks * 100).round(2) + + print_summary_table(stats, migration_progress) + print_files_table(results) if results.any? + end + + def print_mock_statistics(result) + total_mocks = result[:flexmock_count] + result[:rspec_mock_count] + migration_progress = (result[:rspec_mock_count].to_f / total_mocks * 100).round(2) + puts( + Terminal::Table.new do |t| + t.add_row(['Total Mocks', total_mocks]) + t.add_row(['Flexmock Usage', result[:flexmock_count]]) + t.add_row(['RSpec Mock Usage', result[:rspec_mock_count]]) + t.add_row(['Migration Progress', "#{migration_progress}%"]) + end + ) + end + + def print_locations_table(title, locations) + return if locations.empty? + + puts("\n#{title}:".yellow) + puts( + Terminal::Table.new do |table| + table.headings = %w[Line Type Content] + locations.each do |loc| + table.add_row(create_location_row(loc)) + end + end + ) + end + + def create_location_row(loc) + type_str = loc[:type].nil? ? 'unknown' : loc[:type] + color = determine_color(loc[:type]) + + [ + loc[:line_number].to_s.yellow, + type_str.respond_to?(color) ? type_str.send(color) : type_str, + loc[:content] + ] + end + + def determine_color(type) + case type + when 'migration mock block' then :cyan + when 'expect mock', 'allow mock' then :blue + when 'verifying double' then :green + else :light_white + end + end + + def print_summary_table(stats, migration_progress) + puts( + Terminal::Table.new do |table| + table.add_row(['Total Spec Files', stats[:total_files]]) + table.add_row(['Files with Mocks', stats[:files_with_mocks]]) + table.add_row(['Files with Mixed Usage', stats[:files_with_mixed_usage]]) + table.add_row(['Total Flexmock Occurrences', stats[:total_flexmock_occurrences]]) + table.add_row(['Total RSpec Mock Occurrences', stats[:total_rspec_mock_occurrences]]) + table.add_row(['Migration Progress', "#{migration_progress}%"]) + end + ) + end + + def print_files_table(results) + puts("\n=== Files Requiring Migration ===".red) + puts( + Terminal::Table.new do |table| + table.headings = ['File Path', 'Flexmock Count', 'RSpec Mock Count', 'Progress'] + results.sort_by { |row| -row[:flexmock_count] }.each do |result| + table.add_row(create_file_row(result)) + end + end + ) + end + + def create_file_row(result) + total = result[:flexmock_count] + result[:rspec_mock_count] + progress = + total.zero? ? 100 : (result[:rspec_mock_count].to_f / total * 100).round(2) + [ + result[:file_path], + result[:flexmock_count], + result[:rspec_mock_count], + "#{progress}%" + ] + end + end + end + end + end +end diff --git a/lib/rspec/mock/migration_analytics/file_analyzer.rb b/lib/rspec/mock/migration_analytics/file_analyzer.rb new file mode 100644 index 0000000..756055f --- /dev/null +++ b/lib/rspec/mock/migration_analytics/file_analyzer.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module RSpec + module Mock + module MigrationAnalytics + class FileAnalyzer + def self.call( + file_path, + flexmock_tracker = RSpec::Mock::MigrationAnalytics::Tracker::Flexmock.new, + rspec_tracker = RSpec::Mock::MigrationAnalytics::Tracker::Rspec.new + ) + new(file_path, flexmock_tracker, rspec_tracker).call + end + + def initialize(file_path, flexmock_tracker, rspec_tracker) + @file_path = file_path + @flexmock_tracker = flexmock_tracker + @rspec_tracker = rspec_tracker + end + + def call + build_analytics + generate_report + end + + private + + attr_reader :file_path, :flexmock_tracker, :rspec_tracker + + def build_analytics + ::File.read(file_path).split("\n").each_with_index do |line, index| + line_number = index + 1 + flexmock_tracker.scan_line(line, line_number) + rspec_tracker.scan_line(line, line_number) + end + end + + %i[flexmock_tracker rspec_tracker].each do |method_name| + target_method_name = :"#{method_name}_locations" + define_method(target_method_name) { send(method_name).locations } + define_method(:"#{target_method_name}_any?") { send(target_method_name).any? } + end + + def generate_report + { + file_path: file_path, + flexmock_count: flexmock_tracker_locations.size, + rspec_mock_count: rspec_tracker_locations.size, + flexmock_locations: flexmock_tracker_locations, + rspec_mock_locations: rspec_tracker_locations, + has_mocks: flexmock_tracker_locations_any? || rspec_tracker_locations_any?, + has_mixed_usage: flexmock_tracker_locations_any? && rspec_tracker_locations_any? + } + end + end + end + end +end diff --git a/lib/rspec/mock/migration_analytics/tracker/base.rb b/lib/rspec/mock/migration_analytics/tracker/base.rb new file mode 100644 index 0000000..b1cb4c3 --- /dev/null +++ b/lib/rspec/mock/migration_analytics/tracker/base.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module RSpec + module Mock + module MigrationAnalytics + module Tracker + class Base + attr_reader :locations + attr_accessor :in_mock_block, :block_level + + alias in_mock_block? in_mock_block + + def initialize + @locations = [] + @block_level = 0 + @in_mock_block = false + end + + def scan_line; end + end + end + end + end +end diff --git a/lib/rspec/mock/migration_analytics/tracker/flexmock.rb b/lib/rspec/mock/migration_analytics/tracker/flexmock.rb new file mode 100644 index 0000000..29ddf9f --- /dev/null +++ b/lib/rspec/mock/migration_analytics/tracker/flexmock.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +require 'set' + +module RSpec + module Mock + module MigrationAnalytics + module Tracker + class Flexmock < RSpec::Mock::MigrationAnalytics::Tracker::Base + PATTERNS = { + direct_call: /\bFlexMock\./, + helper_call: /\bflexmock\s*\(/, + new_instances: /\bflexmock\s*\([^)]+\)\.new_instances\s*do\s*\|/, + var_assignment: /(\w+)\s*=\s*flexmock\s*\(/, + nested_mock: /\b(?:m|mock)\.should_receive(?:\([^)]*\))?/, + block_start: /\bflexmock\s*\([^)]+\)\s*do\s*\|/, + expectation_var: /(\w+)\s*=\s*flexmock\s*\([^)]*\)\.should_receive/, + single_line_block: /^(it|before)\s*{.*flexmock\(/, + should_receive: /flexmock\([^)]+\)\.should_receive/, + chained_should_receive: /\b(?:mock|m)\.should_receive\([^)]*\)(?:\.(and_return|with|once|twice|never|returns|and_raise))+/, + block_param: /\bflexmock\s*\([^)]+\)\s*do\s*\|([^|]+)\|/, + chaining: [ + /\.should_receive/, + /\.and_return/, + /\.once/, + /\.twice/, + /\.with/, + /\.returns/, + /\.and_raise/ + ], + pure_chain: /\b(?:should_receive\([^)]*\)(?:\.(and_return|with|once|twice|never|returns|and_raise))+)/, + flexmock_in_block: /\b(it|before|let)\s*{[^}]*flexmock/, + flexmock_in_do_block: /\b(it|before|let)\s+do.*flexmock/, + block_end: /\bend\b|\}/, + expectation_assignment: /(\w+)\s*=\s*.*should_receive/, + direct_should_receive: /flexmock\([^)]*\)\.should_receive/, + var_reference: /\b%s\b/, + mock_chain: /\.should_receive|\.(and_return|with|once|twice|never|returns|and_raise)/, + var_boundary_template: '\b%s\b', + should_receive_chain: /\.should_receive/, + method_chain: /\.(and_return|with|once|twice|never|returns|and_raise)/ + }.freeze + + attr_reader :flexmock_vars, :flexmock_expectations + + def initialize + @flexmock_vars = ::Set.new + @flexmock_expectations = ::Set.new + super + end + + def scan_line(line, line_number) + return if line.strip.empty? || line.strip.start_with?('#') + + track_flexmock_blocks(line) + track_flexmock_vars(line) + + return unless flexmock_line?(line) + locations << { + line_number: line_number, + content: line.strip, + type: determine_flexmock_type(line) + } + end + + private + + def flexmock_line?(line) + stripped_line = line.strip + return false if stripped_line.empty? || stripped_line.start_with?('#') + + # Direct flexmock + return true if direct_flexmock?(stripped_line) + + # Variable-based flexmock + return true if flexmock_var_reference?(stripped_line) + + # Block-based flexmock + return true if in_mock_block? && mock_chain?(stripped_line) + + false + end + + def determine_flexmock_type(line) + stripped_line = line.strip + case stripped_line + when RSpec::Mock::MigrationAnalytics::Tracker::Flexmock::PATTERNS[:new_instances] then 'flexmock new_instances block' + when RSpec::Mock::MigrationAnalytics::Tracker::Flexmock::PATTERNS[:block_start] then 'flexmock block' + when RSpec::Mock::MigrationAnalytics::Tracker::Flexmock::PATTERNS[:should_receive] then 'flexmock expectation' + when RSpec::Mock::MigrationAnalytics::Tracker::Flexmock::PATTERNS[:chained_should_receive] then 'flexmock chained should' + when RSpec::Mock::MigrationAnalytics::Tracker::Flexmock::PATTERNS[:nested_mock] then 'nested flexmock' + when RSpec::Mock::MigrationAnalytics::Tracker::Flexmock::PATTERNS[:pure_chain] then 'flexmock chain' + else 'flexmock related' + end + end + + def direct_flexmock?(line) + case line + when RSpec::Mock::MigrationAnalytics::Tracker::Flexmock::PATTERNS[:direct_call], + RSpec::Mock::MigrationAnalytics::Tracker::Flexmock::PATTERNS[:helper_call], + RSpec::Mock::MigrationAnalytics::Tracker::Flexmock::PATTERNS[:new_instances], + RSpec::Mock::MigrationAnalytics::Tracker::Flexmock::PATTERNS[:should_receive], + RSpec::Mock::MigrationAnalytics::Tracker::Flexmock::PATTERNS[:expectation_var], + RSpec::Mock::MigrationAnalytics::Tracker::Flexmock::PATTERNS[:flexmock_in_block], + RSpec::Mock::MigrationAnalytics::Tracker::Flexmock::PATTERNS[:flexmock_in_do_block] + true + else + false + end + end + + def mock_chain?(line) + RSpec::Mock::MigrationAnalytics::Tracker::Flexmock::PATTERNS[:chaining].any? { |pattern| line.match?(pattern) } + end + + def track_flexmock_vars(line) + case line.strip + when RSpec::Mock::MigrationAnalytics::Tracker::Flexmock::PATTERNS[:var_assignment], + RSpec::Mock::MigrationAnalytics::Tracker::Flexmock::PATTERNS[:block_param], + RSpec::Mock::MigrationAnalytics::Tracker::Flexmock::PATTERNS[:expectation_assignment] + flexmock_vars.add(::Regexp.last_match(1).strip) + end + end + + def track_flexmock_blocks(line) + case line.strip + when RSpec::Mock::MigrationAnalytics::Tracker::Flexmock::PATTERNS[:block_start], + RSpec::Mock::MigrationAnalytics::Tracker::Flexmock::PATTERNS[:new_instances], + RSpec::Mock::MigrationAnalytics::Tracker::Flexmock::PATTERNS[:flexmock_in_do_block] + self.in_mock_block = true + self.block_level += 1 + when RSpec::Mock::MigrationAnalytics::Tracker::Flexmock::PATTERNS[:block_end] + if in_mock_block? + self.block_level -= 1 + self.in_mock_block = false if block_level.negative? + end + end + end + + def flexmock_var_reference?(line) + return false if flexmock_vars.empty? + + flexmock_vars.any? do |var| + regex = ::Regexp.new(RSpec::Mock::MigrationAnalytics::Tracker::Flexmock::PATTERNS[:var_boundary_template] % var) + line.match?(regex) && ( + line.match?(RSpec::Mock::MigrationAnalytics::Tracker::Flexmock::PATTERNS[:should_receive_chain]) || + line.match?(RSpec::Mock::MigrationAnalytics::Tracker::Flexmock::PATTERNS[:method_chain]) + ) + end + end + end + end + end + end +end diff --git a/lib/rspec/mock/migration_analytics/tracker/rspec.rb b/lib/rspec/mock/migration_analytics/tracker/rspec.rb new file mode 100644 index 0000000..3afc1d6 --- /dev/null +++ b/lib/rspec/mock/migration_analytics/tracker/rspec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module RSpec + module Mock + module MigrationAnalytics + module Tracker + class Rspec < RSpec::Mock::MigrationAnalytics::Tracker::Base + PATTERNS = { + block_start: /\b(rspec_mock)\s+(?:do|\{)/, + block_end: /(?:\}|\bend\b)/, + single_line_block: /rspec_mock\s*{/, + expect: /\bexpect\b/, + allow: /\ballow\b/, + have_received: /\bhave_received\b/, + mock_chain: /\.(to_receive|to\s+receive|and_return|and_raise)/, + mock_related: /\b(?: + expect| + allow| + receive| + and_return| + and_raise| + have_received| + instance_double| + class_double| + object_double| + double + )\b/x, + end_brace: /\}\s*$/, + empty_or_comment: /^\s*(?:#.*)?$/ + }.freeze + + def scan_line(line, line_number) + return if line.strip.empty? || line.strip.start_with?('#') + + line.split(';').each do |statement| + stripped_statement = statement.strip + track_rspec_mock_blocks(stripped_statement) + track_rspec_mock_usage(stripped_statement, line_number) + end + end + + private + + def mock_related?(line) + line.match?(RSpec::Mock::MigrationAnalytics::Tracker::Rspec::PATTERNS[:mock_related]) + end + + def determine_rspec_mock_type(line) + case line + when RSpec::Mock::MigrationAnalytics::Tracker::Rspec::PATTERNS[:have_received] then 'spy verification' + when RSpec::Mock::MigrationAnalytics::Tracker::Rspec::PATTERNS[:expect] then 'expect mock' + when RSpec::Mock::MigrationAnalytics::Tracker::Rspec::PATTERNS[:allow] then 'allow mock' + when RSpec::Mock::MigrationAnalytics::Tracker::Rspec::PATTERNS[:mock_chain] then 'mock chain' + else 'rspec mock related' + end + end + + def track_rspec_mock_blocks(line) + case line + when RSpec::Mock::MigrationAnalytics::Tracker::Rspec::PATTERNS[:single_line_block] + self.in_mock_block = true + when RSpec::Mock::MigrationAnalytics::Tracker::Rspec::PATTERNS[:block_start] + self.in_mock_block = true + self.block_level += 1 + when RSpec::Mock::MigrationAnalytics::Tracker::Rspec::PATTERNS[:block_end] + self.block_level -= 1 + self.in_mock_block = false if block_level.zero? + end + + # Reset block state for single-line blocks + self.in_mock_block = false if line.end_with?('}') + end + + def track_rspec_mock_usage(line, line_number) + return unless (in_mock_block? || line.match?(RSpec::Mock::MigrationAnalytics::Tracker::Rspec::PATTERNS[:single_line_block])) && + mock_related?(line) + + locations << { + line_number: line_number, + content: line, + type: determine_rspec_mock_type(line) + } + end + end + end + end + end +end diff --git a/lib/rspec/mock/version.rb b/lib/rspec/mock/version.rb index d019e18..b7a3abc 100644 --- a/lib/rspec/mock/version.rb +++ b/lib/rspec/mock/version.rb @@ -2,6 +2,6 @@ module RSpec module Mock - VERSION = '0.2.0' + VERSION = '0.3.0' end end diff --git a/rspec-mock.gemspec b/rspec-mock.gemspec index c1d1884..c97c220 100644 --- a/rspec-mock.gemspec +++ b/rspec-mock.gemspec @@ -26,8 +26,10 @@ Gem::Specification.new do |spec| spec.files = `git ls-files -z`.split("\x0").select { |f| f.match(%r{^(bin|lib)/|.ruby-version|rspec-mock.gemspec|LICENSE}) } spec.require_paths = %w[lib] + spec.add_runtime_dependency 'colorize', '>= 0.8.1' spec.add_runtime_dependency 'rspec-core', '~> 3.10' spec.add_runtime_dependency 'rspec-mocks', '~> 3.10' + spec.add_runtime_dependency 'terminal-table', '~> 3.0' spec.add_development_dependency 'rspec', '~> 3.13' end diff --git a/spec/rspec/mock/migration_analytics/cli_spec.rb b/spec/rspec/mock/migration_analytics/cli_spec.rb new file mode 100644 index 0000000..2a5b24b --- /dev/null +++ b/spec/rspec/mock/migration_analytics/cli_spec.rb @@ -0,0 +1,237 @@ +# frozen_string_literal: true + +RSpec.describe RSpec::Mock::MigrationAnalytics::Cli do + let(:sample_file_path) { 'spec/models/user_spec.rb' } + let(:sample_dir_path) { 'spec/models' } + + shared_context 'with stubbed ENV' do + before do + allow(ENV).to receive(:[]).and_return(nil) + allow(ENV).to receive(:[]).with('COLUMNS').and_return('80') + end + end + + describe '.call' do + context 'when no arguments are provided' do + before { stub_const('ARGV', []) } + + it 'prints usage and exits with status 1' do + expect(described_class).to receive(:print_usage) + expect { described_class.call }.to raise_error(SystemExit) do |error| + expect(error.status).to eq(1) + end + end + end + + context 'when path is provided' do + before { stub_const('ARGV', [sample_file_path]) } + + it 'verifies the provided path' do + expect(described_class).to receive(:verify_path).with(sample_file_path) + described_class.call + end + + context 'when an error occurs' do + let(:error_message) { 'Something went wrong' } + + before do + allow(described_class).to receive(:verify_path).and_raise(StandardError.new(error_message)) + allow(ENV).to receive(:[]).and_return(nil) + allow(ENV).to receive(:[]).with('COLUMNS').and_return('80') + end + + it 'prints the error message in red' do + expect { described_class.call } + .to output(/āŒ Error: #{error_message}/).to_stdout + end + + context 'when DEBUG env is set' do + before do + allow(ENV).to receive(:[]).with('DEBUG').and_return('true') + end + + it 'prints the backtrace' do + expect { described_class.call } + .to output(%r{#{error_message}.*gems/rspec}m).to_stdout + end + end + end + end + end + + describe '.verify_path' do + context 'when path is a directory' do + before { allow(File).to receive(:directory?).with(sample_dir_path).and_return(true) } + + it 'calls verify_directory' do + expect(described_class).to receive(:verify_directory).with(sample_dir_path) + described_class.verify_path(sample_dir_path) + end + end + + context 'when path is a file' do + before { allow(File).to receive(:directory?).with(sample_file_path).and_return(false) } + + it 'calls verify_file' do + expect(described_class).to receive(:verify_file).with(sample_file_path) + described_class.verify_path(sample_file_path) + end + end + end + + describe '.verify_file' do + include_context 'with stubbed ENV' + + context 'when file does not exist' do + before { allow(File).to receive(:exist?).with(sample_file_path).and_return(false) } + + it 'prints error message' do + expect { described_class.send(:verify_file, sample_file_path) } + .to output(/File not found: #{sample_file_path}/).to_stdout + end + end + + context 'when file is not a spec file' do + let(:non_spec_file) { 'app/models/user.rb' } + + before { allow(File).to receive(:exist?).with(non_spec_file).and_return(true) } + + it 'prints warning message' do + expect { described_class.send(:verify_file, non_spec_file) } + .to output(/Not a Ruby spec file: #{non_spec_file}/).to_stdout + end + end + + context 'when file is valid spec file' do + let(:analysis_result) do + { + file_path: sample_file_path, + has_mocks: true, + flexmock_count: 2, + rspec_mock_count: 3, + flexmock_locations: [{ line_number: 1, type: 'migration mock block', content: 'mock code' }], + rspec_mock_locations: [{ line_number: 2, type: 'expect mock', content: 'expect code' }] + } + end + + before do + allow(File).to receive(:exist?).with(sample_file_path).and_return(true) + allow(RSpec::Mock::MigrationAnalytics::FileAnalyzer).to receive(:call) + .with(sample_file_path).and_return(analysis_result) + end + + it 'prints analysis results' do + expect { described_class.send(:verify_file, sample_file_path) } + .to output(/=== Mock Usage Analysis:.*Mock Usage/m) + .to_stdout + end + end + + context 'when file has no mocks' do + let(:analysis_result) do + { + file_path: sample_file_path, + has_mocks: false, + flexmock_count: 0, + rspec_mock_count: 0, + flexmock_locations: [], + rspec_mock_locations: [] + } + end + + before do + allow(File).to receive(:exist?).with(sample_file_path).and_return(true) + allow(RSpec::Mock::MigrationAnalytics::FileAnalyzer).to receive(:call) + .with(sample_file_path).and_return(analysis_result) + end + + it 'prints no mocking usage found message' do + expect { described_class.send(:verify_file, sample_file_path) } + .to output(/āœ… No mocking usage found/).to_stdout + end + end + end + + describe '.verify_directory' do + include_context 'with stubbed ENV' + + let(:spec_files) { ['spec/model1_spec.rb', 'spec/model2_spec.rb'] } + let(:analysis_results) do + [ + { + file_path: spec_files[0], + has_mocks: true, + flexmock_count: 1, + rspec_mock_count: 2, + has_mixed_usage: true, + flexmock_locations: [], + rspec_mock_locations: [] + }, + { + file_path: spec_files[1], + has_mocks: true, + flexmock_count: 2, + rspec_mock_count: 1, + has_mixed_usage: false, + flexmock_locations: [], + rspec_mock_locations: [] + } + ] + end + + before do + allow(Dir).to receive(:glob).with("#{sample_dir_path}/**/*_spec.rb").and_return(spec_files) + allow(RSpec::Mock::MigrationAnalytics::FileAnalyzer).to receive(:call) + .and_return(*analysis_results) + end + + it 'analyzes all spec files and prints summary' do + expect { described_class.send(:verify_directory, sample_dir_path) } + .to output(/=== Migration Status Report ===.*Files Requiring Migration/m).to_stdout + end + end + + describe 'helper methods' do + describe '.create_location_row' do + it 'formats location data with correct colors' do + location = { line_number: 1, type: 'expect mock', content: 'test content' } + row = described_class.send(:create_location_row, location) + expect(row).to be_an(Array) + expect(row.size).to eq(3) + end + end + + describe '.determine_color' do + { + 'migration mock block' => :cyan, + 'expect mock' => :blue, + 'allow mock' => :blue, + 'verifying double' => :green, + 'unknown' => :light_white + }.each do |type, expected_color| + it "returns #{expected_color} for #{type}" do + expect(described_class.send(:determine_color, type)).to eq(expected_color) + end + end + end + end + + describe '.print_usage' do + include_context 'with stubbed ENV' + + it 'prints usage instructions with examples' do + expected_lines = [ + 'Usage: ruby cli.rb ', + '', + 'Examples:', + ' ruby cli.rb spec/models/user_spec.rb', + ' ruby cli.rb spec/models/', + ' ruby cli.rb spec/' + ] + + expect { described_class.send(:print_usage) } + .to output(/#{expected_lines.join('.*')}/m) + .to_stdout + end + end +end diff --git a/spec/rspec/mock/migration_analytics/file_analyzer_spec.rb b/spec/rspec/mock/migration_analytics/file_analyzer_spec.rb new file mode 100644 index 0000000..ddaf56f --- /dev/null +++ b/spec/rspec/mock/migration_analytics/file_analyzer_spec.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +RSpec.describe RSpec::Mock::MigrationAnalytics::FileAnalyzer do + describe '.call' do + subject(:result) { create_result(described_class.call(temporary_spec_file.path)) } + + let(:temporary_spec_file) { create_file } + + after do + temporary_spec_file.close + temporary_spec_file.unlink + end + + context 'when flexmock usage' do + context 'with flexmock usage with do-end block' do + before do + temporary_spec_file.write(<<~RSPEC) + RSpec.describe MyClass do + let(:mock_in_let) { flexmock(MyClass) } + let!(:mock_in_let!) { flexmock(MyClass) } + + def mock_in_method + flexmock(MyClass) + end + + it 'mocks something' do + mock = flexmock(MyClass) + mock.should_receive(:method).once + end + end + RSPEC + temporary_spec_file.rewind + end + + it do + expect(result.flexmock_count).to eq(5) + expect(result.rspec_mock_count).to eq(0) + expect(result.has_mocks).to be(true) + end + end + + context 'with flexmock usage with {} block' do + before do + temporary_spec_file.write(<<~RSPEC) + RSpec.describe MyClass do + it { flexmock(MyClass).should_receive(:method).once } + end + RSPEC + temporary_spec_file.rewind + end + + it do + expect(result.flexmock_count).to eq(1) + expect(result.rspec_mock_count).to eq(0) + expect(result.has_mocks).to be(true) + end + end + end + + context 'when rspec mock usage' do + context 'with rspec mock usage in rspec_mock block with do-end block' do + before do + temporary_spec_file.write(<<~RSPEC) + RSpec.describe MyClass do + before do + rspec_mock do + mock = instance_double(MyClass) + allow(MyClass).to receive(:method).and_return(mock) + end + end + + it 'mocks something' do + rspec_mock do + expect(MyClass).to receive(:method) + end + end + end + RSPEC + temporary_spec_file.rewind + end + + it do + expect(result.flexmock_count).to eq(0) + expect(result.rspec_mock_count).to eq(3) + expect(result.has_mocks).to be(true) + end + end + + context 'with rspec mock usage in rspec_mock block with {} block' do + before do + temporary_spec_file.write(<<~RSPEC) + RSpec.describe MyClass do + it { rspec_mock { expect(MyClass).to receive(:method) } } + end + RSPEC + temporary_spec_file.rewind + end + + it do + expect(result.flexmock_count).to eq(0) + expect(result.rspec_mock_count).to eq(1) + expect(result.has_mocks).to be(true) + end + end + end + + context 'when mixed usage' do + context 'with flexmock and rspec mock usage with do-end block' do + before do + temporary_spec_file.write(<<~RSPEC) + RSpec.describe MyClass do + it 'uses both mock types' do + mock = flexmock(MyClass) + rspec_mock do + expect(OtherClass).to receive(:method) + end + end + end + RSPEC + temporary_spec_file.rewind + end + + it do + expect(result.flexmock_count).to eq(1) + expect(result.rspec_mock_count).to eq(1) + expect(result.has_mixed_usage).to be(true) + end + end + + context 'with flexmock and rspec mock usage with {} block' do + before do + temporary_spec_file.write(<<~RSPEC) + RSpec.describe MyClass do + it { flexmock(MyClass).should_receive(:method).once; rspec_mock { expect(OtherClass).to receive(:method) } } + end + RSPEC + temporary_spec_file.rewind + end + + it do + expect(result.flexmock_count).to eq(1) + expect(result.rspec_mock_count).to eq(1) + expect(result.has_mixed_usage).to be(true) + end + end + end + end +end diff --git a/spec/rspec/mock/migration_analytics/tracker/flexmock_spec.rb b/spec/rspec/mock/migration_analytics/tracker/flexmock_spec.rb new file mode 100644 index 0000000..cb5c11f --- /dev/null +++ b/spec/rspec/mock/migration_analytics/tracker/flexmock_spec.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +RSpec.describe RSpec::Mock::MigrationAnalytics::Tracker::Flexmock do + subject(:tracker) { described_class.new } + + describe '#initialize' do + it 'initializes with empty collections and default values' do + expect(tracker.flexmock_vars).to be_empty + expect(tracker.flexmock_expectations).to be_empty + expect(tracker.locations).to be_empty + expect(tracker.block_level).to eq(0) + expect(tracker.in_mock_block).to be false + end + end + + describe '#scan_line' do + context 'with empty or comment lines' do + it 'ignores empty lines' do + tracker.scan_line('', 1) + tracker.scan_line(' ', 2) + expect(tracker.locations).to be_empty + end + + it 'ignores comment lines' do + tracker.scan_line('# flexmock(user)', 1) + tracker.scan_line(' # mock.should_receive(:method)', 2) + expect(tracker.locations).to be_empty + end + end + + context 'with direct FlexMock calls' do + it 'detects FlexMock class calls' do + tracker.scan_line('FlexMock.new', 1) + expect(tracker.locations.first).to include( + line_number: 1, + content: 'FlexMock.new', + type: 'flexmock related' + ) + end + end + + context 'with helper method calls' do + it 'detects basic flexmock calls' do + tracker.scan_line('flexmock(user)', 1) + expect(tracker.locations.first).to include( + line_number: 1, + content: 'flexmock(user)', + type: 'flexmock related' + ) + end + + it 'detects new_instances blocks' do + tracker.scan_line('flexmock(User).new_instances do |m|', 1) + expect(tracker.locations.first).to include( + line_number: 1, + content: 'flexmock(User).new_instances do |m|', + type: 'flexmock new_instances block' + ) + end + end + + context 'with variable assignments' do + it 'tracks basic mock assignments' do + tracker.scan_line('user_mock = flexmock(user)', 1) + expect(tracker.flexmock_vars).to include('user_mock') + expect(tracker.locations.first[:line_number]).to eq(1) + end + + it 'tracks block parameter assignments' do + tracker.scan_line('flexmock(User) do |mock|', 1) + expect(tracker.flexmock_vars).to include('mock') + expect(tracker.locations.first[:line_number]).to eq(1) + end + + it 'tracks expectation assignments' do + tracker.scan_line('expectation = mock.should_receive(:method)', 1) + expect(tracker.flexmock_vars).to include('expectation') + end + end + + context 'with expectations' do + it 'detects direct should_receive calls' do + tracker.scan_line('flexmock(user).should_receive(:name)', 1) + expect(tracker.locations.first).to include( + type: 'flexmock expectation', + line_number: 1 + ) + end + end + + context 'with block tracking' do + it 'tracks nested blocks' do + tracker.scan_line('flexmock(user) do |mock|', 1) + tracker.scan_line(' flexmock(other) do |other_mock|', 2) + expect(tracker.block_level).to eq(2) + expect(tracker.flexmock_vars).to include('mock', 'other_mock') + + tracker.scan_line(' end', 3) + tracker.scan_line('end', 4) + expect(tracker.block_level).to eq(0) + end + end + + context 'with variable references' do + before do + tracker.scan_line('user_mock = flexmock(user)', 1) + end + + it 'detects method chains on tracked variables' do + tracker.scan_line('user_mock.should_receive(:name)', 2) + expect(tracker.locations.last[:line_number]).to eq(2) + end + + it 'detects return value chains on tracked variables' do + tracker.scan_line('user_mock.should_receive(:name).and_return("John")', 2) + expect(tracker.locations.last[:line_number]).to eq(2) + end + + it 'detects multiple expectations on tracked variables' do + tracker.scan_line('user_mock.should_receive(:name).with("John").once', 2) + tracker.scan_line('user_mock.should_receive(:email).and_return("john@example.com")', 3) + expect(tracker.locations.map { |loc| loc[:line_number] }).to eq([1, 2, 3]) + end + end + + context 'with RSpec blocks' do + it 'detects flexmock in single-line blocks' do + tracker.scan_line('it { flexmock(user) }', 1) + expect(tracker.locations.first[:line_number]).to eq(1) + end + + it 'detects flexmock in do-end blocks' do + tracker.scan_line('it do', 1) + tracker.scan_line(' flexmock(user)', 2) + tracker.scan_line('end', 3) + expect(tracker.locations.first[:line_number]).to eq(2) + end + + it 'detects flexmock in before blocks' do + tracker.scan_line('before { flexmock(user) }', 1) + expect(tracker.locations.first[:line_number]).to eq(1) + end + + it 'detects flexmock in let blocks' do + tracker.scan_line('let(:mock) { flexmock(user) }', 1) + expect(tracker.locations.first[:line_number]).to eq(1) + end + end + end +end diff --git a/spec/rspec/mock/migration_analytics/tracker/rspec_spec.rb b/spec/rspec/mock/migration_analytics/tracker/rspec_spec.rb new file mode 100644 index 0000000..1488512 --- /dev/null +++ b/spec/rspec/mock/migration_analytics/tracker/rspec_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +RSpec.describe RSpec::Mock::MigrationAnalytics::Tracker::Rspec do + subject(:tracker) { described_class.new } + + describe '#scan_line' do + context 'when line is empty or comment' do + it 'ignores empty lines' do + tracker.scan_line('', 1) + tracker.scan_line(' ', 2) + + expect(tracker.locations).to be_empty + end + + it 'ignores comment lines' do + tracker.scan_line('# this is a comment', 1) + + expect(tracker.locations).to be_empty + end + end + + context 'when scanning mock blocks' do + it 'tracks single-line rspec_mock blocks' do + tracker.scan_line('rspec_mock { expect(foo).to receive(:bar) }', 1) + + expect(tracker.locations).to contain_exactly( + hash_including( + line_number: 1, + content: 'rspec_mock { expect(foo).to receive(:bar) }', + type: 'expect mock' + ) + ) + end + + it 'tracks multi-line rspec_mock blocks' do + tracker.scan_line('rspec_mock do', 1) + tracker.scan_line(' expect(foo).to receive(:bar)', 2) + tracker.scan_line(' allow(baz).to receive(:qux)', 3) + tracker.scan_line('end', 4) + + expect(tracker.locations).to contain_exactly( + hash_including( + line_number: 2, + content: 'expect(foo).to receive(:bar)', + type: 'expect mock' + ), + hash_including( + line_number: 3, + content: 'allow(baz).to receive(:qux)', + type: 'allow mock' + ) + ) + end + end + + context 'when scanning different mock types' do + before { tracker.scan_line('rspec_mock do', 1) } + after { tracker.scan_line('end', 999) } + + it 'identifies spy verifications' do + tracker.scan_line('expect(foo).to have_received(:bar)', 2) + + expect(tracker.locations.last).to include( + line_number: 2, + type: 'spy verification' + ) + end + + it 'identifies expect mocks' do + tracker.scan_line('expect(foo).to receive(:bar)', 2) + + expect(tracker.locations.last).to include( + line_number: 2, + type: 'expect mock' + ) + end + + it 'identifies allow mocks' do + tracker.scan_line('allow(foo).to receive(:bar)', 2) + + expect(tracker.locations.last).to include( + line_number: 2, + type: 'allow mock' + ) + end + + it 'identifies mock chains' do + tracker.scan_line('foo.to_receive(:bar).and_return(42)', 2) + + expect(tracker.locations.last).to include( + line_number: 2, + type: 'mock chain' + ) + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index b442fee..33a9255 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -19,6 +19,7 @@ mock.verify_partial_doubles = true end + config.include ContextHelper config.include RSpec::Mock::Methods ::Kernel.srand(config.seed) diff --git a/spec/support/helpers/context_helper.rb b/spec/support/helpers/context_helper.rb new file mode 100644 index 0000000..75ccf25 --- /dev/null +++ b/spec/support/helpers/context_helper.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require 'tempfile' + +module ContextHelper + def create_result(options) + @result_class ||= ::Struct.new(*options.keys, keyword_init: true) + @result_class.new(**options) + end + + def create_file + ::Tempfile.new(['temporary_spec', '.rb']) + end +end