├── .codeclimate.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .ruby-version ├── .travis.yml ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── Rakefile ├── bin ├── ducalis └── legacy_versions_test.sh ├── config └── .ducalis.yml ├── ducalis.gemspec ├── lib ├── ducalis.rb └── ducalis │ ├── adapters │ ├── circle_ci.rb │ └── default.rb │ ├── cli_arguments.rb │ ├── commentators │ ├── github.rb │ └── message.rb │ ├── cops │ ├── black_list_suffix.rb │ ├── callbacks_activerecord.rb │ ├── case_mapping.rb │ ├── complex_cases │ │ └── smart_delete_check.rb │ ├── complex_regex.rb │ ├── complex_statements.rb │ ├── controllers_except.rb │ ├── data_access_objects.rb │ ├── descriptive_block_names.rb │ ├── enforce_namespace.rb │ ├── evlis_overusing.rb │ ├── extensions │ │ └── type_resolving.rb │ ├── facade_pattern.rb │ ├── fetch_expression.rb │ ├── keyword_defaults.rb │ ├── module_like_class.rb │ ├── multiple_times.rb │ ├── only_defs.rb │ ├── options_argument.rb │ ├── params_passing.rb │ ├── possible_tap.rb │ ├── preferable_methods.rb │ ├── private_instance_assign.rb │ ├── protected_scope_cop.rb │ ├── public_send.rb │ ├── raise_without_error_class.rb │ ├── recursion.rb │ ├── regex_cop.rb │ ├── rest_only_cop.rb │ ├── rubocop_disable.rb │ ├── standard_methods.rb │ ├── strings_in_activerecords.rb │ ├── too_long_workers.rb │ ├── uncommented_gem.rb │ ├── unlocked_gem.rb │ └── useless_only.rb │ ├── diffs.rb │ ├── documentation.rb │ ├── errors.rb │ ├── git_access.rb │ ├── github_formatter.rb │ ├── patch.rb │ ├── patched_rubocop │ ├── cop_cast.rb │ ├── ducalis_config_loader.rb │ ├── git_runner.rb │ ├── git_turget_finder.rb │ └── inject.rb │ ├── rubo_cop.rb │ ├── utils.rb │ └── version.rb └── spec ├── ducalis ├── adapters │ ├── circle_ci_spec.rb │ └── default_spec.rb ├── cli_arguments_spec.rb ├── commentators │ ├── github_spec.rb │ └── message_spec.rb ├── cops │ ├── black_list_suffix_spec.rb │ ├── callbacks_activerecord_spec.rb │ ├── case_mapping_spec.rb │ ├── complex_cases │ │ └── smart_delete_check_spec.rb │ ├── complex_regex_spec.rb │ ├── complex_statements_spec.rb │ ├── controllers_except_spec.rb │ ├── data_access_objects_spec.rb │ ├── descriptive_block_names_spec.rb │ ├── enforce_namespace_spec.rb │ ├── evlis_overusing_spec.rb │ ├── facade_pattern_spec.rb │ ├── fetch_expression_spec.rb │ ├── keyword_defaults_spec.rb │ ├── module_like_class_spec.rb │ ├── multiple_times_spec.rb │ ├── only_defs_spec.rb │ ├── options_argument_spec.rb │ ├── params_passing_spec.rb │ ├── possible_tap_spec.rb │ ├── preferable_methods_spec.rb │ ├── private_instance_assign_spec.rb │ ├── protected_scope_cop_spec.rb │ ├── public_send_spec.rb │ ├── raise_without_error_class_spec.rb │ ├── recursion_spec.rb │ ├── regex_cop_spec.rb │ ├── rest_only_cop_spec.rb │ ├── rubocop_disable_spec.rb │ ├── standard_methods_spec.rb │ ├── strings_in_activerecords_spec.rb │ ├── too_long_workers_spec.rb │ ├── uncommented_gem_spec.rb │ ├── unlocked_gem_spec.rb │ └── useless_only_spec.rb ├── diffs_spec.rb ├── documentation_spec.rb ├── errors_spec.rb ├── git_access_spec.rb ├── github_formatter_spec.rb ├── patch_spec.rb ├── utils_spec.rb └── version_spec.rb ├── ducalis_spec.rb ├── fixtures └── patch.diff ├── spec_helper.rb └── support ├── cop_helper_cast.rb ├── cop_violation.rb └── coverage.rb /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | engines: 2 | rubocop: 3 | enabled: true 4 | channel: rubocop-0-60 5 | config: 6 | file: .rubocop.yml 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /.bundle/ 3 | /.yardoc 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | 11 | # rspec failure tracking 12 | .rspec_status 13 | 14 | *.gem 15 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 2.2 3 | UseCache: false 4 | Exclude: 5 | - 'client/vendor/bundle/**/*' 6 | - 'vendor/bundle/**/*' 7 | 8 | Metrics/BlockLength: 9 | Exclude: 10 | - 'spec/**/*.rb' 11 | Style/Documentation: 12 | Enabled: false 13 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.5.1 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.2.9 4 | - 2.3.8 5 | - 2.4.5 6 | before_install: 7 | - gem update --system 8 | - gem --version 9 | - gem install bundler 10 | script: 11 | - bundle exec rake 12 | - bash bin/legacy_versions_test.sh 13 | matrix: 14 | include: 15 | - rvm: 2.5.3 16 | env: IGNORE_LEGACY=true WITH_DOCS=true 17 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 5 | 6 | gemspec 7 | 8 | # Development dependencies 9 | gem 'bundler', '~> 1.16.a' 10 | gem 'pry', '~> 0.10', '>= 0.10.0' 11 | gem 'rake', '~> 12.1' 12 | gem 'rspec', '~> 3.0' 13 | gem 'single_cov', group: :test 14 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | ducalis (0.12.0) 5 | git (~> 1.3, >= 1.3.0) 6 | octokit (>= 4.7.0) 7 | regexp-examples (~> 1.3, >= 1.3.2) 8 | regexp_parser (>= 0.5.0) 9 | rubocop (>= 0.45.0) 10 | 11 | GEM 12 | remote: https://rubygems.org/ 13 | specs: 14 | addressable (2.5.2) 15 | public_suffix (>= 2.0.2, < 4.0) 16 | ast (2.4.0) 17 | coderay (1.1.2) 18 | diff-lcs (1.3) 19 | faraday (0.15.3) 20 | multipart-post (>= 1.2, < 3) 21 | git (1.5.0) 22 | jaro_winkler (1.5.1-x86_64-darwin-17) 23 | method_source (0.9.0) 24 | multipart-post (2.0.0) 25 | octokit (4.13.0) 26 | sawyer (~> 0.8.0, >= 0.5.3) 27 | parallel (1.12.1) 28 | parser (2.5.3.0) 29 | ast (~> 2.4.0) 30 | powerpack (0.1.2) 31 | pry (0.11.3) 32 | coderay (~> 1.1.0) 33 | method_source (~> 0.9.0) 34 | public_suffix (3.0.3) 35 | rainbow (3.0.0) 36 | rake (12.3.0) 37 | regexp-examples (1.4.3) 38 | regexp_parser (1.2.0) 39 | rspec (3.7.0) 40 | rspec-core (~> 3.7.0) 41 | rspec-expectations (~> 3.7.0) 42 | rspec-mocks (~> 3.7.0) 43 | rspec-core (3.7.1) 44 | rspec-support (~> 3.7.0) 45 | rspec-expectations (3.7.0) 46 | diff-lcs (>= 1.2.0, < 2.0) 47 | rspec-support (~> 3.7.0) 48 | rspec-mocks (3.7.0) 49 | diff-lcs (>= 1.2.0, < 2.0) 50 | rspec-support (~> 3.7.0) 51 | rspec-support (3.7.1) 52 | rubocop (0.60.0) 53 | jaro_winkler (~> 1.5.1) 54 | parallel (~> 1.10) 55 | parser (>= 2.5, != 2.5.1.1) 56 | powerpack (~> 0.1) 57 | rainbow (>= 2.2.2, < 4.0) 58 | ruby-progressbar (~> 1.7) 59 | unicode-display_width (~> 1.4.0) 60 | ruby-progressbar (1.10.0) 61 | sawyer (0.8.1) 62 | addressable (>= 2.3.5, < 2.6) 63 | faraday (~> 0.8, < 1.0) 64 | single_cov (1.0.3) 65 | unicode-display_width (1.4.0) 66 | 67 | PLATFORMS 68 | ruby 69 | 70 | DEPENDENCIES 71 | bundler (~> 1.16.a) 72 | ducalis! 73 | pry (~> 0.10, >= 0.10.0) 74 | rake (~> 12.1) 75 | rspec (~> 3.0) 76 | single_cov 77 | 78 | BUNDLED WITH 79 | 1.16.1 80 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Ignat Zakrevsky 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ducalis 2 | 3 | [![Gem Version](https://badge.fury.io/rb/ducalis.svg)](https://badge.fury.io/rb/ducalis) 4 | [![Build Status](https://travis-ci.org/ignat-z/ducalis.svg?branch=master)](https://travis-ci.org/ignat-z/ducalis) 5 | [![Maintainability](https://api.codeclimate.com/v1/badges/d03d4e567e8728d2c58b/maintainability)](https://codeclimate.com/github/ignat-z/ducalis/maintainability) 6 | 7 | __Ducalis__ is RuboCop-based static code analyzer for enterprise Rails applications. 8 | 9 | Documentation available at https://ducalis-rb.github.io/. Changelog at https://ducalis-rb.github.io/log. 10 | 11 | __Ducalis__ isn't style checker and could sometimes be false-positive it's not 12 | necessary to follow all it rules, the main purpose of __Ducalis__ is help to find 13 | possible weak code parts. 14 | 15 | ## Installation and Usage 16 | 17 | Add this line to your application's `Gemfile`: 18 | 19 | ```ruby 20 | gem 'ducalis' 21 | ``` 22 | 23 | __Ducalis__ is CLI application. By defaukt it will notify you about any possible 24 | violations in CLI. 25 | 26 | ``` 27 | ducalis . 28 | ducalis app/controllers/ 29 | ``` 30 | 31 | __Ducalis__ allows to pass build even with violations it's make sense to run 32 | __Ducalis__ across current branch or index: 33 | 34 | ``` 35 | ducalis --branch . 36 | ducalis --index . 37 | ``` 38 | 39 | Additionally you can pass `--reporter` argument to notify about found violations 40 | in boundaries of PR: 41 | 42 | ``` 43 | ducalis --reporter "author/repo#42" . 44 | ducalis --reporter "circleci" . 45 | ``` 46 | 47 | _N.B._ You should provide `GITHUB_TOKEN` Env to allow __Ducalis__ download your 48 | PR code and write review comments. 49 | 50 | In CLI modes you can provide yours `.ducalis.yml` file based on 51 | [default](https://github.com/ignat-z/ducalis/blob/master/config/.ducalis.yml) by 52 | `-c` flag or simply putting it in your project directory. 53 | 54 | ## Configuration 55 | 56 | One or more individual cops can be disabled locally in a section of a file by adding a comment such as 57 | 58 | ```ruby 59 | # ducalis:disable Ducalis/PreferableMethods Use `delete_all` because of performance reasons 60 | def remove_audits 61 | AuditLog.where(user_id: user_id).delete_all 62 | end 63 | ``` 64 | 65 | The main behavior of Ducalis can be controlled via the 66 | [.ducalis.yml](). 67 | It makes it possible to enable/disable certain cops (checks) and to alter their 68 | behavior if they accept any parameters. List of all available cops could be 69 | found in the [documentation](). 70 | 71 | ## License 72 | 73 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 74 | 75 | ## Contribution 76 | 77 | Contributions are welcome! To pass your code through the all checks you simply need to run: 78 | 79 | ``` 80 | bundle exec rake 81 | ``` 82 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rspec/core/rake_task' 5 | require 'rubocop/rake_task' 6 | 7 | RuboCop::RakeTask.new do |task| 8 | task.options = %w[--auto-correct] 9 | end 10 | 11 | RSpec::Core::RakeTask.new(:spec) 12 | task default: %i[rubocop spec] 13 | -------------------------------------------------------------------------------- /bin/ducalis: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | $LOAD_PATH.unshift("#{__dir__}/../lib") 5 | 6 | require 'ducalis' 7 | require 'benchmark' 8 | 9 | cli_arguments = Ducalis::CliArguments.new 10 | 11 | if cli_arguments.help_command? 12 | puts 'You can start Ducalis in the following modes:' 13 | puts ' --branch check files in RuboCop style against branch.' 14 | puts ' --index check files in RuboCop style against index.' 15 | puts ' --all [default] check all files in RuboCop style.' 16 | puts 17 | puts 'Use --reporter flag to pass how to report violations' 18 | puts ' Ex: ducalis --reporter "user/repo#42"' 19 | puts ' ducalis --reporter "circleci"' 20 | 21 | exit 0 22 | end 23 | 24 | if cli_arguments.docs_command? 25 | require 'ducalis/documentation' 26 | 27 | File.write(ARGV[1] || 'DOCUMENTATION.md', Documentation.new.call) 28 | 29 | exit 0 30 | end 31 | 32 | cli_arguments.process! 33 | 34 | result = 0 35 | cli = RuboCop::CLI.new 36 | time = Benchmark.realtime do 37 | result = cli.run 38 | end 39 | 40 | puts "Finished in #{time} seconds" if cli.options[:debug] 41 | puts "\033[32mBuild still green. Nothing to worry about\033[0m" if result == 1 42 | 43 | exit 0 44 | -------------------------------------------------------------------------------- /bin/legacy_versions_test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | if [ -n "${IGNORE_LEGACY-}" ]; then 6 | echo "$(tput bold)Nothing to test!" 7 | exit 0; 8 | fi 9 | 10 | echo "$(tput bold)Enforcing old RuboCop version: $(tput sgr0)" 11 | sed -i.bak "s/'>= 0.45.0'/'>= 0.45.0', '0.46.0'/" ducalis.gemspec 12 | bundle install --no-deployment --quiet --no-color 13 | bundle show rubocop 14 | echo "$(tput bold)Running rspec on old RuboCop version: $(tput sgr0)" 15 | bundle exec rspec --format progress 16 | echo "$(tput bold)Enforcing new RuboCop version: $(tput sgr0)" 17 | mv ducalis.gemspec.bak ducalis.gemspec 18 | bundle install --no-deployment --quiet --no-color 19 | bundle show rubocop 20 | echo "$(tput bold)Running rspec on new RuboCop version: $(tput sgr0)" 21 | bundle exec rspec --format progress 22 | -------------------------------------------------------------------------------- /config/.ducalis.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | DisabledByDefault: true 3 | TargetRubyVersion: ~ 4 | Exclude: 5 | - 'db/**/*' 6 | - 'node_modules/**/*' 7 | - 'vendor/bundle/**/*' 8 | 9 | Ducalis/BlackListSuffix: 10 | Enabled: true 11 | BlackList: 12 | - Analyzer 13 | - Client 14 | - Handler 15 | - Loader 16 | - Manager 17 | - Object 18 | - Organizer 19 | - Processor 20 | - Renderer 21 | - Service 22 | - Sorter 23 | 24 | Ducalis/EnforceNamespace: 25 | Enabled: true 26 | ServicePath: 'app/services' 27 | 28 | Ducalis/PreferableMethods: 29 | Enabled: true 30 | 31 | Ducalis/Recursion: 32 | Enabled: true 33 | 34 | Ducalis/ComplexRegex: 35 | Enabled: true 36 | MaxComplexity: 3 37 | 38 | Ducalis/CaseMapping: 39 | Enabled: true 40 | 41 | Ducalis/FacadePattern: 42 | Enabled: true 43 | MaxInstanceVariables: 4 44 | 45 | Ducalis/CallbacksActiverecord: 46 | Enabled: true 47 | 48 | Ducalis/DataAccessObjects: 49 | Enabled: true 50 | 51 | Ducalis/PossibleTap: 52 | Enabled: false 53 | 54 | Ducalis/PublicSend: 55 | Enabled: true 56 | 57 | Ducalis/FetchExpression: 58 | Enabled: true 59 | 60 | Ducalis/ProtectedScopeCop: 61 | Enabled: true 62 | Exclude: 63 | - 'spec/**/*.rb' 64 | - 'app/workers/**/*.rb' 65 | - 'app/mailers/**/*.rb' 66 | 67 | Ducalis/RegexCop: 68 | Enabled: true 69 | Exclude: 70 | - 'spec/**/*.rb' 71 | 72 | Ducalis/StringsInActiverecords: 73 | Enabled: true 74 | 75 | Ducalis/TooLongWorkers: 76 | Enabled: true 77 | Max: 25 78 | 79 | Ducalis/OnlyDefs: 80 | Enabled: true 81 | 82 | Ducalis/UselessOnly: 83 | Enabled: true 84 | 85 | Ducalis/RestOnlyCop: 86 | Enabled: true 87 | 88 | Ducalis/KeywordDefaults: 89 | Enabled: true 90 | 91 | Ducalis/MultipleTimes: 92 | Enabled: true 93 | 94 | Ducalis/OptionsArgument: 95 | Enabled: true 96 | 97 | Ducalis/RubocopDisable: 98 | Enabled: true 99 | 100 | Ducalis/UncommentedGem: 101 | Enabled: true 102 | Include: 103 | - '**/Gemfile' 104 | - '**/gems.rb' 105 | 106 | Ducalis/ParamsPassing: 107 | Enabled: true 108 | Exclude: 109 | - 'spec/**/*.rb' 110 | 111 | Ducalis/ControllersExcept: 112 | Enabled: true 113 | 114 | Ducalis/PrivateInstanceAssign: 115 | Enabled: true 116 | 117 | Ducalis/EvlisOverusing: 118 | Enabled: true 119 | 120 | Ducalis/StandardMethods: 121 | Enabled: true 122 | 123 | Ducalis/RaiseWithoutErrorClass: 124 | Enabled: true 125 | 126 | Ducalis/UnlockedGem: 127 | Enabled: true 128 | 129 | Ducalis/ModuleLikeClass: 130 | Enabled: true 131 | AllowedIncludes: 132 | - Sidekiq::Worker 133 | - Singleton 134 | - ActiveModel::Model 135 | - Virtus.model 136 | 137 | Ducalis/DescriptiveBlockNames: 138 | Enabled: true 139 | MinimalLenght: 3 140 | WhiteList: 141 | - to 142 | - id 143 | -------------------------------------------------------------------------------- /ducalis.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'ducalis/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'ducalis' 9 | spec.version = Ducalis::VERSION 10 | spec.authors = ['Ignat Zakrevsky'] 11 | spec.email = ['iezakrevsky@gmail.com'] 12 | spec.summary = 'RuboCop based static code analyzer' 13 | spec.description = <<-DESCRIPTION 14 | Ducalis is RuboCop based static code analyzer for enterprise Rails \ 15 | applications. 16 | DESCRIPTION 17 | 18 | spec.homepage = 'https://github.com/ignat-z/ducalis' 19 | spec.license = 'MIT' 20 | 21 | if spec.respond_to?(:metadata) 22 | spec.metadata['source_code_uri'] = 'https://github.com/ignat-z/ducalis' 23 | end 24 | 25 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 26 | f.match(%r{^(test|spec|features|client)/}) 27 | end 28 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 29 | 30 | spec.add_dependency 'git', '~> 1.3', '>= 1.3.0' 31 | spec.add_dependency 'octokit', '>= 4.7.0' 32 | spec.add_dependency 'regexp-examples', '~> 1.3', '>= 1.3.2' 33 | spec.add_dependency 'regexp_parser', '>= 0.5.0' 34 | spec.add_dependency 'rubocop', '>= 0.45.0' 35 | end 36 | -------------------------------------------------------------------------------- /lib/ducalis.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop' 4 | 5 | module Ducalis 6 | DOTFILE = '.ducalis.yml'.freeze 7 | DUCALIS_HOME = File.realpath(File.join(File.dirname(__FILE__), '..')) 8 | DEFAULT_FILE = File.join(DUCALIS_HOME, 'config', DOTFILE) 9 | end 10 | 11 | require 'ducalis/version' 12 | require 'ducalis/errors' 13 | 14 | require 'ducalis/adapters/circle_ci' 15 | require 'ducalis/adapters/default' 16 | 17 | require 'ducalis/patched_rubocop/ducalis_config_loader' 18 | require 'ducalis/patched_rubocop/git_runner' 19 | require 'ducalis/patched_rubocop/git_turget_finder' 20 | require 'ducalis/patched_rubocop/inject' 21 | require 'ducalis/patched_rubocop/cop_cast' 22 | 23 | require 'ducalis/commentators/github' 24 | require 'ducalis/github_formatter' 25 | 26 | require 'ducalis/utils' 27 | require 'ducalis/diffs' 28 | require 'ducalis/rubo_cop' 29 | require 'ducalis/cli_arguments' 30 | require 'ducalis/patch' 31 | require 'ducalis/git_access' 32 | 33 | Dir[File.join('.', 'lib', 'ducalis', 'cops', '**', '*.rb')].each do |file| 34 | require file 35 | end 36 | -------------------------------------------------------------------------------- /lib/ducalis/adapters/circle_ci.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Adapters 4 | class CircleCI 5 | CODE = 'circleci'.freeze 6 | 7 | def self.suitable_for?(value) 8 | value == CODE 9 | end 10 | 11 | def initialize(_value); end 12 | 13 | def call 14 | [repo, id] 15 | end 16 | 17 | private 18 | 19 | def repo 20 | @repo ||= ENV.fetch('CIRCLE_REPOSITORY_URL') 21 | .sub('https://github.com/', '') 22 | .sub('git@github.com:', '') 23 | .sub('.git', '') 24 | end 25 | 26 | def id 27 | @id ||= ENV.fetch('CI_PULL_REQUEST') 28 | .split('/') 29 | .last 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/ducalis/adapters/default.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Adapters 4 | class Default 5 | def self.suitable_for?(_value) 6 | true 7 | end 8 | 9 | def initialize(value) 10 | @value = value 11 | end 12 | 13 | def call 14 | @value.split('#') 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/ducalis/cli_arguments.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ducalis 4 | class CliArguments 5 | ADAPTERS = [ 6 | Adapters::CircleCI, 7 | Adapters::Default 8 | ].freeze 9 | 10 | HELP_FLAGS = %w[-h -? --help].freeze 11 | FORMATTER = %w[--format GithubFormatter].freeze 12 | DOCS_ARG = :docs 13 | REPORTER_ARG = :reporter 14 | 15 | def docs_command? 16 | ARGV.any? { |arg| arg == to_key(DOCS_ARG) } 17 | end 18 | 19 | def help_command? 20 | ARGV.any? { |arg| HELP_FLAGS.include?(arg) } 21 | end 22 | 23 | def process! 24 | detect_git_mode! 25 | detect_reporter! 26 | end 27 | 28 | private 29 | 30 | def detect_reporter! 31 | reporter_index = ARGV.index(to_key(REPORTER_ARG)) || return 32 | reporter = ARGV[reporter_index + 1] 33 | [to_key(REPORTER_ARG), reporter].each { |arg| ARGV.delete(arg) } 34 | ARGV.push(*FORMATTER) 35 | GitAccess.instance.store_pull_request!(find_pull_request(reporter)) 36 | end 37 | 38 | def detect_git_mode! 39 | git_mode = GitAccess::MODES.keys.find do |mode| 40 | ARGV.include?(to_key(mode)) 41 | end 42 | return unless git_mode 43 | 44 | ARGV.delete(to_key(git_mode)) 45 | GitAccess.instance.flag = git_mode 46 | end 47 | 48 | def find_pull_request(value) 49 | ADAPTERS.find { |adapter| adapter.suitable_for?(value) }.new(value).call 50 | end 51 | 52 | def to_key(key) 53 | "--#{key}" 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/ducalis/commentators/github.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'ducalis/commentators/message' 4 | 5 | module Ducalis 6 | module Commentators 7 | class Github 8 | STATUS = 'COMMENT'.freeze 9 | SIMILARITY_THRESHOLD = 0.8 10 | 11 | def initialize(repo, id) 12 | @repo = repo 13 | @id = id 14 | end 15 | 16 | def call(offenses) 17 | comments = offenses.reject { |offense| already_commented?(offense) } 18 | .map { |offense| present_offense(offense) } 19 | 20 | return if comments.empty? 21 | 22 | Utils.octokit 23 | .create_pull_request_review(@repo, @id, 24 | event: STATUS, comments: comments) 25 | end 26 | 27 | private 28 | 29 | def already_commented?(offense) 30 | current_offence = present_offense(offense) 31 | commented_offenses.find do |commented_offense| 32 | [ 33 | current_offence[:path] == commented_offense[:path], 34 | current_offence[:position] == commented_offense[:position], 35 | similar_messages?(current_offence[:body], commented_offense[:body]) 36 | ].all? 37 | end 38 | end 39 | 40 | def similar_messages?(message, body) 41 | body.include?(message) || 42 | Utils.similarity(message, body) > SIMILARITY_THRESHOLD 43 | end 44 | 45 | def present_offense(offense) 46 | { 47 | body: Message.new(offense).with_link, 48 | path: diff_for(offense).path, 49 | position: diff_for(offense).patch_line(offense.line) 50 | } 51 | end 52 | 53 | def diff_for(offense) 54 | GitAccess.instance.for(offense.location.source_buffer.name) 55 | end 56 | 57 | def commented_offenses 58 | @commented_offenses ||= Utils.octokit.pull_request_comments(@repo, @id) 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/ducalis/commentators/message.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ducalis 4 | module Commentators 5 | class Message 6 | LINK_FORMAT = '[%s](<%s>)'.freeze 7 | SITE = 'https://ducalis-rb.github.io'.freeze 8 | 9 | def initialize(offense) 10 | @message = offense.message 11 | @cop_name = offense.cop_name 12 | end 13 | 14 | def with_link 15 | if @message.include?(@cop_name) 16 | @message.sub(@cop_name, cop_with_link) 17 | else 18 | [cop_with_link, ': ', @message].join 19 | end 20 | end 21 | 22 | private 23 | 24 | def cop_with_link 25 | format(LINK_FORMAT, cop_name: @cop_name, link: cop_link) 26 | end 27 | 28 | def cop_link 29 | URI.join(SITE, anchor).to_s 30 | end 31 | 32 | def anchor 33 | "##{@cop_name.delete('/').downcase}" 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/ducalis/cops/black_list_suffix.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop' 4 | 5 | module Ducalis 6 | class BlackListSuffix < RuboCop::Cop::Cop 7 | OFFENSE = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 8 | | Please, avoid using of class suffixes like `Manager`, `Client` and so on. If it has no parts, change the name of the class to what each object is managing. 9 | 10 | | It's ok to use Manager as subclass of Person, which is there to refine a type of personal that has management behavior to it. 11 | MESSAGE 12 | 13 | DETAILS = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 14 | | Related [article]() 15 | MESSAGE 16 | 17 | def on_class(node) 18 | classdef_node, _superclass, _body = *node 19 | return unless with_blacklisted_suffix?(classdef_node.source) 20 | 21 | add_offense(node, :expression, OFFENSE) 22 | end 23 | 24 | private 25 | 26 | def with_blacklisted_suffix?(name) 27 | return if cop_config['BlackList'].to_a.empty? 28 | 29 | cop_config['BlackList'].any? { |suffix| name.end_with?(suffix) } 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/ducalis/cops/callbacks_activerecord.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop' 4 | require 'ducalis/cops/extensions/type_resolving' 5 | 6 | module Ducalis 7 | class CallbacksActiverecord < RuboCop::Cop::Cop 8 | prepend TypeResolving 9 | 10 | OFFENSE = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 11 | | Please, avoid using of callbacks for models. It's better to keep models small ("dumb") and instead use "builder" classes/services: to construct new objects. 12 | MESSAGE 13 | 14 | DETAILS = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 15 | | You can read more [here](https://medium.com/planet-arkency/a61fd75ab2d3). 16 | MESSAGE 17 | 18 | METHODS_BLACK_LIST = %i[ 19 | after_commit 20 | after_create 21 | after_destroy 22 | after_find 23 | after_initialize 24 | after_rollback 25 | after_save 26 | after_touch 27 | after_update 28 | after_validation 29 | around_create 30 | around_destroy 31 | around_save 32 | around_update 33 | before_create 34 | before_destroy 35 | before_save 36 | before_update 37 | before_validation 38 | ].freeze 39 | 40 | def on_send(node) 41 | return unless in_model? 42 | return unless METHODS_BLACK_LIST.include?(node.method_name) 43 | 44 | add_offense(node, :selector, OFFENSE) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/ducalis/cops/case_mapping.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop' 4 | 5 | module Ducalis 6 | class CaseMapping < RuboCop::Cop::Cop 7 | OFFENSE = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 8 | | Try to avoid `case when` statements. You can replace it with a sequence of `if... elsif... elsif... else`. 9 | | For cases where you need to choose from a large number of possibilities, you can create a dictionary mapping case values to functions to call by `call`. It's nice to have prefix for the method names, i.e.: `visit_`. 10 | MESSAGE 11 | 12 | DETAILS = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 13 | | Usually `case when` statements are using for the next reasons: 14 | 15 | | I. Mapping between different values. 16 | | `("A" => 1, "B" => 2, ...)` 17 | 18 | | This case is all about data representing. If you do not need to execute any code it's better to use data structure which represents it. This way you are separating concepts: code returns corresponding value and you have config-like data structure which describes your data. 19 | 20 | | ```ruby 21 | | %w[A B ...].index("A") + 1 22 | | # or 23 | | { "A" => 1, "B" => 2 }.fetch("A") 24 | | ``` 25 | 26 | | II. Code execution depending of parameter or type: 27 | 28 | | - a. `(:attack => attack, :defend => defend)` 29 | | - b. `(Feet => value * 0.348, Meters => `value`)` 30 | 31 | | In this case code violates OOP and S[O]LID principle. Code shouldn't know about object type and classes should be open for extension, but closed for modification (but you can't do it with case-statements). This is a signal that you have some problems with architecture. 32 | 33 | | a. 34 | 35 | | ```ruby 36 | | attack: -> { execute_attack }, defend: -> { execute_defend } 37 | | #{(action = '#{' + 'action' + '}') && '# or'} 38 | | call(:"execute_#{action}") 39 | | ``` 40 | 41 | | b. 42 | 43 | | ```ruby 44 | | class Meters; def to_metters; value; end 45 | | class Feet; def to_metters; value * 0.348; end 46 | | ``` 47 | 48 | | III. Code execution depending on some statement. 49 | 50 | | ```ruby 51 | | (`a > 0` => 1, `a == 0` => 0, `a < 0` => -1) 52 | | ``` 53 | 54 | | This case is combination of I and II -- high code complexity and unit-tests complexity. There are variants how to solve it: 55 | 56 | | a. Rewrite to simple if statement 57 | 58 | | ```ruby 59 | | return 0 if a == 0 60 | | a > 0 ? 1 : -1 61 | | ``` 62 | 63 | | b. Move statements to lambdas: 64 | 65 | | ```ruby 66 | | ->(a) { a > 0 } => 1, 67 | | ->(a) { a == 0 } => 0, 68 | | ->(a) { a < 0 } => -1 69 | | ``` 70 | 71 | | This way decreases code complexity by delegating it to lambdas and makes it easy to unit-testing because it's easy to test pure lambdas. 72 | 73 | | Such approach is named [table-driven design](). Table-driven methods are schemes that allow you to look up information in a table rather than using logic statements (i.e. case, if). In simple cases, it's quicker and easier to use logic statements, but as the logic chain becomes more complex, table-driven code is simpler than complicated logic, easier to modify and more efficient. 74 | MESSAGE 75 | 76 | def on_case(node) 77 | add_offense(node, :expression, OFFENSE) 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/ducalis/cops/complex_cases/smart_delete_check.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ComplexCases 4 | class SmartDeleteCheck 5 | WHITE_LIST = %w[File cache file params attrs options].freeze 6 | 7 | def self.call(who, what, args) 8 | !new(who, what, args).false_positive? 9 | end 10 | 11 | def initialize(who, _what, args) 12 | @who = who 13 | @args = args 14 | end 15 | 16 | def false_positive? 17 | [ 18 | called_with_stringlike?, 19 | many_args?, 20 | whitelisted? 21 | ].any? 22 | end 23 | 24 | private 25 | 26 | def called_with_stringlike? 27 | %i[sym str].include?(@args.first && @args.first.type) 28 | end 29 | 30 | def many_args? 31 | @args.count > 1 32 | end 33 | 34 | def whitelisted? 35 | WHITE_LIST.any? { |regex| @who.to_s.include?(regex) } 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/ducalis/cops/complex_regex.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop' 4 | require 'regexp_parser' 5 | 6 | module Ducalis 7 | class ComplexRegex < RuboCop::Cop::Cop 8 | include RuboCop::Cop::DefNode 9 | 10 | OFFENSE = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 11 | | It seems like this regex is a little bit complex. It's better to increase code readability by using long form with "\\x". 12 | MESSAGE 13 | DEFAULT_COST = 0 14 | COMPLEX_TYPES_COSTS = { 15 | quantifier: 1, 16 | meta: 1, 17 | assertion: 1, 18 | group: 0.5 19 | }.freeze 20 | 21 | def on_begin(node) 22 | regex_using(node).each do |regex_desc| 23 | next if formatted?(regex_desc) || simple?(regex_desc.first) 24 | 25 | add_offense(regex_desc.first, :expression, OFFENSE) 26 | end 27 | end 28 | 29 | private 30 | 31 | def simple?(regex_node) 32 | Regexp::Scanner.scan( 33 | Regexp.new(regex_node.source) 34 | ).map do |type, _, _, _, _| 35 | COMPLEX_TYPES_COSTS.fetch(type, DEFAULT_COST) 36 | end.inject(:+) <= maximal_complexity 37 | end 38 | 39 | def maximal_complexity 40 | cop_config['MaxComplexity'] 41 | end 42 | 43 | def formatted?(regex_desc) 44 | regex_desc.size > 1 45 | end 46 | 47 | def_node_search :regex_long_form?, '(regopt :x)' 48 | def_node_search :regex_using, '(regexp $... (regopt ...))' 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/ducalis/cops/complex_statements.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop' 4 | 5 | module Ducalis 6 | class ComplexStatements < RuboCop::Cop::Cop 7 | include RuboCop::Cop::DefNode 8 | 9 | OFFENSE = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 10 | | Please, refactor this complex statement to a method with a meaningful name. 11 | MESSAGE 12 | MAXIMUM_OPERATORS = 2 13 | 14 | def on_if(node) 15 | return if bool_operator(node).count < MAXIMUM_OPERATORS 16 | 17 | add_offense(node, :expression, OFFENSE) 18 | end 19 | 20 | def_node_search :bool_operator, '({and or} ...)' 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/ducalis/cops/controllers_except.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop' 4 | 5 | module Ducalis 6 | class ControllersExcept < RuboCop::Cop::Cop 7 | OFFENSE = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 8 | | Prefer to use `:only` over `:except` in controllers because it's more explicit and will be easier to maintain for new developers. 9 | MESSAGE 10 | 11 | FILTERS = %i[before_filter after_filter around_filter 12 | before_action after_action around_action].freeze 13 | 14 | def on_send(node) 15 | _, method_name, *args = *node 16 | hash_node = args.find { |subnode| subnode.type == :hash } 17 | return unless FILTERS.include?(method_name) && hash_node 18 | 19 | type, _method_names = decomposite_hash(hash_node) 20 | return unless type == s(:sym, :except) 21 | 22 | add_offense(node, :selector, OFFENSE) 23 | end 24 | 25 | private 26 | 27 | def decomposite_hash(args) 28 | args.to_a.first.children.to_a 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/ducalis/cops/data_access_objects.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop' 4 | require 'ducalis/cops/extensions/type_resolving' 5 | 6 | module Ducalis 7 | class DataAccessObjects < RuboCop::Cop::Cop 8 | include RuboCop::Cop::DefNode 9 | prepend TypeResolving 10 | 11 | OFFENSE = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 12 | | It's a good practice to move code related to serialization/deserialization out of the controller. Consider of creating Data Access Object to separate the data access parts from the application logic. It will eliminate problems related to refactoring and testing. 13 | MESSAGE 14 | 15 | NODE_EXPRESSIONS = [ 16 | s(:send, nil, :session), 17 | s(:send, nil, :cookies), 18 | s(:gvar, :$redis), 19 | s(:send, s(:const, nil, :Redis), :current) 20 | ].freeze 21 | 22 | def on_send(node) 23 | return unless in_controller? 24 | return unless NODE_EXPRESSIONS.include?(node.to_a.first) 25 | 26 | add_offense(node, :expression, OFFENSE) 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/ducalis/cops/descriptive_block_names.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop' 4 | 5 | module Ducalis 6 | class DescriptiveBlockNames < RuboCop::Cop::Cop 7 | OFFENSE = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 8 | | Please, use descriptive names as block arguments. There is no any sense to save on letters. 9 | MESSAGE 10 | 11 | def on_block(node) 12 | _send, args, _inner = *node 13 | block_arguments(args).each do |violation_node| 14 | add_offense(violation_node, :expression, OFFENSE) 15 | end 16 | end 17 | 18 | private 19 | 20 | def violate?(node) 21 | node.to_s.length < minimal_length && 22 | !node.to_s.start_with?('_') && 23 | !white_list.include?(node.to_s) 24 | end 25 | 26 | def white_list 27 | cop_config.fetch('WhiteList') 28 | end 29 | 30 | def minimal_length 31 | cop_config.fetch('MinimalLenght').to_i 32 | end 33 | 34 | def_node_search :block_arguments, '(arg #violate?)' 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/ducalis/cops/enforce_namespace.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop' 4 | 5 | module Ducalis 6 | class EnforceNamespace < RuboCop::Cop::Cop 7 | prepend TypeResolving 8 | 9 | OFFENSE = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 10 | | Too improve code organization it is better to define namespaces to group services by high-level features, domains or any other dimension. 11 | MESSAGE 12 | 13 | def on_class(node) 14 | return if !node.parent.nil? || !in_service? 15 | 16 | add_offense(node, :expression, OFFENSE) 17 | end 18 | 19 | def on_module(node) 20 | return if !node.parent.nil? || !in_service? 21 | return if contains_class?(node) || contains_classes?(node) 22 | 23 | add_offense(node, :expression, OFFENSE) 24 | end 25 | 26 | def_node_search :contains_class?, '(module _ ({casgn module class} ...))' 27 | def_node_search :contains_classes?, 28 | '(module _ (begin ({casgn module class} ...) ...))' 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/ducalis/cops/evlis_overusing.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop' 4 | 5 | module Ducalis 6 | class EvlisOverusing < RuboCop::Cop::Cop 7 | prepend TypeResolving 8 | 9 | OFFENSE = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 10 | | Seems like you are overusing safe navigation operator. Try to use right method (ex: `dig` for hashes), null object pattern or ensure types via explicit conversion (`to_a`, `to_s` and so on). 11 | MESSAGE 12 | 13 | DETAILS = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 14 | | Related article: https://karolgalanciak.com/blog/2017/09/24/do-or-do-not-there-is-no-try-object-number-try-considered-harmful/ 15 | MESSAGE 16 | 17 | def on_send(node) 18 | return unless nested_try?(node) 19 | 20 | add_offense(node, :expression, OFFENSE) 21 | end 22 | 23 | def on_csend(node) 24 | return unless node.child_nodes.any?(&:csend_type?) 25 | 26 | add_offense(node, :expression, OFFENSE) 27 | end 28 | 29 | def_node_search :nested_try?, 30 | '(send (send _ {:try :try!} ...) {:try :try!} ...)' 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/ducalis/cops/extensions/type_resolving.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TypeResolving 4 | MODELS_CLASS_NAMES = %w[ 5 | ApplicationRecord 6 | ActiveRecord::Base 7 | ].freeze 8 | 9 | WORKERS_SUFFIXES = %w[ 10 | Worker 11 | Job 12 | ].freeze 13 | 14 | CONTROLLER_SUFFIXES = %w[ 15 | Controller 16 | ].freeze 17 | 18 | SERVICES_PATH = File.join('app', 'services') 19 | 20 | def on_class(node) 21 | classdef_node, superclass, _body = *node 22 | @node = node 23 | @class_name = classdef_node.loc.expression.source 24 | @superclass_name = superclass.loc.expression.source unless superclass.nil? 25 | super if defined?(super) 26 | end 27 | 28 | def on_module(node) 29 | @node = node 30 | super if defined?(super) 31 | end 32 | 33 | private 34 | 35 | def in_service? 36 | path = @node.location.expression.source_buffer.name 37 | services_path = cop_config.fetch('ServicePath') { SERVICES_PATH } 38 | path.include?(services_path) 39 | end 40 | 41 | def in_controller? 42 | return false if @superclass_name.nil? 43 | 44 | @superclass_name.end_with?(*CONTROLLER_SUFFIXES) 45 | end 46 | 47 | def in_model? 48 | MODELS_CLASS_NAMES.include?(@superclass_name) 49 | end 50 | 51 | def in_worker? 52 | @class_name.end_with?(*WORKERS_SUFFIXES) 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/ducalis/cops/facade_pattern.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop' 4 | require 'ducalis/cops/extensions/type_resolving' 5 | 6 | module Ducalis 7 | class FacadePattern < RuboCop::Cop::Cop 8 | include RuboCop::Cop::DefNode 9 | prepend TypeResolving 10 | 11 | OFFENSE = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 12 | | There are too many instance variables for one controller action. It's beetter to refactor it with Facade pattern to simplify the controller. 13 | MESSAGE 14 | 15 | DETAILS = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 16 | | Good article about [Facade](). 17 | MESSAGE 18 | 19 | def on_def(node) 20 | return unless in_controller? 21 | return if non_public?(node) 22 | 23 | assigns = instance_variables_matches(node) 24 | return if assigns.count < max_instance_variables 25 | 26 | assigns.each { |assign| add_offense(assign, :expression, OFFENSE) } 27 | end 28 | 29 | private 30 | 31 | def max_instance_variables 32 | cop_config.fetch('MaxInstanceVariables') 33 | end 34 | 35 | def_node_search :instance_variables_matches, '(ivasgn ...)' 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/ducalis/cops/fetch_expression.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop' 4 | 5 | module Ducalis 6 | class FetchExpression < RuboCop::Cop::Cop 7 | HASH_CALLING_REGEX = /\:\[\]/.freeze # params[:key] 8 | 9 | OFFENSE = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 10 | | You can use `fetch` instead: 11 | 12 | | ```ruby 13 | | %s 14 | | ``` 15 | 16 | | If your hash contains `nil` or `false` values and you want to treat them not like an actual values you should preliminarily remove this values from hash. 17 | | You can use `compact` (in case if you do not want to ignore `false` values) or `keep_if { |key, value| value }` (if you want to ignore all `false` and `nil` values). 18 | MESSAGE 19 | 20 | def investigate(processed_source) 21 | return unless processed_source.ast 22 | 23 | matching_nodes(processed_source.ast).each do |node| 24 | add_offense(node, :expression, format(OFFENSE, 25 | source: correct_variant(node))) 26 | end 27 | end 28 | 29 | private 30 | 31 | def matching_nodes(ast) 32 | [ 33 | *ternar_gets_present(ast).select(&method(:matching_ternar?)), 34 | *ternar_gets_nil(ast).select(&method(:matching_ternar?)), 35 | *default_gets(ast) 36 | ].uniq 37 | end 38 | 39 | def_node_search :default_gets, '(or (send (...) :[] (...)) (...))' 40 | def_node_search :ternar_gets_present, '(if (...) (send ...) (...))' 41 | def_node_search :ternar_gets_nil, '(if (send (...) :nil?) (...) (send ...))' 42 | 43 | def matching_ternar?(node) 44 | present_matching?(node) || nil_matching?(node) 45 | end 46 | 47 | def present_matching?(node) 48 | source, result, = *node 49 | (source == result && result.to_s =~ HASH_CALLING_REGEX) 50 | end 51 | 52 | def nil_matching?(node) 53 | source, _, result = *node 54 | (source.to_a.first == result && result.to_s =~ HASH_CALLING_REGEX) 55 | end 56 | 57 | def correct_variant(node) 58 | if nil_matching?(node) 59 | nil_correct(node) 60 | else 61 | present_correct(node) 62 | end 63 | end 64 | 65 | def nil_correct(node) 66 | hash, _, key = *node.to_a.last.to_a 67 | construct_fetch(hash, key, node.to_a[1]) 68 | end 69 | 70 | def present_correct(node) 71 | hash, _, key = *node.to_a.first.to_a 72 | construct_fetch(hash, key, node.to_a.last) 73 | end 74 | 75 | def construct_fetch(hash, key, default) 76 | [source(hash), ".fetch(#{source(key)})", " { #{source(default)} }"].join 77 | end 78 | 79 | def source(node) 80 | node.loc.expression.source 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/ducalis/cops/keyword_defaults.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop' 4 | 5 | module Ducalis 6 | class KeywordDefaults < RuboCop::Cop::Cop 7 | OFFENSE = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 8 | | Prefer to use keyword arguments for defaults. It increases readability and reduces ambiguities. 9 | | It is ok if an argument is single and the name obvious from the function declaration. 10 | MESSAGE 11 | 12 | def on_def(node) 13 | args = node.type == :defs ? node.to_a[2] : node.to_a[1] 14 | 15 | return if args.to_a.one? 16 | 17 | args.children.each do |arg_node| 18 | next unless arg_node.type == :optarg 19 | 20 | add_offense(node, :expression, OFFENSE) 21 | end 22 | end 23 | alias on_defs on_def 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/ducalis/cops/module_like_class.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop' 4 | 5 | module Ducalis 6 | class ModuleLikeClass < RuboCop::Cop::Cop 7 | include RuboCop::Cop::DefNode 8 | 9 | OFFENSE = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 10 | | Seems like it will be better to define initialize and pass %s there instead of each method. 11 | MESSAGE 12 | 13 | def on_class(node) 14 | _name, inheritance, body = *node 15 | return if !inheritance.nil? || body.nil? || allowed_include?(body) 16 | 17 | matched = matched_args(body) 18 | return if matched.empty? 19 | 20 | add_offense(node, :expression, 21 | format(OFFENSE, args: 22 | matched.map { |arg| "`#{arg}`" }.join(', '))) 23 | end 24 | 25 | private 26 | 27 | def allowed_include?(body) 28 | return if cop_config['AllowedIncludes'].to_a.empty? 29 | 30 | (all_includes(body) & cop_config['AllowedIncludes']).any? 31 | end 32 | 33 | def matched_args(body) 34 | methods_defintions = children(body).select(&public_method_definition?) 35 | return [] if methods_defintions.count == 1 && with_initialize?(body) 36 | 37 | methods_defintions.map(&method_args).inject(&:&).to_a 38 | end 39 | 40 | def children(body) 41 | (body.type != :begin ? s(:begin, body) : body).children 42 | end 43 | 44 | def all_includes(body) 45 | children(body).select(&method(:include_node?)) 46 | .map(&:to_a) 47 | .map { |_, _, node| node.loc.expression.source } 48 | .to_a 49 | end 50 | 51 | def public_method_definition? 52 | ->(node) { node.type == :def && !non_public?(node) && !initialize?(node) } 53 | end 54 | 55 | def method_args 56 | lambda do |n| 57 | _name, args = *n 58 | args.children 59 | .select { |node| node.type == :arg } 60 | .map { |node| node.loc.expression.source } 61 | end 62 | end 63 | 64 | def with_initialize?(body) 65 | children(body).find(&method(:initialize?)) 66 | end 67 | 68 | def_node_search :include_node?, '(send _ :include (...))' 69 | def_node_search :initialize?, '(def :initialize ...)' 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/ducalis/cops/multiple_times.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop' 4 | 5 | module Ducalis 6 | class MultipleTimes < RuboCop::Cop::Cop 7 | include RuboCop::Cop::DefNode 8 | 9 | OFFENSE = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 10 | | You should avoid multiple time-related calls to prevent bugs during the period junctions (like Time.now.day called twice in the same scope could return different values if you called it near 23:59:59). You can pass it as default keyword argument or assign to a local variable. 11 | MESSAGE 12 | 13 | DETAILS = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 14 | | Compare: 15 | 16 | | ```ruby 17 | | def period 18 | | Date.today..(Date.today + 1.day) 19 | | end 20 | | # vs 21 | | def period(today: Date.today) 22 | | today..(today + 1.day) 23 | | end 24 | | ``` 25 | 26 | MESSAGE 27 | 28 | def on_def(body) 29 | multiple = [date_methods(body), time_methods(body)].flat_map(&:to_a) 30 | return if multiple.count < 2 31 | 32 | multiple.each do |time_node| 33 | add_offense(time_node, :expression, OFFENSE) 34 | end 35 | end 36 | alias on_defs on_def 37 | alias on_send on_def 38 | 39 | def_node_search :date_methods, 40 | '(send (const _ :Date) {:today :current :yesterday})' 41 | def_node_search :time_methods, 42 | '(send (const _ :Time) {:current :now})' 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/ducalis/cops/only_defs.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop' 4 | 5 | module Ducalis 6 | class OnlyDefs < RuboCop::Cop::Cop 7 | include RuboCop::Cop::DefNode 8 | 9 | OFFENSE = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 10 | | Prefer object instances to class methods because class methods resist refactoring. Begin with an object instance, even if it doesn’t have state or multiple methods right away. If you come back to change it later, you will be more likely to refactor. If it never changes, the difference between the class method approach and the instance is negligible, and you certainly won’t be any worse off. 11 | MESSAGE 12 | 13 | DETAILS = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 14 | | Related article: https://codeclimate.com/blog/why-ruby-class-methods-resist-refactoring/ 15 | MESSAGE 16 | 17 | def on_class(node) 18 | _name, inheritance, body = *node 19 | return if !inheritance.nil? || body.nil? 20 | return unless !instance_methods_definitions?(body) && 21 | class_methods_defintions?(body) 22 | 23 | add_offense(node, :expression, OFFENSE) 24 | end 25 | 26 | private 27 | 28 | def instance_methods_definitions?(body) 29 | children(body).any?(&public_method_definition?) 30 | end 31 | 32 | def class_methods_defintions?(body) 33 | children(body).any?(&class_method_definition?) || 34 | children(body).any?(&method(:self_class_defs?)) 35 | end 36 | 37 | def public_method_definition? 38 | lambda do |node| 39 | node.type == :def && !non_public?(node) && !initialize?(node) 40 | end 41 | end 42 | 43 | def class_method_definition? 44 | lambda do |node| 45 | node.type == :defs && !non_public?(node) && !initialize?(node) 46 | end 47 | end 48 | 49 | def children(body) 50 | (body.type != :begin ? s(:begin, body) : body).children 51 | end 52 | 53 | def_node_search :initialize?, '(def :initialize ...)' 54 | def_node_search :self_class_defs?, ' (sclass (self) (begin ...))' 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/ducalis/cops/options_argument.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop' 4 | 5 | module Ducalis 6 | class OptionsArgument < RuboCop::Cop::Cop 7 | OFFENSE = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 8 | | Default `options` (or `args`) argument isn't good idea. It's better to explicitly pass which keys are you interested in as keyword arguments. You can use split operator to support hash arguments. 9 | MESSAGE 10 | 11 | DETAILS = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 12 | | Compare: 13 | 14 | | ```ruby 15 | | def generate_1(document, options = {}) 16 | | format = options.delete(:format) 17 | | limit = options.delete(:limit) || 20 18 | | # ... 19 | | [format, limit, options] 20 | | end 21 | 22 | | options = { format: 'csv', limit: 5, useless_arg: :value } 23 | | generate_1(1, options) #=> ["csv", 5, {:useless_arg=>:value}] 24 | | generate_1(1, format: 'csv', limit: 5, useless_arg: :value) #=> ["csv", 5, {:useless_arg=>:value}] 25 | 26 | | # vs 27 | 28 | | def generate_2(document, format:, limit: 20, **options) 29 | | # ... 30 | | [format, limit, options] 31 | | end 32 | 33 | | options = { format: 'csv', limit: 5, useless_arg: :value } 34 | | generate_2(1, **options) #=> ["csv", 5, {:useless_arg=>:value}] 35 | | generate_2(1, format: 'csv', limit: 5, useless_arg: :value) #=> ["csv", 5, {:useless_arg=>:value}] 36 | | ``` 37 | 38 | MESSAGE 39 | 40 | def on_def(node) 41 | return unless options_like_arg?(node) 42 | 43 | add_offense(node, :expression, OFFENSE) 44 | end 45 | 46 | def_node_search :options_like_arg?, '(${arg optarg} ${:options :args} ...)' 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/ducalis/cops/params_passing.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop' 4 | 5 | module Ducalis 6 | class ParamsPassing < RuboCop::Cop::Cop 7 | OFFENSE = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 8 | | It's better to pass already preprocessed params hash to services. Or you can use `arcane` gem. 9 | MESSAGE 10 | 11 | PARAMS_CALL = s(:send, nil, :params) 12 | 13 | def on_send(node) 14 | _who, _what, *args = *node 15 | node = inspect_args(args) 16 | add_offense(node, :expression, OFFENSE) if node 17 | end 18 | 19 | private 20 | 21 | def inspect_args(args) 22 | return if Array(args).empty? 23 | 24 | args.find { |arg| arg == PARAMS_CALL }.tap do |node| 25 | return node if node 26 | end 27 | inspect_hash(args.find { |arg| arg.type == :hash }) 28 | end 29 | 30 | def inspect_hash(args) 31 | return if args.nil? 32 | 33 | args.children.find { |arg| arg.to_a[1] == PARAMS_CALL } 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/ducalis/cops/possible_tap.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop' 4 | 5 | module Ducalis 6 | class PossibleTap < RuboCop::Cop::Cop 7 | OFFENSE = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 8 | | Consider of using `.tap`, default ruby [method]() which allows to replace intermediate variables with block, by this you are limiting scope pollution and make method scope more clear. 9 | | If it isn't possible, consider of moving it to method or even inline it. 10 | | 11 | MESSAGE 12 | 13 | DETAILS = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 14 | | [Related article](). 15 | MESSAGE 16 | 17 | PAIRS = { 18 | lvar: :lvasgn, 19 | ivar: :ivasgn 20 | }.freeze 21 | 22 | ASSIGNS = PAIRS.keys 23 | 24 | def on_def(node) 25 | _name, _args, body = *node 26 | return if body.nil? 27 | return unless (possibe_var = return_var?(body) || return_var_call?(body)) 28 | return unless (assign_node = find_assign(body, possibe_var)) 29 | 30 | add_offense(assign_node, :expression, OFFENSE) 31 | end 32 | 33 | private 34 | 35 | def unwrap_assign(node) 36 | node.type == :or_asgn ? node.children.first : node 37 | end 38 | 39 | def find_assign(body, var_node) 40 | subnodes(body).find do |subnode| 41 | unwrap_assign(subnode).type == PAIRS[var_node.type] && 42 | unwrap_assign(subnode).to_a.first == var_node.to_a.first 43 | end 44 | end 45 | 46 | def return_var?(body) 47 | return unless body.children.last.respond_to?(:type) 48 | return unless ASSIGNS.include?(body.children.last.type) 49 | 50 | body.children.last 51 | end 52 | 53 | def return_var_call?(body) 54 | return unless last_child(body).respond_to?(:children) 55 | return if last_child(body).type == :if 56 | 57 | subnodes(last_child(body).to_a.first).find do |node| 58 | ASSIGNS.include?(node.type) 59 | end 60 | end 61 | 62 | def subnodes(node) 63 | return [] unless node.respond_to?(:children) 64 | 65 | ([node] + node.children).select { |child| child.respond_to?(:type) } 66 | end 67 | 68 | def last_child(body) 69 | body.children.last 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/ducalis/cops/preferable_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop' 4 | require 'ducalis/cops/complex_cases/smart_delete_check' 5 | 6 | module Ducalis 7 | class PreferableMethods < RuboCop::Cop::Cop 8 | OFFENSE = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 9 | | Prefer to use %s method instead of %s because of %s. 10 | MESSAGE 11 | 12 | ALWAYS_TRUE = ->(_who, _what, _args) { true } 13 | 14 | VALIDATE_CHECK = lambda do |_who, _what, args| 15 | (args.first && args.first.source) =~ /validate/ 16 | end 17 | 18 | DESCRIPTION = { 19 | # Method => [ 20 | # Alternative, 21 | # Reason, 22 | # Callable condition 23 | # ] 24 | toggle!: [ 25 | '`toggle.save`', 26 | 'it is not invoking validations', 27 | ALWAYS_TRUE 28 | ], 29 | save: [ 30 | '`save`', 31 | 'it is not invoking validations', 32 | VALIDATE_CHECK 33 | ], 34 | delete: [ 35 | '`destroy`', 36 | 'it is not invoking callbacks', 37 | ComplexCases::SmartDeleteCheck 38 | ], 39 | delete_all: [ 40 | '`destroy_all`', 41 | 'it is not invoking callbacks', 42 | ALWAYS_TRUE 43 | ], 44 | update_attribute: [ 45 | '`update` (`update_attributes` for Rails versions < 4)', 46 | 'it is not invoking validations', 47 | ALWAYS_TRUE 48 | ], 49 | update_column: [ 50 | '`update` (`update_attributes` for Rails versions < 4)', 51 | 'it is not invoking callbacks', 52 | ALWAYS_TRUE 53 | ], 54 | update_columns: [ 55 | '`update` (`update_attributes` for Rails versions < 4)', 56 | 'it is not invoking validations, callbacks and updated_at', 57 | ALWAYS_TRUE 58 | ] 59 | }.freeze 60 | 61 | DETAILS = "Dangerous methods are: 62 | #{DESCRIPTION.keys.map { |name| "`#{name}`" }.join(', ')}.".freeze 63 | 64 | def on_send(node) 65 | who, what, *args = *node 66 | return unless DESCRIPTION.key?(what) 67 | 68 | alternative, reason, condition = DESCRIPTION.fetch(what) 69 | return unless condition.call(who, what, args) 70 | 71 | add_offense(node, :expression, format(OFFENSE, original: what, 72 | alternative: alternative, 73 | reason: reason)) 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/ducalis/cops/private_instance_assign.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop' 4 | require 'ducalis/cops/extensions/type_resolving' 5 | 6 | module Ducalis 7 | class PrivateInstanceAssign < RuboCop::Cop::Cop 8 | include RuboCop::Cop::DefNode 9 | prepend TypeResolving 10 | 11 | OFFENSE = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 12 | | Don't use controller's filter methods for setting instance variables, use them only for changing application flow, such as redirecting if a user is not authenticated. Controller instance variables are forming contract between controller and view. Keeping instance variables defined in one place makes it easier to: reason, refactor and remove old views, test controllers and views, extract actions to new controllers, etc. 13 | MESSAGE 14 | 15 | ADD_OFFENSE = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 16 | If you want to memoize variable, please, add underscore to the variable name start: `@_name`. 17 | MESSAGE 18 | 19 | DETAILS = ADD_OFFENSE 20 | 21 | def on_ivasgn(node) 22 | return unless in_controller? 23 | return unless non_public?(node) 24 | return check_memo(node) if node.parent.type == :or_asgn 25 | 26 | add_offense(node, :expression, OFFENSE) 27 | end 28 | 29 | private 30 | 31 | def check_memo(node) 32 | return if node.to_a.first.to_s.start_with?('@_') 33 | 34 | add_offense(node, :expression, [OFFENSE, ADD_OFFENSE].join(' ')) 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/ducalis/cops/protected_scope_cop.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop' 4 | 5 | module Ducalis 6 | class ProtectedScopeCop < RuboCop::Cop::Cop 7 | OFFENSE = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 8 | | Seems like you are using `find` on non-protected scope. Potentially it could lead to unauthorized access. It's better to call `find` on authorized resources scopes. 9 | MESSAGE 10 | 11 | DETAILS = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 12 | | Example: 13 | 14 | | ```ruby 15 | | current_group.employees.find(params[:id]) 16 | | # better then 17 | | Employee.find(params[:id]) 18 | | ``` 19 | 20 | MESSAGE 21 | 22 | def on_send(node) 23 | return unless [find_method?(node), find_by_id?(node)].any? 24 | return unless const_like?(node) 25 | 26 | add_offense(node, :expression, OFFENSE) 27 | end 28 | 29 | def_node_search :const_like?, '(const ...)' 30 | def_node_search :find_method?, '(send (...) :find (...))' 31 | def_node_search :find_by_id?, 32 | '(send (...) :find_by (:hash (:pair (:sym :id) (...))))' 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/ducalis/cops/public_send.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop' 4 | 5 | module Ducalis 6 | class PublicSend < RuboCop::Cop::Cop 7 | OFFENSE = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 8 | | You should avoid of using `send`-like method in production code. You can rewrite it as a hash with lambdas and fetch necessary actions or rewrite it as a module which you can include in code. 9 | MESSAGE 10 | 11 | def on_send(node) 12 | return unless send_call?(node) 13 | 14 | add_offense(node, :expression, OFFENSE) 15 | end 16 | 17 | def_node_matcher :send_call?, '(send _ ${:send :public_send :__send__} ...)' 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/ducalis/cops/raise_without_error_class.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop' 4 | 5 | module Ducalis 6 | class RaiseWithoutErrorClass < RuboCop::Cop::Cop 7 | OFFENSE = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 8 | | It's better to add exception class as raise argument. It will make easier to catch and process it later. 9 | MESSAGE 10 | 11 | def on_send(node) 12 | _who, what, *args = *node 13 | return if what != :raise 14 | return if args.first && args.first.type != :str 15 | 16 | add_offense(node, :expression, OFFENSE) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/ducalis/cops/recursion.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop' 4 | 5 | module Ducalis 6 | class Recursion < RuboCop::Cop::Cop 7 | OFFENSE = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 8 | It seems like you are using recursion in your code. In common, it is not a bad idea, but try to keep your business logic layer free from refursion code. 9 | MESSAGE 10 | 11 | def on_def(node) 12 | @method_name, _args, body = *node 13 | return unless body 14 | return unless send_call?(body) || send_self_call?(body) 15 | 16 | add_offense(node, :expression, OFFENSE) 17 | end 18 | 19 | private 20 | 21 | def call_itself?(call_name) 22 | @method_name == call_name 23 | end 24 | 25 | def_node_search :send_call?, '(send nil? #call_itself? ...)' 26 | def_node_search :send_self_call?, '(send (self) #call_itself? ...)' 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/ducalis/cops/regex_cop.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop' 4 | require 'regexp-examples' 5 | 6 | module Ducalis 7 | class RegexCop < RuboCop::Cop::Cop 8 | include RuboCop::Cop::DefNode 9 | 10 | OFFENSE = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 11 | | It's better to move regex to constants with example instead of direct using it. It will allow you to reuse this regex and provide instructions for others. 12 | 13 | | Example: 14 | 15 | | ```ruby 16 | | CONST_NAME = %s # "%s" 17 | | %s 18 | | ``` 19 | 20 | MESSAGE 21 | 22 | SELF_DESCRIPTIVE = %w( 23 | /[[:alnum:]]/ 24 | /[[:alpha:]]/ 25 | /[[:blank:]]/ 26 | /[[:cntrl:]]/ 27 | /[[:digit:]]/ 28 | /[[:graph:]]/ 29 | /[[:lower:]]/ 30 | /[[:print:]]/ 31 | /[[:punct:]]/ 32 | /[[:space:]]/ 33 | /[[:upper:]]/ 34 | /[[:xdigit:]]/ 35 | /[[:word:]]/ 36 | /[[:ascii:]]/ 37 | ).freeze 38 | 39 | DETAILS = "Available regexes are: 40 | #{SELF_DESCRIPTIVE.map { |name| "`#{name}`" }.join(', ')}".freeze 41 | 42 | DEFAULT_EXAMPLE = 'some_example'.freeze 43 | 44 | def on_begin(node) 45 | not_defined_regexes(node).each do |regex| 46 | next if SELF_DESCRIPTIVE.include?(regex.source) || const_dynamic?(regex) 47 | 48 | add_offense(regex, :expression, format(OFFENSE, present_node(regex))) 49 | end 50 | end 51 | 52 | private 53 | 54 | def_node_search :const_using, '(regexp $_ ... (regopt))' 55 | def_node_search :const_definition, '(casgn ...)' 56 | 57 | def not_defined_regexes(node) 58 | const_using(node).reject do |regex| 59 | defined_as_const?(regex, const_definition(node)) 60 | end.map(&:parent) 61 | end 62 | 63 | def defined_as_const?(regex, definitions) 64 | definitions.any? { |node| const_using(node).any? { |use| use == regex } } 65 | end 66 | 67 | def const_dynamic?(node) 68 | node.child_nodes.any?(&:begin_type?) 69 | end 70 | 71 | def present_node(node) 72 | { 73 | constant: node.source, 74 | fixed_string: node.source_range.source_line 75 | .sub(node.source, 'CONST_NAME').lstrip, 76 | example: regex_sample(node) 77 | } 78 | end 79 | 80 | def regex_sample(node) 81 | Regexp.new(node.to_a.first.to_a.first).examples.sample 82 | rescue RegexpExamples::IllegalSyntaxError 83 | DEFAULT_EXAMPLE 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/ducalis/cops/rest_only_cop.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop' 4 | require 'ducalis/cops/extensions/type_resolving' 5 | 6 | module Ducalis 7 | class RestOnlyCop < RuboCop::Cop::Cop 8 | include RuboCop::Cop::DefNode 9 | prepend TypeResolving 10 | 11 | OFFENSE = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 12 | | It's better for controllers to stay adherent to REST: 13 | | http://jeromedalbert.com/how-dhh-organizes-his-rails-controllers/. 14 | MESSAGE 15 | 16 | DETAILS = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 17 | | [About RESTful architecture]() 18 | MESSAGE 19 | 20 | WHITELIST = %i[index show new edit create update destroy].freeze 21 | 22 | def on_def(node) 23 | return unless in_controller? 24 | return if non_public?(node) 25 | 26 | method_name, = *node 27 | return if WHITELIST.include?(method_name) 28 | 29 | add_offense(node, :expression, OFFENSE) 30 | end 31 | alias on_defs on_def 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/ducalis/cops/rubocop_disable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop' 4 | 5 | module Ducalis 6 | class RubocopDisable < RuboCop::Cop::Cop 7 | OFFENSE = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 8 | | Please, do not suppress RuboCop metrics, may be you can introduce some refactoring or another concept. 9 | MESSAGE 10 | 11 | def investigate(processed_source) 12 | return unless processed_source.ast 13 | 14 | processed_source.comments.each do |comment_node| 15 | next unless comment_node.loc.expression.source =~ /rubocop:disable/ 16 | 17 | add_offense(comment_node, :expression, OFFENSE) 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/ducalis/cops/standard_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop' 4 | 5 | module Ducalis 6 | class StandardMethods < RuboCop::Cop::Cop 7 | BLACK_LIST = [Object].flat_map { |klass| klass.new.methods } 8 | 9 | OFFENSE = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 10 | | Please, be sure that you really want to redefine standard ruby methods. 11 | | You should know what are you doing and all consequences. 12 | MESSAGE 13 | 14 | def on_def(node) 15 | name, _args, _body = *node 16 | return unless BLACK_LIST.include?(name) 17 | 18 | add_offense(node, :expression, OFFENSE) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/ducalis/cops/strings_in_activerecords.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop' 4 | require_relative './callbacks_activerecord' 5 | 6 | module Ducalis 7 | class StringsInActiverecords < RuboCop::Cop::Cop 8 | OFFENSE = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 9 | | Please, do not use strings as arguments for %s argument. It's hard to test, grep sources, code highlighting and so on. Consider using of symbols or lambdas for complex expressions. 10 | MESSAGE 11 | 12 | VALIDATEBLE_METHODS = 13 | ::Ducalis::CallbacksActiverecord::METHODS_BLACK_LIST + %i[ 14 | validates 15 | validate 16 | ] 17 | 18 | def on_send(node) 19 | _, method_name, *args = *node 20 | return unless VALIDATEBLE_METHODS.include?(method_name) 21 | return if args.empty? 22 | 23 | node.to_a.last.each_child_node do |current_node| 24 | next if skip_node?(current_node) 25 | 26 | add_offense(node, :selector, format(OFFENSE, method_name: method_name)) 27 | end 28 | end 29 | 30 | private 31 | 32 | def skip_node?(current_node) 33 | key, value = *current_node 34 | return true unless current_node.type == :pair 35 | return true unless %w[if unless].include?(key.source) 36 | return true unless value.type == :str 37 | 38 | false 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/ducalis/cops/too_long_workers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop' 4 | require 'ducalis/cops/extensions/type_resolving' 5 | 6 | module Ducalis 7 | class TooLongWorkers < RuboCop::Cop::Cop 8 | include RuboCop::Cop::ClassishLength 9 | prepend TypeResolving 10 | 11 | OFFENSE = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 12 | | Seems like your worker is doing too much work, consider of moving business logic to service object. As rule, workers should have only two responsibilities: 13 | | - __Model materialization__: As async jobs working with serialized attributes it's nescessary to cast them into actual objects. 14 | | - __Errors handling__: Rescue errors and figure out what to do with them. 15 | MESSAGE 16 | 17 | def on_class(node) 18 | return unless in_worker? 19 | 20 | length = code_length(node) 21 | return unless length > max_length 22 | 23 | add_offense(node, :expression, "#{OFFENSE} [#{length}/#{max_length}]") 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/ducalis/cops/uncommented_gem.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop' 4 | 5 | module Ducalis 6 | class UncommentedGem < RuboCop::Cop::Cop 7 | OFFENSE = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 8 | | Please, add comment why are you including non-realized gem version for %s. 9 | MESSAGE 10 | 11 | DETAILS = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 12 | | It will increase [bus-factor](). 13 | MESSAGE 14 | 15 | ALLOWED_KEYS = %w[require group :require :group].freeze 16 | 17 | def investigate(processed_source) 18 | return unless processed_source.ast 19 | 20 | gem_declarations(processed_source.ast).select do |node| 21 | _, _, gemname, _args = *node 22 | next if commented?(processed_source, node) 23 | 24 | add_offense(node, :selector, 25 | format(OFFENSE, gem: gemname.loc.expression.source)) 26 | end 27 | end 28 | 29 | private 30 | 31 | def_node_search :gem_declarations, '(send _ :gem str #allowed_args?)' 32 | 33 | def commented?(processed_source, node) 34 | processed_source.comments 35 | .map { |subnode| subnode.loc.line } 36 | .include?(node.loc.line) 37 | end 38 | 39 | def allowed_args?(args) 40 | return false if args.nil? || args.type != :hash 41 | 42 | args.children.any? do |arg_node| 43 | !ALLOWED_KEYS.include?(arg_node.children.first.source) 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/ducalis/cops/unlocked_gem.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop' 4 | 5 | module Ducalis 6 | class UnlockedGem < RuboCop::Cop::Cop 7 | OFFENSE = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 8 | | It's better to lock gem versions explicitly with pessimistic operator (~>). 9 | MESSAGE 10 | 11 | def investigate(processed_source) 12 | return unless processed_source.ast 13 | 14 | gem_declarations(processed_source.ast).select do |node| 15 | _, _, gemname, _args = *node 16 | add_offense(node, :selector, 17 | format(OFFENSE, gem: gemname.loc.expression.source)) 18 | end 19 | end 20 | 21 | def_node_search :gem_declarations, '(send _ :gem (str _))' 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/ducalis/cops/useless_only.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop' 4 | 5 | module Ducalis 6 | class UselessOnly < RuboCop::Cop::Cop 7 | OFFENSE = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 8 | | Seems like there is no any reason to keep before filter only for one action. Maybe it will be better to inline it? 9 | MESSAGE 10 | 11 | DETAILS = <<-MESSAGE.gsub(/^ +\|\s/, '').strip 12 | | Compare: 13 | 14 | | ```ruby 15 | | before_filter :do_something, only: %i[index] 16 | | def index; end 17 | 18 | | # to 19 | 20 | | def index 21 | | do_something 22 | | end 23 | 24 | | ``` 25 | 26 | MESSAGE 27 | 28 | FILTERS = %i[before_filter after_filter around_filter 29 | before_action after_action around_action].freeze 30 | 31 | def on_send(node) 32 | _, method_name, *args = *node 33 | hash_node = args.find { |subnode| subnode.type == :hash } 34 | return unless FILTERS.include?(method_name) && hash_node 35 | 36 | type, method_names = decomposite_hash(hash_node) 37 | return unless type == s(:sym, :only) 38 | return unless method_names.children.count == 1 39 | 40 | add_offense(node, :selector, OFFENSE) 41 | end 42 | 43 | private 44 | 45 | def decomposite_hash(args) 46 | args.to_a.first.children.to_a 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/ducalis/diffs.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Diffs 4 | class BaseDiff 5 | attr_reader :diff, :path 6 | 7 | def initialize(diff, path) 8 | @diff = diff 9 | @path = path 10 | end 11 | end 12 | 13 | class NilDiff < BaseDiff 14 | def changed?(*) 15 | true 16 | end 17 | 18 | def patch_line(*) 19 | -1 20 | end 21 | end 22 | 23 | class GitDiff < BaseDiff 24 | def changed?(changed_line) 25 | patch.line_for(changed_line).changed? 26 | end 27 | 28 | def patch_line(changed_line) 29 | patch.line_for(changed_line).patch_position 30 | end 31 | 32 | private 33 | 34 | def patch 35 | Ducalis::Patch.new(diff.patch) 36 | end 37 | end 38 | 39 | private_constant :BaseDiff, :NilDiff, :GitDiff 40 | end 41 | -------------------------------------------------------------------------------- /lib/ducalis/documentation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'parser/current' 4 | 5 | # This class could be used to dynamically generate documentation from cops spec. 6 | # It recognizes bad and good examples by signal words like `raises`. Additional 7 | # information for documentation could be passed by setting `DETAILS` constant. 8 | class SpecsProcessor < Parser::AST::Processor 9 | attr_reader :cases 10 | 11 | LINE_BEGIN_OPEN_SQUARE_BRACKET = /\A\[/.freeze # "/[/1, 2, 3]\n" 12 | CLOSE_SQUARE_BRACKET_END_LINE = /\]\z/.freeze # "[1, 2, 3/]\n/" 13 | LINE_BEGIN_QUOTE = /\A[\'|\"]/.freeze # "/'/idddqd'," 14 | QUOTE_COMMA_END_LINE = /[\'|\"]\,?\z/.freeze # "'iddqd/',/" 15 | 16 | def initialize(*) 17 | super 18 | @cases = [] 19 | @nesting = [] 20 | end 21 | 22 | def process(node) 23 | @nesting.push(node) 24 | super 25 | @nesting.pop 26 | end 27 | 28 | def on_send(node) 29 | _, name, _body = *node 30 | cases << [current_it, source_code(node)] if name == :inspect_source 31 | super 32 | end 33 | 34 | private 35 | 36 | def source_code(node) 37 | prepare_code(node).tap do |code| 38 | code.shift if code.first.to_s.empty? 39 | code.pop if code.last.to_s.empty? 40 | end 41 | end 42 | 43 | def prepare_code(node) 44 | remove_array_wrapping(node.to_a.last.loc.expression.source) 45 | .split("\n") 46 | .map { |line| remove_string_wrapping(line) } 47 | end 48 | 49 | def current_it 50 | it_block = @nesting.reverse.find { |node| node.type == :block } 51 | it = it_block.to_a.first 52 | _, _, message_node = *it 53 | message_node.to_a.first 54 | end 55 | 56 | def remove_array_wrapping(source) 57 | source.sub(LINE_BEGIN_OPEN_SQUARE_BRACKET, '') 58 | .sub(CLOSE_SQUARE_BRACKET_END_LINE, '') 59 | end 60 | 61 | def remove_string_wrapping(line) 62 | line.strip.sub(LINE_BEGIN_QUOTE, '') 63 | .sub(QUOTE_COMMA_END_LINE, '') 64 | end 65 | end 66 | 67 | class Documentation 68 | SIGNAL_WORD = 'raises'.freeze 69 | PREFER_WORD = 'better'.freeze 70 | RULE_WORD = '[rule]'.freeze 71 | 72 | def cop_rules 73 | cops.map do |file| 74 | rules = spec_cases_for(file).select do |desc, _code| 75 | desc.include?(RULE_WORD) 76 | end 77 | [file, rules] 78 | end 79 | end 80 | 81 | def call 82 | cops.map do |file| 83 | present_cop(klass_const_for(file), spec_cases_for(file)) 84 | end.flatten.join("\n") 85 | end 86 | 87 | private 88 | 89 | def cops 90 | Dir[File.join(File.dirname(__FILE__), 'cops', '*.rb')].sort 91 | end 92 | 93 | def present_cop(klass, specs) 94 | [ 95 | "## #{klass}\n", # header 96 | message(klass) + "\n" # description 97 | ] + 98 | specs.map do |(it, code)| 99 | [ 100 | prepare(it).to_s, # case description 101 | "\n```ruby\n#{mention(it)}\n#{code.join("\n")}\n```\n" # code example 102 | ] 103 | end 104 | end 105 | 106 | def prepare(it_description) 107 | it_description.sub("#{RULE_WORD} ", '') 108 | end 109 | 110 | def mention(it_description) 111 | it_description.include?(SIGNAL_WORD) ? '# bad' : '# good' 112 | end 113 | 114 | def message(klass) 115 | [ 116 | klass.const_get(:OFFENSE), 117 | *(klass.const_get(:DETAILS) if klass.const_defined?(:DETAILS)) 118 | ].join("\n") 119 | end 120 | 121 | def spec_cases_for(file) 122 | source_code = File.read( 123 | file.sub('/lib/', '/spec/') 124 | .sub(/.rb$/, '_spec.rb') 125 | ) 126 | SpecsProcessor.new.tap do |processor| 127 | processor.process(Parser::CurrentRuby.parse(source_code)) 128 | end.cases.select(&method(:allowed?)) 129 | end 130 | 131 | def allowed?(example) 132 | desc, _code = example 133 | desc.include?(RULE_WORD) 134 | end 135 | 136 | def klass_const_for(file) 137 | require file 138 | Ducalis.const_get(camelize(File.basename(file).sub(/.rb$/, ''))) 139 | end 140 | 141 | def camelize(snake_case_word) 142 | snake_case_word.sub(/^[a-z\d]*/, &:capitalize).tap do |string| 143 | string.gsub!(%r{(?:_|(/))([a-z\d]*)}i) do 144 | "#{Regexp.last_match(1)}#{Regexp.last_match(2).capitalize}" 145 | end 146 | string.gsub!('/', '::') 147 | end 148 | end 149 | end 150 | -------------------------------------------------------------------------------- /lib/ducalis/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ducalis 4 | class MissingGit < ::StandardError 5 | MESSAGE = "Can't find .git folder.".freeze 6 | 7 | def initialize(msg = MESSAGE) 8 | super 9 | end 10 | end 11 | 12 | class MissingToken < ::StandardError 13 | MESSAGE = 'You should provide token in order to interact with GitHub'.freeze 14 | 15 | def initialize(msg = MESSAGE) 16 | super 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/ducalis/git_access.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'git' 4 | require 'singleton' 5 | 6 | class GitAccess 7 | DELETED = 'deleted'.freeze 8 | GIT_DIR = '.git'.freeze 9 | 10 | MODES = { 11 | branch: ->(git) { git.diff('origin/master') }, 12 | index: ->(git) { git.diff('HEAD') } 13 | }.freeze 14 | 15 | include Diffs 16 | include Singleton 17 | 18 | attr_accessor :flag, :repo, :id 19 | 20 | def store_pull_request!(info) 21 | repo, id = info 22 | self.repo = repo 23 | self.id = id 24 | end 25 | 26 | def changed_files 27 | changes.map(&:path) 28 | end 29 | 30 | def for(path) 31 | return find(path) unless path.include?(Dir.pwd) 32 | 33 | find(Pathname.new(path).relative_path_from(Pathname.new(Dir.pwd)).to_s) 34 | end 35 | 36 | private 37 | 38 | def under_git? 39 | @under_git ||= Dir.exist?(File.join(Dir.pwd, GIT_DIR)) 40 | end 41 | 42 | def changes 43 | return default_value if flag.nil? || !under_git? 44 | 45 | @changes ||= patch_diffs 46 | end 47 | 48 | def patch_diffs 49 | MODES.fetch(flag) 50 | .call(Git.open(Dir.pwd)) 51 | .reject { |diff| diff.type == DELETED } 52 | .select { |diff| File.exist?(diff.path) } 53 | .map { |diff| GitDiff.new(diff, diff.path) } 54 | end 55 | 56 | def default_value 57 | raise Ducalis::MissingGit unless flag.nil? 58 | 59 | [] 60 | end 61 | 62 | def find(path) 63 | changes.find { |diff| diff.path == path } || NilDiff.new(nil, path) 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/ducalis/github_formatter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class GithubFormatter < RuboCop::Formatter::BaseFormatter 4 | attr_reader :all 5 | 6 | def started(_target_files) 7 | @all = [] 8 | end 9 | 10 | def file_finished(_file, offenses) 11 | print '.' 12 | @all << offenses unless offenses.empty? 13 | end 14 | 15 | def finished(_inspected_files) 16 | print "\n" 17 | Ducalis::Commentators::Github.new( 18 | GitAccess.instance.repo, 19 | GitAccess.instance.id 20 | ).call(@all.flatten) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/ducalis/patch.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ducalis 4 | class Patch 5 | RANGE_LINE = /^@@ .+\+(?\d+),/.freeze 6 | MODIFIED_LINE = /^\+(?!\+|\+)/.freeze 7 | NOT_REMOVED_LINE = /^[^-]/.freeze 8 | ANY_LINE = /.*/.freeze 9 | 10 | DIFF_LINES = { 11 | RANGE_LINE => lambda do |lines, _line_number, line, _position| 12 | [lines, line.match(RANGE_LINE)[:line_number].to_i] 13 | end, 14 | MODIFIED_LINE => lambda do |lines, line_number, line, position| 15 | [lines + [Line.new(line_number, line, position)], line_number + 1] 16 | end, 17 | NOT_REMOVED_LINE => lambda do |lines, line_number, _line, _position| 18 | [lines, line_number + 1] 19 | end, 20 | ANY_LINE => lambda do |lines, line_number, _line, _position| 21 | [lines, line_number] 22 | end 23 | }.freeze 24 | 25 | def initialize(patch) 26 | diff_only = patch[patch.match(RANGE_LINE).begin(0)..-1] 27 | @patch_lines = diff_only.lines.to_enum.with_index 28 | end 29 | 30 | def line_for(line_number) 31 | changed_lines.detect do |line| 32 | line.number == line_number 33 | end || UnchangedLine.new 34 | end 35 | 36 | private 37 | 38 | attr_reader :patch_lines 39 | 40 | def changed_lines 41 | patch_lines.inject([[], 0]) do |(lines, line_number), (line, position)| 42 | _regex, action = DIFF_LINES.find { |regex, _action| line =~ regex } 43 | action.call(lines, line_number, line, position) 44 | end.first 45 | end 46 | end 47 | 48 | class UnchangedLine 49 | def initialize(*); end 50 | 51 | def patch_position 52 | -1 53 | end 54 | 55 | def changed? 56 | false 57 | end 58 | end 59 | 60 | class Line 61 | attr_reader :number, :content, :patch_position 62 | 63 | def initialize(number, content, patch_position) 64 | @number = number 65 | @content = content 66 | @patch_position = patch_position 67 | end 68 | 69 | def changed? 70 | true 71 | end 72 | end 73 | private_constant :Line, :UnchangedLine 74 | end 75 | -------------------------------------------------------------------------------- /lib/ducalis/patched_rubocop/cop_cast.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PatchedRubocop 4 | module CopCast 5 | def add_offense(node, loc, message = nil, severity = nil) 6 | if PatchedRubocop::CURRENT_VERSION > PatchedRubocop::ADAPTED_VERSION 7 | super(node, location: loc, message: message, severity: severity) 8 | else 9 | super(node, loc, message, severity) 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/ducalis/patched_rubocop/ducalis_config_loader.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PatchedRubocop 4 | module DucalisConfigLoader 5 | def configuration_file_for(target_dir) 6 | config = super 7 | if config == RuboCop::ConfigLoader::DEFAULT_FILE 8 | ::Ducalis::DEFAULT_FILE 9 | else 10 | config 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/ducalis/patched_rubocop/git_runner.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PatchedRubocop 4 | module GitRunner 5 | def inspect_file(file) 6 | offenses, updated = super 7 | offenses = offenses.select do |offense| 8 | GitAccess.instance.for(file.path).changed?(offense.line) 9 | end 10 | 11 | [offenses, updated] 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/ducalis/patched_rubocop/git_turget_finder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PatchedRubocop 4 | module GitTurgetFinder 5 | def find_files(base_dir, flags) 6 | replacement = GitAccess.instance.changed_files 7 | return replacement unless replacement.empty? 8 | 9 | super 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/ducalis/patched_rubocop/inject.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PatchedRubocop 4 | module Inject 5 | PATH = Ducalis::DEFAULT_FILE.to_s 6 | 7 | def self.defaults! 8 | hash = RuboCop::ConfigLoader.send(:load_yaml_configuration, PATH) 9 | config = RuboCop::Config.new(hash, PATH) 10 | puts "configuration from #{PATH}" if RuboCop::ConfigLoader.debug? 11 | config = RuboCop::ConfigLoader.merge_with_default(config, PATH) 12 | RuboCop::ConfigLoader 13 | .instance_variable_set(:@default_configuration, config) 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/ducalis/rubo_cop.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PatchedRubocop 4 | CURRENT_VERSION = Gem::Version.new(RuboCop::Version.version) 5 | ADAPTED_VERSION = Gem::Version.new('0.46.0') 6 | SPEC_CHANGES_VERSION = Gem::Version.new('0.59.0') 7 | end 8 | 9 | module RuboCop 10 | class ConfigLoader 11 | ::Ducalis::Utils.silence_warnings { DOTFILE = ::Ducalis::DOTFILE } 12 | class << self 13 | prepend PatchedRubocop::DucalisConfigLoader 14 | end 15 | end 16 | 17 | class CommentConfig 18 | ::Ducalis::Utils.silence_warnings do 19 | COMMENT_DIRECTIVE_REGEXP = Regexp.new( 20 | ('# ducalis : ((?:dis|en)able)\b ' + COPS_PATTERN).gsub(' ', '\s*') 21 | ) 22 | end 23 | end 24 | 25 | class TargetFinder 26 | prepend PatchedRubocop::GitTurgetFinder 27 | end 28 | 29 | class Runner 30 | prepend PatchedRubocop::GitRunner 31 | end 32 | end 33 | 34 | RuboCop::Cop::Cop.prepend(PatchedRubocop::CopCast) 35 | PatchedRubocop::Inject.defaults! 36 | -------------------------------------------------------------------------------- /lib/ducalis/utils.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'octokit' 4 | 5 | module Ducalis 6 | module Utils 7 | module_function 8 | 9 | def octokit 10 | @octokit ||= begin 11 | token = ENV.fetch('GITHUB_TOKEN') { raise MissingToken } 12 | Octokit::Client.new(access_token: token).tap do |client| 13 | client.auto_paginate = true 14 | end 15 | end 16 | end 17 | 18 | def similarity(string1, string2) 19 | longer = [string1.size, string2.size].max 20 | same = string1.each_char 21 | .zip(string2.each_char) 22 | .select { |char1, char2| char1 == char2 } 23 | .size 24 | 1 - (longer - same) / string1.size.to_f 25 | end 26 | 27 | def silence_warnings 28 | original_verbose = $VERBOSE 29 | $VERBOSE = nil 30 | yield 31 | $VERBOSE = original_verbose 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/ducalis/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ducalis 4 | VERSION = '0.12.0'.freeze 5 | end 6 | -------------------------------------------------------------------------------- /spec/ducalis/adapters/circle_ci_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/adapters/circle_ci' 7 | 8 | RSpec.describe Adapters::CircleCI do 9 | describe '#self.suitable_for?' do 10 | it 'returns true for `circleci` string' do 11 | expect(described_class.suitable_for?('circleci')).to be true 12 | end 13 | end 14 | 15 | describe '#call' do 16 | before do 17 | stub_const('ENV', 18 | 'CI_PULL_REQUEST' => 'https://github.com/org/repo/pull/42', 19 | 'CIRCLE_REPOSITORY_URL' => 'git@github.com:org/repo.git') 20 | end 21 | 22 | it 'parses info from ENV variables' do 23 | expect(described_class.new(nil).call).to match_array(['org/repo', '42']) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/ducalis/adapters/default_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/adapters/default' 7 | 8 | RSpec.describe Adapters::Default do 9 | describe '#self.suitable_for?' do 10 | it 'always return true' do 11 | expect(described_class.suitable_for?('literally any value')).to be true 12 | end 13 | end 14 | 15 | describe '#call' do 16 | it "split's PR info to repo and id" do 17 | expect( 18 | described_class.new('org/repo#42').call 19 | ).to match_array(['org/repo', '42']) 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/ducalis/cli_arguments_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/cli_arguments' 7 | 8 | RSpec.describe Ducalis::CliArguments do 9 | subject { described_class.new } 10 | 11 | describe '#docs_command?' do 12 | it 'return true if ARGV contains doc command' do 13 | stub_const('ARGV', ['--docs']) 14 | expect(subject.docs_command?).to be true 15 | end 16 | end 17 | 18 | describe '#help_command?' do 19 | it 'return true if ARGV contains any help command' do 20 | stub_const('ARGV', ['-h']) 21 | expect(subject.help_command?).to be true 22 | end 23 | end 24 | 25 | describe '#process!' do 26 | let(:git_access) { instance_double(GitAccess, repo: 'repo', id: 42) } 27 | let(:true_adapter) { double(suitable_for?: true, call: %w[repo 42]) } 28 | let(:false_adapter) { double(suitable_for?: false) } 29 | 30 | before do 31 | stub_const('GitAccess', class_double(GitAccess, instance: git_access)) 32 | stub_const('GitAccess::MODES', index: :_value) 33 | end 34 | 35 | it 'receiving git diff mode from ARGV' do 36 | stub_const('ARGV', ['--index']) 37 | expect(git_access).to receive(:flag=).with(:index) 38 | subject.process! 39 | expect(ARGV).to match_array([]) 40 | end 41 | 42 | it "works when mode wasn't passed" do 43 | stub_const('ARGV', []) 44 | expect(git_access).to_not receive(:flag=) 45 | subject.process! 46 | end 47 | 48 | it 'receiving values for passed formatter' do 49 | stub_const('ARGV', ['--reporter', 'repo#42']) 50 | stub_const('Ducalis::CliArguments::ADAPTERS', 51 | [false_adapter, true_adapter]) 52 | expect(true_adapter).to receive(:new).with('repo#42') 53 | .and_return(true_adapter) 54 | expect(git_access).to receive(:store_pull_request!).with(%w[repo 42]) 55 | subject.process! 56 | expect(ARGV).to match_array(['--format', 'GithubFormatter']) 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /spec/ducalis/commentators/github_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/commentators/github' 7 | 8 | RSpec.describe Ducalis::Commentators::Github do 9 | subject { described_class.new('repo', 42) } 10 | 11 | before { stub_const('Ducalis::Utils', double(:utils, octokit: octokit)) } 12 | 13 | let(:octokit) { instance_double(Octokit::Client) } 14 | 15 | let(:path) { 'app/controllers/books_controller.rb' } 16 | let(:message) { 'actual cop message with description' } 17 | let(:position) { 60 } 18 | let(:existing_comment) { { path: path, position: position, body: message } } 19 | 20 | let(:buffer) { instance_double(Parser::Source::Buffer, name: path) } 21 | let(:range) { instance_double(Parser::Source::Range, source_buffer: buffer) } 22 | 23 | let(:message_extension) do 24 | instance_double(Ducalis::Commentators::Message, with_link: message) 25 | end 26 | let(:offense) do 27 | instance_double(RuboCop::Cop::Offense, 28 | message: message, location: range, line: position) 29 | end 30 | 31 | context "when PR doesn't have any previous comments" do 32 | before do 33 | expect(Ducalis::Commentators::Message).to receive(:new).with(offense) 34 | .twice.and_return(message_extension) 35 | expect(octokit).to receive(:pull_request_comments).and_return([]) 36 | expect(Dir).to receive(:pwd).exactly(8).times.and_return('') 37 | end 38 | 39 | it 'comments offenses' do 40 | expect(octokit).to receive(:create_pull_request_review) 41 | subject.call([offense]) 42 | end 43 | end 44 | 45 | context 'when PR already commented but some offenses are not' do 46 | let(:nil_diff) { double(:diff, path: 'some/path', patch_line: -1) } 47 | 48 | before do 49 | expect(Ducalis::Commentators::Message).to receive(:new).with(offense) 50 | .twice.and_return(message_extension) 51 | expect(GitAccess.instance).to receive(:for) 52 | .with(path).exactly(4).times.and_return(nil_diff) 53 | expect(octokit).to receive(:pull_request_comments) 54 | .and_return([existing_comment]) 55 | end 56 | 57 | it 'comments missed offenses' do 58 | expect(octokit).to receive(:create_pull_request_review) 59 | subject.call([offense]) 60 | end 61 | end 62 | 63 | context 'when PR already commented with the same comment' do 64 | let(:git_diff) { double(:diff, path: path, patch_line: position) } 65 | 66 | before do 67 | expect(Ducalis::Commentators::Message).to receive(:new) 68 | .with(offense).and_return(message_extension) 69 | expect(GitAccess.instance).to receive(:for) 70 | .with(path).twice.and_return(git_diff) 71 | expect(octokit).to receive(:pull_request_comments) 72 | .and_return([existing_comment]) 73 | end 74 | 75 | it "doesn't re-comment this PR" do 76 | expect(octokit).not_to receive(:create_pull_request_review) 77 | subject.call([offense]) 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /spec/ducalis/commentators/message_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/commentators/message' 7 | 8 | RSpec.describe Ducalis::Commentators::Message do 9 | subject { described_class.new(offense) } 10 | let(:offense) do 11 | instance_double(RuboCop::Cop::Offense, message: message, cop_name: cop_name) 12 | end 13 | let(:cop_name) { 'Ducalis/ProtectedScopeCop' } 14 | 15 | context 'with new messages format (which contains copname)' do 16 | let(:message) { 'Ducalis/ProtectedScopeCop: A long description.' } 17 | 18 | it 'comments offenses with link to the cop desc', :aggregate_failures do 19 | expect(subject.with_link).to include('ducalis-rb') 20 | expect(subject.with_link).to include('#ducalisprotectedscopecop') 21 | expect(subject.with_link).to include('A long description.') 22 | end 23 | end 24 | 25 | context 'with old messages format' do 26 | let(:message) { 'A long description.' } 27 | 28 | it 'comments offenses with link to the cop desc', :aggregate_failures do 29 | expect(subject.with_link).to include('ducalis-rb') 30 | expect(subject.with_link).to include('#ducalisprotectedscopecop') 31 | expect(subject.with_link).to include('A long description.') 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/ducalis/cops/black_list_suffix_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/cops/black_list_suffix' 7 | 8 | RSpec.describe Ducalis::BlackListSuffix do 9 | subject(:cop) { described_class.new } 10 | let(:cop_config) { { 'BlackList' => ['Sorter'] } } 11 | before { allow(cop).to receive(:cop_config).and_return(cop_config) } 12 | 13 | it '[rule] raises on classes with suffixes from black list' do 14 | inspect_source([ 15 | 'class ListSorter', 16 | 'end' 17 | ]) 18 | expect(cop).to raise_violation(/class suffixes/) 19 | end 20 | 21 | it '[rule] better to have names which map on business-logic' do 22 | inspect_source([ 23 | 'class SortedList', 24 | 'end' 25 | ]) 26 | expect(cop).not_to raise_violation 27 | end 28 | 29 | it 'ignores classes with full match' do 30 | inspect_source([ 31 | 'class Manager', 32 | 'end' 33 | ]) 34 | expect(cop).not_to raise_violation 35 | end 36 | 37 | it 'works with empty config' do 38 | allow(cop).to receive(:cop_config).and_return({}) 39 | inspect_source([ 40 | 'class Manager', 41 | 'end' 42 | ]) 43 | expect(cop).not_to raise_violation 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/ducalis/cops/callbacks_activerecord_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/cops/callbacks_activerecord' 7 | 8 | RSpec.describe Ducalis::CallbacksActiverecord do 9 | subject(:cop) { described_class.new } 10 | 11 | it '[rule] raises on ActiveRecord classes which contains callbacks' do 12 | inspect_source([ 13 | 'class Product < ActiveRecord::Base', 14 | ' before_create :generate_code', 15 | 'end' 16 | ]) 17 | expect(cop).to raise_violation(/callbacks/) 18 | end 19 | 20 | it '[rule] better to use builder classes for complex workflows' do 21 | inspect_source([ 22 | 'class Product < ActiveRecord::Base', 23 | 'end', 24 | '', 25 | 'class ProductCreation', 26 | ' def initialize(attributes)', 27 | ' @attributes = attributes', 28 | ' end', 29 | '', 30 | ' def create', 31 | ' Product.create(@attributes).tap do |product|', 32 | ' generate_code(product)', 33 | ' end', 34 | ' end', 35 | '', 36 | ' private', 37 | '', 38 | ' def generate_code(product)', 39 | ' # logic goes here', 40 | ' end', 41 | 'end' 42 | ]) 43 | expect(cop).not_to raise_violation 44 | end 45 | 46 | it 'ignores non-ActiveRecord classes which contains callbacks' do 47 | inspect_source([ 48 | 'class Product < BasicProduct', 49 | ' before_create :generate_code', 50 | 'end' 51 | ]) 52 | expect(cop).not_to raise_violation 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/ducalis/cops/case_mapping_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/cops/case_mapping' 7 | 8 | RSpec.describe Ducalis::CaseMapping do 9 | subject(:cop) { described_class.new } 10 | 11 | it '[rule] raises on case statements' do 12 | inspect_source([ 13 | 'case grade', 14 | 'when "A"', 15 | ' puts "Well done!"', 16 | 'when "B"', 17 | ' puts "Try harder!"', 18 | 'when "C"', 19 | ' puts "You need help!!!"', 20 | 'else', 21 | ' puts "You just making it up!"', 22 | 'end' 23 | ]) 24 | expect(cop).to raise_violation(/case/) 25 | end 26 | 27 | it '[rule] better to use mapping' do 28 | inspect_source([ 29 | '{', 30 | ' "A" => "Well done!",', 31 | ' "B" => "Try harder!",', 32 | ' "C" => "You need help!!!",', 33 | '}.fetch(grade) { "You just making it up!" }' 34 | ]) 35 | expect(cop).not_to raise_violation 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/ducalis/cops/complex_cases/smart_delete_check_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require 'parser/ast/node' 7 | require './lib/ducalis/cops/complex_cases/smart_delete_check' 8 | 9 | RSpec.describe ComplexCases::SmartDeleteCheck do 10 | let(:who) { instance_double(Parser::AST::Node) } 11 | let(:node) { instance_double(Parser::AST::Node) } 12 | 13 | describe '.call' do 14 | let(:check_kass) { instance_double(described_class, false_positive?: true) } 15 | 16 | it 'delegates false_positive calling with not' do 17 | expect(described_class).to receive(:new).and_return(check_kass) 18 | expect(described_class.call(:who, :what, :args)).to be false 19 | end 20 | end 21 | 22 | describe '#false_positive?' do 23 | context 'when string argument passed' do 24 | subject { described_class.new(who, nil, [node]) } 25 | 26 | it 'returns true' do 27 | expect(node).to receive(:type).and_return(:str) 28 | expect(subject.false_positive?).to be true 29 | end 30 | end 31 | 32 | context 'when there are many arguments' do 33 | subject { described_class.new(who, nil, [node, node]) } 34 | 35 | it 'returns true' do 36 | expect(node).to receive(:type).and_return(:non_string) 37 | expect(subject.false_positive?).to be true 38 | end 39 | end 40 | 41 | context 'when caller is whitelisted' do 42 | subject { described_class.new(who, nil, [node]) } 43 | 44 | it 'returns true' do 45 | expect(node).to receive(:type).and_return(:non_string) 46 | expect(who).to receive(:to_s).and_return('File') 47 | expect(subject.false_positive?).to be true 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/ducalis/cops/complex_regex_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require './lib/ducalis/cops/complex_regex' 5 | 6 | SingleCov.covered! 7 | 8 | RSpec.describe Ducalis::ComplexRegex do 9 | subject(:cop) { described_class.new } 10 | let(:cop_config) { { 'MaxComplexity' => 3 } } 11 | before { allow(cop).to receive(:cop_config).and_return(cop_config) } 12 | 13 | it '[rule] raises for regex with a lot of quantifiers' do 14 | inspect_source([ 15 | "PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)./", 16 | "AGE_RANGE_MATCH = /^(\d+)(?:-)(\d+)$/", 17 | "FLOAT_NUMBER_REGEX = /(\d+,\d+.\d+|\d+[.,]\d+|\d+)/" 18 | ]) 19 | expect(cop).to raise_violation(/long form/, count: 3) 20 | end 21 | 22 | it '[rule] better to use long form with comments' do 23 | inspect_source([ 24 | 'COMPLEX_REGEX = %r{', 25 | ' start # some text', 26 | " \s # white space char", 27 | ' (group) # first group', 28 | ' (?:alt1|alt2) # some alternation', 29 | ' end', 30 | '}x', 31 | 'LOG_FORMAT = %r{', 32 | " (\d{2}:\d{2}) # Time", 33 | " \s(\w+) # Event type", 34 | " \s(.*) # Message", 35 | '}x' 36 | ]) 37 | expect(cop).not_to raise_violation 38 | end 39 | 40 | it 'accepts simple regexes as is' do 41 | inspect_source([ 42 | "IDENTIFIER = /TXID:\d+/", 43 | "REGEX_ONLY_NINE_DIGITS = /^\d{9}$/", 44 | 'ALPHA_ONLY = /[a-zA-Z]+/' 45 | ]) 46 | expect(cop).not_to raise_violation 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/ducalis/cops/complex_statements_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/cops/complex_statements' 7 | 8 | RSpec.describe Ducalis::ComplexStatements do 9 | subject(:cop) { described_class.new } 10 | 11 | it '[rule] raises on complex or statements' do 12 | inspect_source([ 13 | 'if divisible(4) && (divisible(400) || !divisible(100))', 14 | ' puts "This is a leap year!"', 15 | 'end' 16 | ]) 17 | expect(cop).to raise_violation(/complex/) 18 | end 19 | 20 | it 'raises for complex unless statements (especially!)' do 21 | inspect_source('puts "Hi" unless a_cond && b_cond || c_cond') 22 | expect(cop).to raise_violation(/complex/) 23 | end 24 | 25 | it '[rule] better to move a complex statements to method' do 26 | inspect_source([ 27 | 'if leap_year?', 28 | ' puts "This is a leap year!"', 29 | 'end', 30 | '', 31 | 'private', 32 | '', 33 | 'def leap_year?', 34 | ' divisible(4) && (divisible(400) || !divisible(100))', 35 | 'end' 36 | ]) 37 | expect(cop).not_to raise_violation 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/ducalis/cops/controllers_except_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/cops/controllers_except.rb' 7 | 8 | RSpec.describe Ducalis::ControllersExcept do 9 | subject(:cop) { described_class.new } 10 | 11 | it '[rule] raises for `before_filters` with `except` method as array' do 12 | inspect_source([ 13 | 'class ProductsController < ApplicationController', 14 | ' before_filter :update_cost, except: [:index]', 15 | '', 16 | ' def index; end', 17 | ' def edit; end', 18 | '', 19 | ' private', 20 | '', 21 | ' def update_cost; end', 22 | 'end' 23 | ]) 24 | expect(cop).to raise_violation(/explicit/) 25 | end 26 | 27 | it '[rule] better use `only` for `before_filters`' do 28 | inspect_source([ 29 | 'class ProductsController < ApplicationController', 30 | ' before_filter :update_cost, only: [:edit]', 31 | '', 32 | ' def index; end', 33 | ' def edit; end', 34 | '', 35 | ' private', 36 | '', 37 | ' def update_cost; end', 38 | 'end' 39 | ]) 40 | expect(cop).not_to raise_violation 41 | end 42 | 43 | it 'raises for filters with many actions and only one `except` method' do 44 | inspect_source([ 45 | 'class ProductsController < ApplicationController', 46 | ' before_filter :update_cost, :load_me, except: %i[edit]', 47 | '', 48 | ' def index; end', 49 | ' def edit; end', 50 | '', 51 | ' private', 52 | '', 53 | ' def update_cost; end', 54 | ' def load_me; end', 55 | 'end' 56 | ]) 57 | expect(cop).to raise_violation(/explicit/) 58 | end 59 | 60 | it 'ignores `before_filters` without arguments' do 61 | inspect_source([ 62 | 'class ProductsController < ApplicationController', 63 | ' before_filter :update_cost', 64 | '', 65 | ' def index; end', 66 | '', 67 | ' private', 68 | '', 69 | ' def update_cost; end', 70 | 'end' 71 | ]) 72 | expect(cop).not_to raise_violation 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /spec/ducalis/cops/data_access_objects_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/cops/data_access_objects' 7 | 8 | RSpec.describe Ducalis::DataAccessObjects do 9 | subject(:cop) { described_class.new } 10 | 11 | it '[rule] raises on working with `session` object' do 12 | inspect_source([ 13 | 'class ProductsController < ApplicationController', 14 | ' def edit', 15 | ' session[:start_time] = Time.now', 16 | ' end', 17 | '', 18 | ' def update', 19 | ' @time = Date.parse(session[:start_time]) - Time.now', 20 | ' end', 21 | 'end' 22 | ]) 23 | expect(cop).to raise_violation(/Data Access/, count: 2) 24 | end 25 | 26 | it '[rule] better to use DAO objects' do 27 | inspect_source([ 28 | 'class ProductsController < ApplicationController', 29 | ' def edit', 30 | ' session_time.start!', 31 | ' end', 32 | '', 33 | ' def update', 34 | ' @time = session_time.period', 35 | ' end', 36 | '', 37 | ' private', 38 | '', 39 | ' def session_time', 40 | ' @_session_time ||= SessionTime.new(session)', 41 | ' end', 42 | 'end', 43 | '', 44 | 'class SessionTime', 45 | ' KEY = :start_time', 46 | '', 47 | ' def initialize(session)', 48 | ' @session = session', 49 | ' @current_time = Time.now', 50 | ' end', 51 | '', 52 | ' def start!', 53 | ' @session[KEY] = @current_time', 54 | ' end', 55 | '', 56 | ' def period', 57 | ' Date.parse(@session[KEY]) - @current_time', 58 | ' end', 59 | 'end' 60 | ]) 61 | expect(cop).not_to raise_violation 62 | end 63 | 64 | it 'raises on working with `cookies` object' do 65 | inspect_source([ 66 | 'class HomeController < ApplicationController', 67 | ' def set_cookies', 68 | ' cookies[:user_name] = "Horst Meier"', 69 | ' cookies[:customer_number] = "1234567890"', 70 | ' end', 71 | '', 72 | ' def show_cookies', 73 | ' @user_name = cookies[:user_name]', 74 | ' @customer_number = cookies[:customer_number]', 75 | ' end', 76 | '', 77 | ' def delete_cookies', 78 | ' cookies.delete :user_name', 79 | ' cookies.delete :customer_number', 80 | ' end', 81 | 'end' 82 | ]) 83 | expect(cop).to raise_violation(/Data Access/, count: 6) 84 | end 85 | 86 | it 'raises on working with global `$redis` object' do 87 | inspect_source([ 88 | 'class ProductsController < ApplicationController', 89 | ' def update', 90 | ' $redis.incr("current_hits")', 91 | ' end', 92 | '', 93 | ' def show', 94 | ' $redis.get("current_hits").to_i', 95 | ' end', 96 | 'end' 97 | ]) 98 | expect(cop).to raise_violation(/Data Access/, count: 2) 99 | end 100 | 101 | it 'raises on working with `Redis.current` object' do 102 | inspect_source([ 103 | 'class ProductsController < ApplicationController', 104 | ' def update', 105 | ' Redis.current.incr("current_hits")', 106 | ' end', 107 | '', 108 | ' def show', 109 | ' Redis.current.get("current_hits").to_i', 110 | ' end', 111 | 'end' 112 | ]) 113 | expect(cop).to raise_violation(/Data Access/, count: 2) 114 | end 115 | 116 | it 'ignores passing DAO-like objects to services' do 117 | inspect_source([ 118 | 'class ProductsController < ApplicationController', 119 | ' def update', 120 | ' current_hits.increment', 121 | ' end', 122 | '', 123 | ' def show', 124 | ' current_hits.count', 125 | ' end', 126 | '', 127 | ' private', 128 | '', 129 | ' def current_hits', 130 | ' @_current_hits ||= CurrentHits.new(Redis.current)', 131 | ' end', 132 | 'end' 133 | ]) 134 | expect(cop).not_to raise_violation 135 | end 136 | 137 | it 'ignores non-controller classes `$redis` object' do 138 | inspect_source([ 139 | 'class ProductsDAO', 140 | ' def update', 141 | ' $redis.incr("current_hits")', 142 | ' end', 143 | '', 144 | ' def show', 145 | ' $redis.get("current_hits").to_i', 146 | ' end', 147 | 'end' 148 | ]) 149 | expect(cop).to_not raise_violation 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /spec/ducalis/cops/descriptive_block_names_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/cops/descriptive_block_names' 7 | 8 | RSpec.describe Ducalis::DescriptiveBlockNames do 9 | subject(:cop) { described_class.new } 10 | let(:cop_config) { { 'MinimalLenght' => 3, 'WhiteList' => %w[id] } } 11 | before { allow(cop).to receive(:cop_config).and_return(cop_config) } 12 | 13 | it '[rule] raises for blocks with one/two chars names' do 14 | inspect_source([ 15 | 'employees.map { |e| e.call(some, word) }', 16 | 'cards.each { |c| c.date = dates[c.id] }', 17 | 'Tempfile.new("name.pdf").tap do |f|', 18 | ' f.binmode', 19 | ' f.write(code)', 20 | ' f.close', 21 | 'end' 22 | ]) 23 | expect(cop).to raise_violation(/descriptive names/, count: 3) 24 | end 25 | 26 | it '[rule] better to use descriptive names' do 27 | inspect_source([ 28 | 'employees.map { |employee| employee.call(some, word) }', 29 | 'cards.each { |card| card.date = dates[card.id] }', 30 | 'Tempfile.new("name.pdf").tap do |file|', 31 | ' file.binmode', 32 | ' file.write(code)', 33 | ' file.close', 34 | 'end' 35 | ]) 36 | expect(cop).not_to raise_violation 37 | end 38 | 39 | it 'ignores records from whitelist' do 40 | inspect_source('people_ids.each { |id| puts(id) }') 41 | expect(cop).not_to raise_violation 42 | end 43 | 44 | it 'ignores variables which start with underscore' do 45 | inspect_source('people_ids.each { |_| puts "hi" }') 46 | expect(cop).not_to raise_violation 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/ducalis/cops/enforce_namespace_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/cops/enforce_namespace' 7 | 8 | RSpec.describe Ducalis::EnforceNamespace do 9 | subject(:cop) { described_class.new } 10 | before { allow(cop).to receive(:in_service?).and_return true } 11 | 12 | it '[rule] raises on classes without namespace' do 13 | inspect_source('class MyService; end') 14 | expect(cop).to raise_violation(/namespace/) 15 | end 16 | 17 | it '[rule] better to add a namespace for classes' do 18 | inspect_source([ 19 | 'module Namespace', 20 | ' class MyService', 21 | ' end', 22 | 'end' 23 | ]) 24 | expect(cop).not_to raise_violation 25 | end 26 | 27 | it 'raises on modules without namespace' do 28 | inspect_source('module MyServiceModule; end') 29 | expect(cop).to raise_violation(/namespace/) 30 | end 31 | 32 | it 'ignores alone class with namespace' do 33 | inspect_source('module My; class Service; end; end') 34 | expect(cop).not_to raise_violation 35 | end 36 | 37 | it 'ignores multiple classes with namespace' do 38 | inspect_source('module My; class Service; end; class A; end; end') 39 | expect(cop).not_to raise_violation 40 | end 41 | 42 | it 'ignores non-service classes/modules' do 43 | allow(cop).to receive(:in_service?).and_return false 44 | inspect_source('module User; end;') 45 | inspect_source('class User; end;') 46 | expect(cop).not_to raise_violation 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/ducalis/cops/evlis_overusing_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ELVIS_SUPPORT_VERSION = 2.3 4 | 5 | SingleCov.covered! uncovered: 6 | if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new(ELVIS_SUPPORT_VERSION) 7 | 0 8 | else 9 | 2 # on_csend method will be never called on Ruby < 2.3 =( 10 | end 11 | 12 | require 'spec_helper' 13 | require './lib/ducalis/cops/evlis_overusing' 14 | 15 | RSpec.describe Ducalis::EvlisOverusing do 16 | let(:ruby_version) { ELVIS_SUPPORT_VERSION } 17 | subject(:cop) { described_class.new } 18 | 19 | it 'raises on multiple safe operator callings' do 20 | if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new(ruby_version.to_s) 21 | inspect_source('user&.person&.full_name') 22 | expect(cop).to raise_violation(/overusing/) 23 | end 24 | end 25 | 26 | it '[rule] better to use NullObjects' do 27 | inspect_source([ 28 | 'class NullManufacturer', 29 | ' def contact', 30 | ' "No Manufacturer"', 31 | ' end', 32 | 'end', 33 | '', 34 | 'def manufacturer', 35 | ' product.manufacturer || NullManufacturer.new', 36 | 'end', 37 | '', 38 | 'manufacturer.contact' 39 | ]) 40 | expect(cop).not_to raise_violation 41 | end 42 | 43 | it '[rule] raises on multiple try callings' do 44 | inspect_source('product.try(:manufacturer).try(:contact)') 45 | expect(cop).to raise_violation(/overusing/) 46 | end 47 | 48 | it 'raises on multiple try! callings' do 49 | inspect_source('product.try!(:manufacturer).try!(:contact)') 50 | expect(cop).to raise_violation(/overusing/) 51 | end 52 | 53 | it 'raises on multiple safe try callings' do 54 | inspect_source('params[:account].try(:[], :owner).try(:[], :address)') 55 | expect(cop).to raise_violation(/overusing/) 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/ducalis/cops/facade_pattern_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/cops/facade_pattern' 7 | 8 | RSpec.describe Ducalis::FacadePattern do 9 | subject(:cop) { described_class.new } 10 | let(:cop_config) { { 'MaxInstanceVariables' => 4 } } 11 | before { allow(cop).to receive(:cop_config).and_return(cop_config) } 12 | 13 | it '[rule] raises on working with `session` object' do 14 | inspect_source([ 15 | 'class DashboardsController < ApplicationController', 16 | ' def index', 17 | ' @group = current_group', 18 | ' @relationship_manager = @group.relationship_manager', 19 | ' @contract_signer = @group.contract_signer', 20 | '', 21 | ' @statistic = EnrollmentStatistic.for(@group)', 22 | ' @tasks = serialize(@group.tasks, ' \ 23 | 'serializer: TaskSerializer)', 24 | ' @external_links = @group.external_links', 25 | ' end', 26 | 'end' 27 | ]) 28 | expect(cop).to raise_violation(/Facade/, count: 6) 29 | end 30 | 31 | it '[rule] better to use facade pattern' do 32 | inspect_source([ 33 | 'class Dashboard', 34 | ' def initialize(group)', 35 | ' @group', 36 | ' end', 37 | '', 38 | ' def external_links', 39 | ' @group.external_links', 40 | ' end', 41 | '', 42 | ' def tasks', 43 | ' serialize(@group.tasks, serializer: TaskSerializer)', 44 | ' end', 45 | '', 46 | ' def statistic', 47 | ' EnrollmentStatistic.for(@group)', 48 | ' end', 49 | '', 50 | ' def contract_signer', 51 | ' @group.contract_signer', 52 | ' end', 53 | '', 54 | ' def relationship_manager', 55 | ' @group.relationship_manager', 56 | ' end', 57 | 'end', 58 | '', 59 | 'class DashboardsController < ApplicationController', 60 | ' def index', 61 | ' @dashboard = Dashboard.new(current_group)', 62 | ' end', 63 | 'end' 64 | ]) 65 | expect(cop).to_not raise_violation 66 | end 67 | 68 | it 'ignores private methods (I hope nobody will use it as a good example)' do 69 | inspect_source([ 70 | 'class DashboardsController < ApplicationController', 71 | ' private', 72 | '', 73 | ' def assing_variables', 74 | ' @var_1 = 1', 75 | ' @var_2 = 2', 76 | ' @var_3 = 3', 77 | ' @var_4 = 4', 78 | ' end', 79 | 'end' 80 | ]) 81 | expect(cop).to_not raise_violation 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /spec/ducalis/cops/fetch_expression_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/cops/fetch_expression' 7 | 8 | RSpec.describe Ducalis::FetchExpression do 9 | subject(:cop) { described_class.new } 10 | 11 | it '[rule] raises on using [] with default' do 12 | inspect_source('params[:to] || destination') 13 | expect(cop).to raise_violation(/fetch/) 14 | end 15 | 16 | it '[rule] better to use fetch operator' do 17 | inspect_source('params.fetch(:to) { destination }') 18 | expect(cop).not_to raise_violation 19 | end 20 | 21 | it 'raises on using ternary operator with default' do 22 | inspect_source('params[:to] ? params[:to] : destination') 23 | expect(cop).to raise_violation(/fetch/) 24 | end 25 | 26 | it 'raises on using ternary operator with nil?' do 27 | inspect_source('params[:to].nil? ? destination : params[:to]') 28 | expect(cop).to raise_violation(/fetch/) 29 | end 30 | 31 | it 'works for empty file' do 32 | inspect_source('') 33 | expect(cop).not_to raise_violation 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/ducalis/cops/keyword_defaults_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/cops/keyword_defaults' 7 | 8 | RSpec.describe Ducalis::KeywordDefaults do 9 | subject(:cop) { described_class.new } 10 | 11 | it '[rule] raises if method definition contains default values' do 12 | inspect_source('def calculate(step, index, dry = true); end') 13 | expect(cop).to raise_violation(/keyword arguments/) 14 | end 15 | 16 | it 'raises if class method definition contains default values' do 17 | inspect_source('def self.calculate(step, index, dry = true); end') 18 | expect(cop).to raise_violation(/keyword arguments/) 19 | end 20 | 21 | it '[rule] better to pass default values through keywords' do 22 | inspect_source('def calculate(step, index, dry: true); end') 23 | expect(cop).not_to raise_violation 24 | end 25 | 26 | it 'ignores for methods without arguments' do 27 | inspect_source('def calculate_amount; end') 28 | expect(cop).not_to raise_violation 29 | end 30 | 31 | it 'ignores for class methods without arguments' do 32 | inspect_source('def self.calculate_amount; end') 33 | expect(cop).not_to raise_violation 34 | end 35 | 36 | it 'does not raise when method contains only 1 argument' do 37 | inspect_source('def calculate(dry = true); end') 38 | expect(cop).not_to raise_violation 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/ducalis/cops/module_like_class_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/cops/module_like_class' 7 | 8 | RSpec.describe Ducalis::ModuleLikeClass do 9 | subject(:cop) { described_class.new } 10 | let(:cop_config) { { 'AllowedIncludes' => ['Singleton'] } } 11 | 12 | it '[rule] raises for class without constructor but accepts the same args' do 13 | inspect_source([ 14 | 'class TaskJournal', 15 | ' def initialize(customer)', 16 | ' # ...', 17 | ' end', 18 | '', 19 | ' def approve(task, estimate, options)', 20 | ' # ...', 21 | ' end', 22 | '', 23 | ' def decline(user, task, estimate, details)', 24 | ' # ...', 25 | ' end', 26 | '', 27 | ' private', 28 | '', 29 | ' def log(record)', 30 | ' # ...', 31 | ' end', 32 | 'end' 33 | ]) 34 | expect(cop).to raise_violation(/pass `task`, `estimate`/) 35 | end 36 | 37 | it '[rule] better to pass common arguments to the constructor' do 38 | inspect_source([ 39 | 'class TaskJournal', 40 | ' def initialize(customer, task, estimate)', 41 | ' # ...', 42 | ' end', 43 | '', 44 | ' def approve(options)', 45 | ' # ...', 46 | ' end', 47 | '', 48 | ' def decline(user, details)', 49 | ' # ...', 50 | ' end', 51 | '', 52 | ' private', 53 | '', 54 | ' def log(record)', 55 | ' # ...', 56 | ' end', 57 | 'end' 58 | ]) 59 | expect(cop).not_to raise_violation 60 | end 61 | 62 | it '[rule] raises for class with only one public method with args' do 63 | inspect_source([ 64 | 'class TaskJournal', 65 | ' def approve(task)', 66 | ' # ...', 67 | ' end', 68 | '', 69 | ' private', 70 | '', 71 | ' def log(record)', 72 | ' # ...', 73 | ' end', 74 | 'end' 75 | ]) 76 | expect(cop).to raise_violation(/pass `task`/) 77 | end 78 | 79 | it 'ignores classes with custom includes' do 80 | allow(cop).to receive(:cop_config).and_return(cop_config) 81 | inspect_source([ 82 | 'class TaskJournal', 83 | ' include Singleton', 84 | '', 85 | ' def approve(task)', 86 | ' # ...', 87 | ' end', 88 | 'end' 89 | ]) 90 | expect(cop).not_to raise_violation 91 | end 92 | 93 | it 'ignores classes with inheritance' do 94 | inspect_source([ 95 | 'class TaskJournal < BasicJournal', 96 | ' def approve(task)', 97 | ' # ...', 98 | ' end', 99 | '', 100 | ' private', 101 | '', 102 | ' def log(record)', 103 | ' # ...', 104 | ' end', 105 | 'end' 106 | ]) 107 | expect(cop).not_to raise_violation 108 | end 109 | 110 | it 'ignores classes with one method and initializer' do 111 | inspect_source([ 112 | 'class TaskJournal', 113 | ' def initialize(task)', 114 | ' # ...', 115 | ' end', 116 | '', 117 | ' def call(args)', 118 | ' # ...', 119 | ' end', 120 | 'end' 121 | ]) 122 | expect(cop).not_to raise_violation 123 | end 124 | 125 | it 'works for classes with only one method in body' do 126 | inspect_source([ 127 | 'class TaskJournal', 128 | ' def call; end', 129 | 'end' 130 | ]) 131 | expect(cop).not_to raise_violation 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /spec/ducalis/cops/multiple_times_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/cops/multiple_times' 7 | 8 | RSpec.describe Ducalis::MultipleTimes do 9 | subject(:cop) { described_class.new } 10 | 11 | it '[rule] raises if method contains more then one Time.current calling' do 12 | inspect_source([ 13 | 'def initialize(plan)', 14 | ' @year = plan[:year] || Date.current.year', 15 | ' @quarter = plan[:quarter] || quarter(Date.current)', 16 | 'end' 17 | ]) 18 | expect(cop).to raise_violation(/multiple/, count: 2) 19 | end 20 | 21 | it '[rule] better to inject time as parameter to the method or constructor' do 22 | inspect_source([ 23 | 'def initialize(plan, current_date: Date.current)', 24 | ' @year = plan[:year] || current_date.year', 25 | ' @quarter = plan[:quarter] || quarter(current_date)', 26 | 'end' 27 | ]) 28 | expect(cop).not_to raise_violation 29 | end 30 | 31 | it 'raises if method contains mix of different time-related calls' do 32 | inspect_source([ 33 | 'def initialize(plan)', 34 | ' @hour = plan[:hour] || Time.current.hour', 35 | ' @quarter = plan[:quarter] || quarter(Date.current)', 36 | 'end' 37 | ]) 38 | expect(cop).to raise_violation(/multiple/, count: 2) 39 | end 40 | 41 | it 'raises if method contains more then one Date.today calling' do 42 | inspect_source([ 43 | 'def range_to_change', 44 | ' [Date.today - RATE_CHANGES_DAYS,', 45 | ' Date.today + RATE_CHANGES_DAYS]', 46 | 'end' 47 | ]) 48 | expect(cop).to raise_violation(/multiple/, count: 2) 49 | end 50 | 51 | it 'raises if block contains more then one Date.today calling' do 52 | inspect_source([ 53 | 'validates :year,', 54 | ' inclusion: {', 55 | ' in: Date.current.year - 1..Date.current.year + 2', 56 | ' }' 57 | ]) 58 | expect(cop).to raise_violation(/multiple/, count: 2) 59 | end 60 | 61 | it 'raises if method contains more then one Date.yesterday calling' do 62 | inspect_source([ 63 | 'def range_to_change', 64 | ' [Date.yesterday - RATE_CHANGES_DAYS,', 65 | ' Date.yesterday + RATE_CHANGES_DAYS]', 66 | 'end' 67 | ]) 68 | expect(cop).to raise_violation(/multiple/, count: 2) 69 | end 70 | 71 | it 'raises if block contains more then one Date.yesterday calling' do 72 | inspect_source([ 73 | 'validates :day,', 74 | ' inclusion: {', 75 | ' in: Date.yesterday - 1..Date.yesterday + 2', 76 | ' }' 77 | ]) 78 | expect(cop).to raise_violation(/multiple/, count: 2) 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /spec/ducalis/cops/only_defs_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/cops/only_defs' 7 | 8 | RSpec.describe Ducalis::OnlyDefs do 9 | subject(:cop) { described_class.new } 10 | 11 | it 'ignores classes with one instance method' do 12 | inspect_source([ 13 | 'class TaskJournal', 14 | ' def initialize(task)', 15 | ' # ...', 16 | ' end', 17 | '', 18 | ' def call(args)', 19 | ' # ...', 20 | ' end', 21 | 'end' 22 | ]) 23 | expect(cop).not_to raise_violation 24 | end 25 | 26 | it 'ignores classes with mixed methods' do 27 | inspect_source([ 28 | 'class TaskJournal', 29 | ' def self.find(task)', 30 | ' # ...', 31 | ' end', 32 | '', 33 | ' def call(args)', 34 | ' # ...', 35 | ' end', 36 | 'end' 37 | ]) 38 | expect(cop).not_to raise_violation 39 | end 40 | 41 | it '[rule] raises error for class with ONLY class methods' do 42 | inspect_source([ 43 | 'class TaskJournal', 44 | '', 45 | ' def self.call(task)', 46 | ' # ...', 47 | ' end', 48 | '', 49 | ' def self.find(args)', 50 | ' # ...', 51 | ' end', 52 | 'end' 53 | ]) 54 | expect(cop).to raise_violation(/class methods/) 55 | end 56 | 57 | it '[rule] better to use instance methods' do 58 | inspect_source([ 59 | 'class TaskJournal', 60 | ' def call(task)', 61 | ' # ...', 62 | ' end', 63 | '', 64 | ' def find(args)', 65 | ' # ...', 66 | ' end', 67 | 'end' 68 | ]) 69 | expect(cop).not_to raise_violation 70 | end 71 | 72 | it 'raises error for class with ONLY class << self' do 73 | inspect_source([ 74 | 'class TaskJournal', 75 | ' class << self', 76 | ' def call(task)', 77 | ' # ...', 78 | ' end', 79 | '', 80 | ' def find(args)', 81 | ' # ...', 82 | ' end', 83 | ' end', 84 | 'end' 85 | ]) 86 | expect(cop).to raise_violation(/class methods/) 87 | end 88 | 89 | it 'ignores inherited classes' do 90 | inspect_source([ 91 | 'class TaskJournal < BasicJournal', 92 | ' def self.call(task)', 93 | ' # ...', 94 | ' end', 95 | '', 96 | ' def self.find(args)', 97 | ' # ...', 98 | ' end', 99 | 'end' 100 | ]) 101 | expect(cop).to_not raise_violation 102 | end 103 | 104 | it 'ignores instance methods mixed with ONLY class << self' do 105 | inspect_source([ 106 | 'class TaskJournal', 107 | ' class << self', 108 | ' def call(task)', 109 | ' # ...', 110 | ' end', 111 | ' end', 112 | '', 113 | ' def find(args)', 114 | ' # ...', 115 | ' end', 116 | 'end' 117 | ]) 118 | expect(cop).not_to raise_violation 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /spec/ducalis/cops/options_argument_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/cops/options_argument' 7 | 8 | RSpec.describe Ducalis::OptionsArgument do 9 | subject(:cop) { described_class.new } 10 | 11 | it '[rule] raises if method accepts default options argument' do 12 | inspect_source([ 13 | 'def generate(document, options = {})', 14 | ' format = options.delete(:format)', 15 | ' limit = options.delete(:limit) || 20', 16 | ' [format, limit, options]', 17 | 'end' 18 | ]) 19 | expect(cop).to raise_violation(/keyword arguments/) 20 | end 21 | 22 | it 'raises if method accepts a options argument' do 23 | inspect_source([ 24 | 'def log(record, options)', 25 | ' # ...', 26 | 'end' 27 | ]) 28 | expect(cop).to raise_violation(/keyword arguments/) 29 | end 30 | 31 | it 'raises if method accepts args argument' do 32 | inspect_source([ 33 | 'def log(record, args)', 34 | ' # ...', 35 | 'end' 36 | ]) 37 | expect(cop).to raise_violation(/keyword arguments/) 38 | end 39 | 40 | it '[rule] better to pass options with the split operator' do 41 | inspect_source([ 42 | 'def generate(document, format:, limit: 20, **options)', 43 | ' [format, limit, options]', 44 | 'end' 45 | ]) 46 | expect(cop).not_to raise_violation 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/ducalis/cops/params_passing_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/cops/params_passing' 7 | 8 | RSpec.describe Ducalis::ParamsPassing do 9 | subject(:cop) { described_class.new } 10 | 11 | it '[rule] raises if user pass `params` as argument from controller' do 12 | inspect_source([ 13 | 'class ProductsController < ApplicationController', 14 | ' def index', 15 | ' Record.new(params).log', 16 | ' end', 17 | 'end' 18 | ]) 19 | expect(cop).to raise_violation(/preprocessed params/) 20 | end 21 | 22 | it '[rule] better to pass permitted params' do 23 | inspect_source([ 24 | 'class ProductsController < ApplicationController', 25 | ' def index', 26 | ' Record.new(record_params).log', 27 | ' end', 28 | 'end' 29 | ]) 30 | expect(cop).not_to raise_violation 31 | end 32 | 33 | it 'raises if user pass `params` as any argument from controller' do 34 | inspect_source([ 35 | 'class ProductsController < ApplicationController', 36 | ' def index', 37 | ' Record.new(first_arg, params).log', 38 | ' end', 39 | 'end' 40 | ]) 41 | expect(cop).to raise_violation(/preprocessed params/) 42 | end 43 | 44 | it 'raises if user pass `params` as keyword argument from controller' do 45 | inspect_source([ 46 | 'class ProductsController < ApplicationController', 47 | ' def index', 48 | ' Record.new(first_arg, any_name: params).log', 49 | ' end', 50 | 'end' 51 | ]) 52 | expect(cop).to raise_violation(/preprocessed params/) 53 | end 54 | 55 | it 'ignores passing only one `params` field' do 56 | inspect_source([ 57 | 'class ProductsController < ApplicationController', 58 | ' def index', 59 | ' Record.new(first_arg, params[:id]).log', 60 | ' end', 61 | 'end' 62 | ]) 63 | expect(cop).not_to raise_violation 64 | end 65 | 66 | it 'ignores passing processed `params`' do 67 | inspect_source([ 68 | 'class ProductsController < ApplicationController', 69 | ' def index', 70 | ' Record.new(first_arg, params.slice(:name)).log', 71 | ' end', 72 | 'end' 73 | ]) 74 | expect(cop).not_to raise_violation 75 | end 76 | 77 | it 'ignores passing `params` from `arcane` gem' do 78 | inspect_source([ 79 | 'class ProductsController < ApplicationController', 80 | ' def index', 81 | ' Record.new(params.for(Log).as(user).refine).log', 82 | ' end', 83 | 'end' 84 | ]) 85 | expect(cop).not_to raise_violation 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /spec/ducalis/cops/possible_tap_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/cops/possible_tap' 7 | 8 | RSpec.describe Ducalis::PossibleTap do 9 | subject(:cop) { described_class.new } 10 | 11 | it '[rule] raises for methods with scope variable return' do 12 | inspect_source([ 13 | 'def load_group', 14 | ' group = channel.groups.find(params[:group_id])', 15 | ' authorize group, :edit?', 16 | ' group', 17 | 'end' 18 | ]) 19 | expect(cop).to raise_violation(/tap/) 20 | end 21 | 22 | it '[rule] better to use tap to increase code readability' do 23 | inspect_source([ 24 | 'def load_group', 25 | ' channel.groups.find(params[:group_id]) do |group|', 26 | ' authorize group, :edit?', 27 | ' end', 28 | 'end' 29 | ]) 30 | expect(cop).not_to raise_violation 31 | end 32 | 33 | it 'raises for methods with instance variable changes and return' do 34 | inspect_source([ 35 | 'def load_group', 36 | ' @group = Group.find(params[:id])', 37 | ' authorize @group', 38 | ' @group', 39 | 'end' 40 | ]) 41 | expect(cop).to raise_violation(/tap/) 42 | end 43 | 44 | it 'raises for methods with instance variable `||=` assign and return' do 45 | inspect_source([ 46 | 'def define_roles', 47 | ' return [] unless employee', 48 | '', 49 | ' @roles ||= []', 50 | ' @roles << "primary" if employee.primary?', 51 | ' @roles << "contract" if employee.contract?', 52 | ' @roles', 53 | 'end' 54 | ]) 55 | expect(cop).to raise_violation(/tap/) 56 | end 57 | 58 | it 'raises for methods which return call on scope variable' do 59 | inspect_source([ 60 | 'def load_group', 61 | ' elections = @elections.group_by(&:code)', 62 | ' result = elections.map do |code, elections|', 63 | ' { code => statistic }', 64 | ' end', 65 | ' result << total_spend(@elections)', 66 | ' result.inject(:merge)', 67 | 'end' 68 | ]) 69 | expect(cop).to raise_violation(/tap/) 70 | end 71 | 72 | it 'raises for methods which return instance variable but have scope vars' do 73 | inspect_source([ 74 | 'def generate_file(file_name)', 75 | ' @file = Tempfile.new([file_name, ".pdf"])', 76 | ' signed_pdf = some_new_stuff', 77 | ' @file.write(signed_pdf.to_pdf)', 78 | ' @file.close', 79 | ' @file', 80 | 'end' 81 | ]) 82 | expect(cop).to raise_violation(/tap/) 83 | end 84 | 85 | it 'ignores empty methods' do 86 | inspect_source([ 87 | 'def edit', 88 | 'end' 89 | ]) 90 | expect(cop).not_to raise_violation 91 | end 92 | 93 | it 'ignores methods which body is just call' do 94 | inspect_source([ 95 | 'def total_cost(cost_field)', 96 | ' Service.cost_sum(cost_field)', 97 | 'end' 98 | ]) 99 | expect(cop).not_to raise_violation 100 | end 101 | 102 | it 'ignores methods which return some statement' do 103 | inspect_source([ 104 | 'def stop_terminated_employee', 105 | ' if current_user && current_user.terminated?', 106 | ' sign_out current_user', 107 | ' redirect_to new_user_session_path', 108 | ' end', 109 | 'end' 110 | ]) 111 | expect(cop).not_to raise_violation 112 | end 113 | 114 | it '[bugfix] calling methods on possible tap variable' do 115 | inspect_source([ 116 | 'def create_message_struct(message)', 117 | ' objects = message.map { |object| process(object) }', 118 | ' Auditor::Message.new(message.process, objects)', 119 | 'end' 120 | ]) 121 | expect(cop).not_to raise_violation 122 | end 123 | 124 | it '[bugfix] methods which simply returns instance var without changes' do 125 | inspect_source([ 126 | 'def employee', 127 | ' @employee', 128 | 'end' 129 | ]) 130 | expect(cop).not_to raise_violation 131 | end 132 | 133 | it '[bugfix] methods which ends with if condition' do 134 | inspect_source([ 135 | 'def complete=(value, complete_at)', 136 | ' value = value.to_b', 137 | ' self.complete_at = complete_at if complete && value', 138 | ' self.complete_at = nil unless value', 139 | 'end' 140 | ]) 141 | expect(cop).not_to raise_violation 142 | end 143 | 144 | it '[bugfix] methods with args without children nodes' do 145 | inspect_source([ 146 | 'def filtered_admins(reducers)', 147 | ' reducers', 148 | ' .map { |reducer| @base_scope.public_send(reducer) }', 149 | ' .order("admin_users.created_at DESC")', 150 | 'end' 151 | ]) 152 | expect(cop).not_to raise_violation 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /spec/ducalis/cops/preferable_methods_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/cops/preferable_methods.rb' 7 | 8 | RSpec.describe Ducalis::PreferableMethods do 9 | subject(:cop) { described_class.new } 10 | 11 | it '[rule] raises for `delete` method calling' do 12 | inspect_source('User.where(id: 7).delete') 13 | expect(cop).to raise_violation(/destroy/) 14 | end 15 | 16 | it '[rule] better to use callback-calling methods' do 17 | inspect_source('User.where(id: 7).destroy') 18 | expect(cop).not_to raise_violation 19 | end 20 | 21 | it '[rule] raises `update_column` method calling' do 22 | inspect_source('User.where(id: 7).update_column(admin: false)') 23 | expect(cop).to raise_violation(/update/) 24 | expect(cop).to raise_violation(/update_attributes/) 25 | end 26 | 27 | it 'raises `save` method calling with validate: false' do 28 | inspect_source('User.where(id: 7).save(validate: false)') 29 | expect(cop).to raise_violation(/save/) 30 | end 31 | 32 | it 'raises `toggle!` method calling' do 33 | inspect_source('User.where(id: 7).toggle!') 34 | expect(cop).to raise_violation(/toggle.save/) 35 | end 36 | 37 | it 'ignores `save` method calling without validate: false' do 38 | inspect_source('User.where(id: 7).save') 39 | inspect_source('User.where(id: 7).save(some_arg: true)') 40 | expect(cop).not_to raise_violation 41 | end 42 | 43 | it 'ignores calling `delete` on params' do 44 | inspect_source('params.delete(code)') 45 | expect(cop).not_to raise_violation 46 | end 47 | 48 | it 'ignores calling `delete` with symbol' do 49 | inspect_source('some_hash.delete(:code)') 50 | expect(cop).not_to raise_violation 51 | end 52 | 53 | it 'ignores calling `delete` with string' do 54 | inspect_source('string.delete("-")') 55 | expect(cop).not_to raise_violation 56 | end 57 | 58 | it 'ignores calling `delete` with multiple args' do 59 | inspect_source('some.delete(1, header: [])') 60 | expect(cop).not_to raise_violation 61 | end 62 | 63 | it 'ignores calling `delete` on File class' do 64 | inspect_source('File.delete') 65 | expect(cop).not_to raise_violation 66 | end 67 | 68 | it 'ignores calling `delete` on files-like variables' do 69 | inspect_source('tempfile.delete') 70 | expect(cop).not_to raise_violation 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /spec/ducalis/cops/private_instance_assign_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/cops/private_instance_assign' 7 | 8 | RSpec.describe Ducalis::PrivateInstanceAssign do 9 | subject(:cop) { described_class.new } 10 | 11 | it '[rule] raises for instance variables in controllers private methods' do 12 | inspect_source([ 13 | 'class EmployeesController < ApplicationController', 14 | ' private', 15 | '', 16 | ' def load_employee', 17 | ' @employee = Employee.find(params[:id])', 18 | ' end', 19 | 'end' 20 | ]) 21 | expect(cop).to raise_violation(/instance/) 22 | end 23 | 24 | it '[rule] better to implicitly assign variables in public methods' do 25 | inspect_source([ 26 | 'class EmployeesController < ApplicationController', 27 | ' def index', 28 | ' @employee = load_employee', 29 | ' end', 30 | '', 31 | ' private', 32 | '', 33 | ' def load_employee', 34 | ' Employee.find(params[:id])', 35 | ' end', 36 | 'end' 37 | ]) 38 | expect(cop).not_to raise_violation 39 | end 40 | 41 | it '[rule] raises for memoization variables in controllers private methods' do 42 | inspect_source([ 43 | 'class EmployeesController < ApplicationController', 44 | ' private', 45 | '', 46 | ' def catalog', 47 | ' @catalog ||= Catalog.new', 48 | ' end', 49 | 'end' 50 | ]) 51 | expect(cop).to raise_violation(/underscore/) 52 | end 53 | 54 | it '[rule] better to mark private methods memo variables with "_"' do 55 | inspect_source([ 56 | 'class EmployeesController < ApplicationController', 57 | ' private', 58 | '', 59 | ' def catalog', 60 | ' @_catalog ||= Catalog.new', 61 | ' end', 62 | 'end' 63 | ]) 64 | expect(cop).not_to raise_violation 65 | end 66 | 67 | it 'ignores non-controller methods' do 68 | inspect_source([ 69 | 'class CatalogCollection', 70 | ' private', 71 | '', 72 | ' def catalog', 73 | ' @catalog = Catalog.new', 74 | ' end', 75 | 'end' 76 | ]) 77 | expect(cop).to_not raise_violation 78 | end 79 | 80 | it 'ignores assigning instance variables in controllers public methods' do 81 | inspect_source([ 82 | 'class EmployeesController < ApplicationController', 83 | ' def index', 84 | ' @employee = load_employee', 85 | ' end', 86 | '', 87 | ' private', 88 | '', 89 | ' def load_employee', 90 | ' Employee.find(params[:id])', 91 | ' end', 92 | 'end' 93 | ]) 94 | expect(cop).not_to raise_violation 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /spec/ducalis/cops/protected_scope_cop_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/cops/protected_scope_cop' 7 | 8 | RSpec.describe Ducalis::ProtectedScopeCop do 9 | subject(:cop) { described_class.new } 10 | 11 | it '[rule] raises if somewhere AR search was called on not protected scope' do 12 | inspect_source('Group.find(8)') 13 | expect(cop).to raise_violation(/non-protected scope/) 14 | end 15 | 16 | it '[rule] better to search records on protected scopes' do 17 | inspect_source('current_user.groups.find(8)') 18 | expect(cop).not_to raise_violation 19 | end 20 | 21 | it 'raises if AR search was called even for chain of calls' do 22 | inspect_source('Group.includes(:profiles).find(8)') 23 | expect(cop).to raise_violation(/non-protected scope/) 24 | end 25 | 26 | it 'raises if AR search was called with find_by id' do 27 | inspect_source('Group.includes(:profiles).find_by(id: 8)') 28 | expect(cop).to raise_violation(/non-protected scope/) 29 | end 30 | 31 | it 'raises if AR search was called on unnamespaced constant' do 32 | inspect_source('::Group.find(8)') 33 | expect(cop).to raise_violation(/non-protected scope/) 34 | end 35 | 36 | it 'ignores where statements and still raises error' do 37 | inspect_source( 38 | 'Group.includes(:profiles).where(name: "John").find(8)' 39 | ) 40 | expect(cop).to raise_violation(/non-protected scope/) 41 | end 42 | 43 | it 'ignores find method with passed block' do 44 | inspect_source('MAPPING.find { |x| x == 42 }') 45 | expect(cop).not_to raise_violation 46 | end 47 | 48 | it 'ignores find method with passed multiline block' do 49 | inspect_source([ 50 | 'MAPPING.find do |x|', 51 | ' x == 42', 52 | 'end' 53 | ]) 54 | expect(cop).not_to raise_violation 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/ducalis/cops/public_send_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/cops/public_send' 7 | 8 | RSpec.describe Ducalis::PublicSend do 9 | subject(:cop) { described_class.new } 10 | 11 | it '[rule] raises if send method used in code' do 12 | inspect_source('user.send(action)') 13 | expect(cop).to raise_violation(/using `send`/) 14 | end 15 | 16 | it '[rule] better to use mappings for multiple actions' do 17 | inspect_source([ 18 | '{', 19 | ' bark: ->(animal) { animal.bark },', 20 | ' meow: ->(animal) { animal.meow }', 21 | '}.fetch(actions)', 22 | '# or ever better', 23 | 'animal.voice' 24 | ]) 25 | expect(cop).not_to raise_violation 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/ducalis/cops/raise_without_error_class_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/cops/raise_without_error_class' 7 | 8 | RSpec.describe Ducalis::RaiseWithoutErrorClass do 9 | subject(:cop) { described_class.new } 10 | 11 | it '[rule] raises when `raise` called without exception class' do 12 | inspect_source('raise "Something went wrong"') 13 | expect(cop).to raise_violation(/exception class/) 14 | end 15 | 16 | it '[rule] better to `raise` with exception class' do 17 | inspect_source('raise StandardError, "Something went wrong"') 18 | expect(cop).not_to raise_violation 19 | end 20 | 21 | it 'raises when `raise` called without arguments' do 22 | inspect_source('raise') 23 | expect(cop).to raise_violation(/exception class/) 24 | end 25 | 26 | it 'ignores when `raise` called with exception instance' do 27 | inspect_source('raise StandardError.new("Something went wrong")') 28 | expect(cop).not_to raise_violation 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/ducalis/cops/recursion_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/cops/recursion' 7 | 8 | RSpec.describe Ducalis::Recursion do 9 | subject(:cop) { described_class.new } 10 | 11 | it '[rule] raises when method calls itself' do 12 | inspect_source( 13 | [ 14 | 'def set_rand_password', 15 | ' password = SecureRandom.urlsafe_base64(PASSWORD_LENGTH)', 16 | ' return set_rand_password unless password.match(PASSWORD_REGEX)', 17 | 'end' 18 | ] 19 | ) 20 | expect(cop).to raise_violation(/recursion/) 21 | end 22 | 23 | it 'raises when method calls itself with `self`' do 24 | inspect_source( 25 | [ 26 | 'def set_rand_password', 27 | ' password = SecureRandom.urlsafe_base64(PASSWORD_LENGTH)', 28 | ' return self.set_rand_password unless password.match(PASSWORD_REGEX)', 29 | 'end' 30 | ] 31 | ) 32 | expect(cop).to raise_violation(/recursion/) 33 | end 34 | 35 | it 'ignores empty methods' do 36 | inspect_source( 37 | [ 38 | 'def set_rand_password', 39 | 'end' 40 | ] 41 | ) 42 | expect(cop).to_not raise_violation 43 | end 44 | 45 | it 'ignores when code delegated to another method' do 46 | inspect_source( 47 | [ 48 | 'def set_rand_password', 49 | ' password = SecureRandom.urlsafe_base64(PASSWORD_LENGTH)', 50 | ' generate_password', 51 | 'end' 52 | ] 53 | ) 54 | expect(cop).to_not raise_violation 55 | end 56 | 57 | it '[rule] better to use lazy enumerations' do 58 | inspect_source( 59 | [ 60 | 'def repeatedly', 61 | ' Enumerator.new do |yielder|', 62 | ' loop { yielder.yield(yield) }', 63 | ' end', 64 | 'end', 65 | '', 66 | 'repeatedly { SecureRandom.urlsafe_base64(PASSWORD_LENGTH) }', 67 | ' .find { |password| password.match(PASSWORD_REGEX) }' 68 | ] 69 | ) 70 | expect(cop).to_not raise_violation 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /spec/ducalis/cops/regex_cop_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/cops/regex_cop' 7 | 8 | RSpec.describe Ducalis::RegexCop do 9 | subject(:cop) { described_class.new } 10 | 11 | it '[rule] raises if somewhere used regex which is not moved to const' do 12 | inspect_source([ 13 | 'name = "john"', 14 | 'puts "hi" if name =~ /john/' 15 | ]) 16 | 17 | expect(cop).to raise_violation(%r{CONST_NAME = /john/ # "john"}) 18 | expect(cop).to raise_violation(/puts "hi" if name =~ CONST_NAME/) 19 | end 20 | 21 | it '[rule] better to move regexes to constants with examples' do 22 | inspect_source([ 23 | 'FOUR_NUMBERS_REGEX = /\d{4}/ # 1234', 24 | 'puts "match" if number =~ FOUR_NUMBERS_REGEX' 25 | ]) 26 | expect(cop).not_to raise_violation 27 | end 28 | 29 | it 'raises if somewhere in code used regex but defined another const' do 30 | inspect_source([ 31 | 'ANOTHER_CONST = /ivan/', 32 | 'puts "hi" if name =~ /john/' 33 | ]) 34 | expect(cop).to raise_violation(/puts "hi"/) 35 | end 36 | 37 | it 'ignores matching constants' do 38 | inspect_source([ 39 | 'REGEX = /john/', 40 | 'name = "john"', 41 | 'puts "hi" if name =~ REGEX' 42 | ]) 43 | expect(cop).not_to raise_violation 44 | end 45 | 46 | it 'ignores named ruby constants' do 47 | inspect_source([ 48 | 'name = "john"', 49 | 'puts "hi" if name =~ /[[:alpha:]]/' 50 | ]) 51 | expect(cop).not_to raise_violation 52 | end 53 | 54 | it 'ignores dynamic regexes' do 55 | inspect_source([ 56 | 'name = "john"', 57 | 'puts "hi" if name =~ /.{#{' + 'name.length}}/' 58 | ]) 59 | expect(cop).not_to raise_violation 60 | end 61 | 62 | it 'rescue dynamic regexes dynamic regexes' do 63 | inspect_source([ 64 | 'name = "john"', 65 | 'puts "hi" if name =~ /foo(?=bar)/' 66 | ]) 67 | expect(cop).to raise_violation( 68 | %r{CONST_NAME = /foo\(\?=bar\)/ # "some_example"} 69 | ) 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/ducalis/cops/rest_only_cop_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/cops/rest_only_cop.rb' 7 | 8 | RSpec.describe Ducalis::RestOnlyCop do 9 | subject(:cop) { described_class.new } 10 | 11 | it '[rule] raises for controllers with non-REST methods' do 12 | inspect_source([ 13 | 'class ProductsController < ApplicationController', 14 | ' def index; end', 15 | ' def order; end', 16 | 'end' 17 | ]) 18 | expect(cop).to raise_violation(/REST/) 19 | end 20 | 21 | it '[rule] better to use only REST methods and create new controllers' do 22 | inspect_source([ 23 | 'class ProductsController < ApplicationController', 24 | ' def index; end', 25 | 'end', 26 | '', 27 | 'class OrdersController < ApplicationController', 28 | ' def create; end', 29 | 'end' 30 | ]) 31 | expect(cop).not_to raise_violation 32 | end 33 | 34 | it 'ignores controllers with private non-REST methods' do 35 | inspect_source([ 36 | 'class ProductsController < ApplicationController', 37 | ' def index; end', 38 | '', 39 | ' private', 40 | '', 41 | ' def recalculate; end', 42 | 'end' 43 | ]) 44 | expect(cop).not_to raise_violation 45 | end 46 | 47 | it 'ignores controllers with only REST methods' do 48 | inspect_source([ 49 | 'class ProductsController < ApplicationController', 50 | ' def index; end', 51 | ' def show; end', 52 | ' def new; end', 53 | ' def edit; end', 54 | ' def create; end', 55 | ' def update; end', 56 | ' def destroy; end', 57 | 'end' 58 | ]) 59 | expect(cop).not_to raise_violation 60 | end 61 | 62 | it 'ignores non-controllers with non-REST methods' do 63 | inspect_source([ 64 | 'class PriceStore', 65 | ' def index; end', 66 | ' def recalculate; end', 67 | 'end' 68 | ]) 69 | expect(cop).not_to raise_violation 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/ducalis/cops/rubocop_disable_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/cops/rubocop_disable' 7 | 8 | RSpec.describe Ducalis::RubocopDisable do 9 | subject(:cop) { described_class.new } 10 | 11 | it '[rule] raises on RuboCop disable comments' do 12 | inspect_source([ 13 | '# rubocop:disable Metrics/ParameterLists', 14 | 'def calculate(five, args, at, one, list); end' 15 | ]) 16 | expect(cop).to raise_violation(/RuboCop/) 17 | end 18 | 19 | it '[rule] better to follow RuboCop comments' do 20 | inspect_source('def calculate(five, context); end') 21 | expect(cop).not_to raise_violation 22 | end 23 | 24 | it 'ignores comment without RuboCop disabling' do 25 | inspect_source([ 26 | '# some meaningful comment', 27 | 'def calculate(five, args, at, one, list); end' 28 | ]) 29 | expect(cop).not_to raise_violation 30 | end 31 | 32 | it 'works for empty file' do 33 | inspect_source('') 34 | expect(cop).not_to raise_violation 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/ducalis/cops/standard_methods_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/cops/standard_methods' 7 | 8 | RSpec.describe Ducalis::StandardMethods do 9 | subject(:cop) { described_class.new } 10 | 11 | it '[rule] raises if use redefines default ruby methods' do 12 | inspect_source([ 13 | 'def to_s', 14 | ' "my version"', 15 | 'end' 16 | ]) 17 | expect(cop).to raise_violation(/redefine standard/) 18 | end 19 | 20 | it '[rule] better to define non-default ruby methods' do 21 | inspect_source([ 22 | 'def present', 23 | ' "my version"', 24 | 'end' 25 | ]) 26 | expect(cop).not_to raise_violation 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/ducalis/cops/strings_in_activerecords_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/cops/strings_in_activerecords' 7 | 8 | RSpec.describe Ducalis::StringsInActiverecords do 9 | subject(:cop) { described_class.new } 10 | 11 | it '[rule] raises for string if argument' do 12 | inspect_source([ 13 | 'before_save :set_full_name, ', 14 | " if: 'name_changed? || postfix_name_changed?'" 15 | ]) 16 | expect(cop).to raise_violation(/before_save/) 17 | end 18 | 19 | it '[rule] better to use lambda as argument' do 20 | inspect_source('validates :file, if: -> { remote_url.blank? }') 21 | expect(cop).not_to raise_violation 22 | end 23 | 24 | it 'works for block arguments' do 25 | inspect_source('before_save {}') 26 | expect(cop).not_to raise_violation 27 | end 28 | 29 | it 'works for lambda arguments' do 30 | inspect_source('after_destroy -> { run_after_commit { remove_pages } }') 31 | expect(cop).not_to raise_violation 32 | end 33 | 34 | it 'ignores validates with other method invokes' do 35 | inspect_source('validates :file, presence: true') 36 | expect(cop).not_to raise_violation 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/ducalis/cops/too_long_workers_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/cops/too_long_workers' 7 | 8 | RSpec.describe Ducalis::TooLongWorkers do 9 | subject(:cop) { described_class.new } 10 | let(:cop_config) { { 'Max' => 6, 'CountComments' => false } } 11 | before { allow(cop).to receive(:cop_config).and_return(cop_config) } 12 | 13 | it '[rule] raises for a class with more than 5 lines' do 14 | inspect_source([ 15 | 'class UserOnboardingWorker', 16 | ' def perform(user_id, group_id)', 17 | ' user = User.find_by(id: user_id)', 18 | ' group = Group.find(id: group_id)', 19 | '', 20 | ' return if user.nil? || group.nil?', 21 | '', 22 | ' GroupOnboard.new(user).process', 23 | ' OnboardingMailer.new(user).dliver_later', 24 | ' GroupNotifications.new(group).onboard(user)', 25 | ' end', 26 | 'end' 27 | ]) 28 | expect(cop).to raise_violation(/too much work/) 29 | end 30 | 31 | it '[rule] better to use workers only as async primitive and use services' do 32 | inspect_source([ 33 | 'class UserOnboardingWorker', 34 | ' def perform(user_id, group_id)', 35 | ' user = User.find_by(id: user_id)', 36 | ' group = Group.find(id: group_id)', 37 | '', 38 | ' return if user.nil? || group.nil?', 39 | '', 40 | ' OnboardingProcessing.new(user).call', 41 | ' end', 42 | 'end' 43 | ]) 44 | expect(cop).not_to raise_violation 45 | end 46 | 47 | it 'ignores non-worker classes' do 48 | inspect_source(['class StrangeClass', 49 | ' a = 1', 50 | ' a = 2', 51 | ' a = 3', 52 | ' a = 4', 53 | ' a = 5', 54 | ' a = 6', 55 | ' a = 7', 56 | 'end']) 57 | expect(cop).not_to raise_violation 58 | end 59 | 60 | it 'accepts a class with 5 lines' do 61 | inspect_source(['class TestWorker', 62 | ' a = 1', 63 | ' a = 2', 64 | ' a = 3', 65 | ' a = 4', 66 | ' a = 5', 67 | ' a = 6', 68 | 'end']) 69 | expect(cop).not_to raise_violation 70 | end 71 | 72 | it 'accepts a class with less than 5 lines' do 73 | inspect_source(['class TestWorker', 74 | ' a = 1', 75 | ' a = 2', 76 | ' a = 3', 77 | ' a = 4', 78 | ' a = 5', 79 | 'end']) 80 | expect(cop).not_to raise_violation 81 | end 82 | 83 | it 'accepts empty classes' do 84 | inspect_source(['class TestWorker', 85 | 'end']) 86 | expect(cop).not_to raise_violation 87 | end 88 | 89 | context 'when CountComments is enabled' do 90 | before { cop_config['CountComments'] = true } 91 | 92 | it 'also counts commented lines' do 93 | inspect_source(['class TestWorker', 94 | ' a = 1', 95 | ' #a = 2', 96 | ' a = 3', 97 | ' #a = 4', 98 | ' a = 5', 99 | ' a = 6', 100 | ' a = 7', 101 | 'end']) 102 | expect(cop).to raise_violation(/too much work/) 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /spec/ducalis/cops/uncommented_gem_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/cops/uncommented_gem.rb' 7 | 8 | RSpec.describe Ducalis::UncommentedGem do 9 | subject(:cop) { described_class.new } 10 | 11 | it '[rule] raises for gem from github without comment' do 12 | inspect_source([ 13 | "gem 'pry', '~> 0.10', '>= 0.10.0'", 14 | "gem 'rake', '~> 12.1'", 15 | "gem 'rspec', git: 'https://github.com/rspec/rspec'" 16 | ]) 17 | expect(cop).to raise_violation(/add comment/) 18 | end 19 | 20 | it '[rule] better to add gems from github with explanatory comment' do 21 | inspect_source([ 22 | "gem 'pry', '~> 0.10', '>= 0.10.0'", 23 | "gem 'rake', '~> 12.1'", 24 | "gem 'rspec', github: 'rspec/rspec' # new non released API" 25 | ]) 26 | expect(cop).not_to raise_violation 27 | end 28 | 29 | it 'ignores gems with require directive' do 30 | inspect_source( 31 | [ 32 | "gem 'pry', '~> 0.10', '>= 0.10.0'", 33 | "gem 'rake', '~> 12.1'", 34 | "gem 'rest-client', require: 'rest_client'" 35 | ] 36 | ) 37 | expect(cop).not_to raise_violation 38 | end 39 | 40 | it 'ignores gems with group directive' do 41 | inspect_source( 42 | [ 43 | "gem 'rake', '~> 12.1'", 44 | "gem 'wirble', group: :development" 45 | ] 46 | ) 47 | expect(cop).not_to raise_violation 48 | end 49 | 50 | it 'ignores gems with group directive and old syntax style' do 51 | inspect_source( 52 | [ 53 | "gem 'rake', '~> 12.1'", 54 | "gem 'wirble', :group => :development" 55 | ] 56 | ) 57 | expect(cop).not_to raise_violation 58 | end 59 | 60 | it 'works for gems without version' do 61 | inspect_source([ 62 | "gem 'rake'", 63 | "gem 'rake'" 64 | ]) 65 | expect(cop).not_to raise_violation 66 | end 67 | 68 | it 'works for empty gemfile' do 69 | inspect_source('') 70 | expect(cop).not_to raise_violation 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /spec/ducalis/cops/unlocked_gem_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/cops/unlocked_gem' 7 | 8 | RSpec.describe Ducalis::UnlockedGem do 9 | subject(:cop) { described_class.new } 10 | 11 | it '[rule] raises for gem without version' do 12 | inspect_source("gem 'pry'") 13 | expect(cop).to raise_violation(/lock gem/) 14 | end 15 | 16 | it '[rule] better to lock gem versions' do 17 | inspect_source([ 18 | "gem 'pry', '~> 0.10', '>= 0.10.0'", 19 | "gem 'rake', '~> 12.1'", 20 | "gem 'thor', '= 0.20.0'", 21 | "gem 'rspec', github: 'rspec/rspec'" 22 | ]) 23 | expect(cop).not_to raise_violation 24 | end 25 | 26 | it 'works for empty gemfile' do 27 | inspect_source('') 28 | expect(cop).not_to raise_violation 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/ducalis/cops/useless_only_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/cops/useless_only.rb' 7 | 8 | RSpec.describe Ducalis::UselessOnly do 9 | subject(:cop) { described_class.new } 10 | 11 | it '[rule] raises for `before_filters` with only one method' do 12 | inspect_source([ 13 | 'class ProductsController < ApplicationController', 14 | ' before_filter :update_cost, only: [:index]', 15 | '', 16 | ' def index; end', 17 | '', 18 | ' private', 19 | '', 20 | ' def update_cost; end', 21 | 'end' 22 | ]) 23 | expect(cop).to raise_violation(/inline/) 24 | end 25 | 26 | it '[rule] better to inline calls for instead of moving it to only' do 27 | inspect_source([ 28 | 'class ProductsController < ApplicationController', 29 | ' def index', 30 | ' update_cost', 31 | ' end', 32 | '', 33 | ' private', 34 | '', 35 | ' def update_cost; end', 36 | 'end' 37 | ]) 38 | expect(cop).not_to raise_violation 39 | end 40 | 41 | it 'raises for `before_filters` with only one method as keyword array' do 42 | inspect_source([ 43 | 'class ProductsController < ApplicationController', 44 | ' before_filter :update_cost, only: %i[index]', 45 | '', 46 | ' def index; end', 47 | '', 48 | ' private', 49 | '', 50 | ' def update_cost; end', 51 | 'end' 52 | ]) 53 | expect(cop).to raise_violation(/inline/) 54 | end 55 | 56 | it 'raises for `before_filters` with many actions and only one method' do 57 | inspect_source([ 58 | 'class ProductsController < ApplicationController', 59 | ' before_filter :update_cost, :load_me, only: %i[index]', 60 | '', 61 | ' def index; end', 62 | '', 63 | ' private', 64 | '', 65 | ' def update_cost; end', 66 | ' def load_me; end', 67 | 'end' 68 | ]) 69 | expect(cop).to raise_violation(/inline/) 70 | end 71 | 72 | it 'raises for `before_filters` with only one method as argument' do 73 | inspect_source([ 74 | 'class ProductsController < ApplicationController', 75 | ' before_filter :update_cost, only: :index', 76 | '', 77 | ' def index; end', 78 | '', 79 | ' private', 80 | '', 81 | ' def update_cost; end', 82 | 'end' 83 | ]) 84 | expect(cop).to raise_violation(/inline/) 85 | end 86 | 87 | it 'ignores `before_filters` without arguments' do 88 | inspect_source([ 89 | 'class ProductsController < ApplicationController', 90 | ' before_filter :update_cost', 91 | '', 92 | ' def index; end', 93 | '', 94 | ' private', 95 | '', 96 | ' def update_cost; end', 97 | 'end' 98 | ]) 99 | expect(cop).not_to raise_violation 100 | end 101 | 102 | it 'ignores `before_filters` with `only` and many arguments' do 103 | inspect_source([ 104 | 'class ProductsController < ApplicationController', 105 | ' before_filter :update_cost, only: %i[index show]', 106 | '', 107 | ' def index; end', 108 | ' def show; end', 109 | '', 110 | ' private', 111 | '', 112 | ' def update_cost; end', 113 | 'end' 114 | ]) 115 | expect(cop).not_to raise_violation 116 | end 117 | 118 | it 'ignores `before_filters` with `except` and one argument' do 119 | inspect_source([ 120 | 'class ProductsController < ApplicationController', 121 | ' before_filter :update_cost, except: %i[index]', 122 | '', 123 | ' def index; end', 124 | '', 125 | ' private', 126 | '', 127 | ' def update_cost; end', 128 | 'end' 129 | ]) 130 | expect(cop).not_to raise_violation 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /spec/ducalis/diffs_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/diffs' 7 | 8 | RSpec.describe Diffs do 9 | subject do 10 | class FakeClass 11 | include Diffs 12 | 13 | def base_diff(diff, path) 14 | BaseDiff.new(diff, path) 15 | end 16 | 17 | def nil_diff(diff, path) 18 | NilDiff.new(diff, path) 19 | end 20 | 21 | def git_diff(diff, path) 22 | GitDiff.new(diff, path) 23 | end 24 | end 25 | FakeClass.new 26 | end 27 | 28 | let(:diff) { instance_double(Git::Diff::DiffFile, patch: 'diff') } 29 | 30 | describe 'BaseDiff' do 31 | let(:base_diff) { subject.base_diff(diff, 'path') } 32 | 33 | it 'allows to initialize diff with diff and path' do 34 | expect(base_diff.diff).to eq(diff) 35 | expect(base_diff.path).to eq('path') 36 | end 37 | end 38 | 39 | describe 'GitDiff' do 40 | let(:git_diff) { subject.git_diff(diff, 'path') } 41 | let(:patch) { instance_double(Ducalis::Patch) } 42 | let(:line) { double(:line, changed?: false, patch_position: 42) } 43 | 44 | before do 45 | expect(Ducalis::Patch).to receive(:new).and_return(patch) 46 | expect(patch).to receive(:line_for).with(32).and_return(line) 47 | end 48 | 49 | describe '#changed?' do 50 | it 'delegates to related diff line' do 51 | expect(git_diff.changed?(32)).to be false 52 | end 53 | end 54 | 55 | describe '#patch_line' do 56 | it 'delegates to related diff line' do 57 | expect(git_diff.patch_line(32)).to be 42 58 | end 59 | end 60 | end 61 | 62 | describe 'NilDiff' do 63 | let(:nil_diff) { subject.nil_diff(nil, nil) } 64 | 65 | describe '#changed?' do 66 | it 'always true' do 67 | expect(nil_diff.changed?).to be true 68 | end 69 | end 70 | 71 | describe '#patch_line' do 72 | it 'always -1' do 73 | expect(nil_diff.patch_line).to eq(-1) 74 | end 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /spec/ducalis/documentation_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.not_covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/documentation' 7 | 8 | RSpec.describe Documentation do 9 | end 10 | -------------------------------------------------------------------------------- /spec/ducalis/errors_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/errors' 7 | 8 | RSpec.describe Ducalis do 9 | end 10 | -------------------------------------------------------------------------------- /spec/ducalis/git_access_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/git_access' 7 | 8 | RSpec.describe GitAccess do 9 | subject { described_class.instance } 10 | 11 | let(:diff) do 12 | instance_double(Git::Diff::DiffFile, type: 'created', path: 'path') 13 | end 14 | 15 | describe '::MODES' do 16 | let(:git) { instance_double(Git::Base) } 17 | 18 | it ':branch returns branch diff' do 19 | expect(git).to receive(:diff).with('origin/master') 20 | GitAccess::MODES[:branch].call(git) 21 | end 22 | 23 | it ':index returns diff between head and current state' do 24 | expect(git).to receive(:diff).with('HEAD') 25 | GitAccess::MODES[:index].call(git) 26 | end 27 | end 28 | 29 | describe '#store_pull_request!' do 30 | it 'allows to set PR information' do 31 | subject.store_pull_request!(['repo', 42]) 32 | expect(subject.repo).to eq('repo') 33 | expect(subject.id).to eq(42) 34 | end 35 | end 36 | 37 | describe '#changed_files' do 38 | before do 39 | stub_const('GitAccess::MODES', current: ->(_) { [diff] }) 40 | allow(Dir).to receive(:pwd).and_return('/root') 41 | allow(Dir).to receive(:exist?).with('/root/.git').and_return(true) 42 | end 43 | 44 | it 'returns [] when nothing configured' do 45 | expect(subject.changed_files).to match_array([]) 46 | end 47 | 48 | it 'raises error when flag passed but there is no git dir' do 49 | subject.flag = :non_default 50 | expect(Dir).to receive(:exist?).with('/root/.git').and_return(false) 51 | expect { subject.changed_files }.to raise_error(Ducalis::MissingGit) 52 | end 53 | 54 | it 'returns changed files based on passed flag' do 55 | subject.flag = :current 56 | expect(Git).to receive(:open).with('/root') 57 | expect(File).to receive(:exist?).with('path').and_return(true) 58 | expect(subject.changed_files).to match_array(%w[path]) 59 | end 60 | end 61 | 62 | describe '#for' do 63 | before { expect(subject).to receive(:changes).and_return([diff]) } 64 | 65 | it 'resolves files with complex paths' do 66 | expect(Dir).to receive(:pwd).twice.and_return('/some/long/') 67 | expect(subject.for('/some/long/path')).to eq(diff) 68 | end 69 | 70 | it 'returns diff for passed path' do 71 | expect(Dir).to receive(:pwd).and_return('/some/long/') 72 | expect(subject.for('path')).to eq(diff) 73 | end 74 | 75 | it 'returns nil diff for unknown path' do 76 | expect(Dir).to receive(:pwd).and_return('/some/long/') 77 | expect(subject.for('another/path').patch_line).to eq(-1) 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /spec/ducalis/github_formatter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/github_formatter' 7 | 8 | RSpec.describe GithubFormatter do 9 | subject { described_class.new(nil) } 10 | 11 | describe '#started' do 12 | it 'initializes offenses accumulator' do 13 | expect do 14 | subject.started([]) 15 | end.to change { subject.all }.from(nil).to([]) 16 | end 17 | end 18 | 19 | describe '#file_finished' do 20 | before do 21 | subject.started([]) 22 | expect(subject).to receive(:print).with('.') 23 | end 24 | 25 | it 'pushes offenses to accumulator' do 26 | expect do 27 | subject.file_finished(:file, %i[result]) 28 | end.to change { subject.all }.from([]).to([%i[result]]) 29 | end 30 | 31 | it 'ignores empty offenses' do 32 | expect do 33 | subject.file_finished(:file, []) 34 | end.to_not(change { subject.all }) 35 | end 36 | end 37 | 38 | describe '#finished' do 39 | let(:git_access) { instance_double(GitAccess, repo: 'repo', id: 42) } 40 | let(:commentator) { instance_double(Ducalis::Commentators::Github) } 41 | 42 | before do 43 | expect(subject).to receive(:print).twice.with('.') 44 | expect(subject).to receive(:print).with("\n") 45 | 46 | subject.started([]) 47 | subject.file_finished(:file, %i[result1]) 48 | subject.file_finished(:file, %i[result2 result3]) 49 | end 50 | 51 | it 'push files to ducalis commentator' do 52 | stub_const('GitAccess', class_double(GitAccess, instance: git_access)) 53 | expect( 54 | Ducalis::Commentators::Github 55 | ).to receive(:new).with('repo', 42) 56 | .and_return(commentator) 57 | expect( 58 | commentator 59 | ).to receive(:call).with(%i[result1 result2 result3]) 60 | subject.finished([]) 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/ducalis/patch_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/patch' 7 | 8 | RSpec.describe Ducalis::Patch do 9 | subject { described_class.new(diff) } 10 | 11 | let(:diff) { File.read('./spec/fixtures/patch.diff') } 12 | 13 | describe '#line_for' do 14 | it 'returns corresponding line description for existing files' do 15 | expect(subject.line_for(16).changed?).to be true 16 | expect(subject.line_for(16).patch_position).to eq(13) 17 | end 18 | 19 | it 'returns empty line for non-existing and non-changed files' do 20 | expect(subject.line_for(42).changed?).to be false 21 | expect(subject.line_for(42).patch_position).to eq(-1) 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/ducalis/utils_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/errors' 7 | 8 | RSpec.describe Ducalis::Utils do 9 | describe '#octokit' do 10 | let(:token) { '7103donotforgettoremovemefromgit' } 11 | let(:client) { instance_double(Octokit::Client) } 12 | 13 | it 'raises missing token error when there is no key in ENV' do 14 | stub_const('ENV', {}) 15 | expect { described_class.octokit }.to raise_error(Ducalis::MissingToken) 16 | end 17 | 18 | it 'returns configured octokit version' do 19 | stub_const('ENV', 'GITHUB_TOKEN' => token) 20 | expect(Octokit::Client).to receive(:new).with(access_token: token) 21 | .and_return(client) 22 | expect(client).to receive(:auto_paginate=).with(true) 23 | described_class.octokit 24 | end 25 | end 26 | 27 | describe '#similarity' do 28 | it 'returns 1 for equal strings' do 29 | expect( 30 | described_class.similarity('aaa', 'aaa') 31 | ).to be_within(0.01).of(1.0) 32 | end 33 | 34 | it 'returns 0 for fully different strings' do 35 | expect( 36 | described_class.similarity('aaa', 'zzz') 37 | ).to be_within(0.01).of(0) 38 | end 39 | 40 | it 'returns similarity score for strings' do 41 | expect( 42 | described_class.similarity('aabb', 'aazz') 43 | ).to be_within(0.01).of(0.5) 44 | end 45 | end 46 | 47 | describe '#silence_warnings' do 48 | it 'allows to change constants without warning' do 49 | SPEC_CONST = 1 50 | expect do 51 | described_class.silence_warnings { SPEC_CONST = 2 } 52 | end.to_not output.to_stderr 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /spec/ducalis/version_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.not_covered! 4 | 5 | require 'spec_helper' 6 | require './lib/ducalis/version' 7 | 8 | RSpec.describe Ducalis::VERSION do 9 | end 10 | -------------------------------------------------------------------------------- /spec/ducalis_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SingleCov.covered! 4 | 5 | require 'ducalis/documentation' 6 | 7 | RSpec.describe Ducalis do 8 | it 'has a version number' do 9 | expect(Ducalis::VERSION).not_to be nil 10 | end 11 | 12 | if ENV.fetch('WITH_DOCS', false) 13 | it 'has a positive and negative examples for each cop' do 14 | Documentation.new.cop_rules.each do |file, rules| 15 | check = has_word(rules, Documentation::SIGNAL_WORD) && 16 | has_word(rules, Documentation::PREFER_WORD) 17 | expect(check).to be(true), 18 | "expected #{file} has positive and negative cases" 19 | end 20 | end 21 | end 22 | 23 | def has_word(rules, word) 24 | rules.any? { |(rule, _code)| rule.include?(word) } 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/fixtures/patch.diff: -------------------------------------------------------------------------------- 1 | diff --git a/lib/ducalis/patch.rb b/lib/ducalis/patch.rb 2 | index f72e6cc..b7db6ab 100644 3 | --- a/lib/ducalis/patch.rb 4 | +++ b/lib/ducalis/patch.rb 5 | @@ -2,7 +2,6 @@ module Ducalis 6 | class Patch 7 | RANGE_INFORMATION_LINE = /^@@ .+\+(?\d+),/ 8 | MODIFIED_LINE = /^\+(?!\+|\+)/ 9 | NOT_REMOVED_LINE = /^[^-]/ 10 | 11 | def initialize(patch) 12 | @@ -14,8 +13,8 @@ module Ducalis 13 | 14 | def changed_lines 15 | line_number = 0 16 | - patch_lines.each_with_object([]) 17 | - .with_index do |(content, lines), patch_position| 18 | + patch_lines.inject([], 0) 19 | + .with_index do |patch_position, lines, content| 20 | case content 21 | when RANGE_INFORMATION_LINE 22 | line_number = Regexp.last_match[:line_number].to_i 23 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/setup' 4 | require 'rubocop/rspec/support' 5 | require 'rspec/expectations' 6 | 7 | Dir['spec/support/**/*.rb'].each { |f| require f.sub('spec/', '') } 8 | 9 | require 'ducalis' 10 | 11 | RSpec.configure do |config| 12 | config.example_status_persistence_file_path = '.rspec_status' 13 | config.disable_monkey_patching! 14 | config.expect_with :rspec do |expectations| 15 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 16 | end 17 | config.mock_with :rspec do |mocks| 18 | mocks.verify_partial_doubles = true 19 | mocks.syntax = :expect 20 | end 21 | config.shared_context_metadata_behavior = :apply_to_host_groups 22 | end 23 | -------------------------------------------------------------------------------- /spec/support/cop_helper_cast.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module CopHelperCast 4 | def inspect_source(source, file = nil) 5 | if PatchedRubocop::CURRENT_VERSION > PatchedRubocop::SPEC_CHANGES_VERSION 6 | super(Array(source).join("\n"), file) 7 | elsif PatchedRubocop::CURRENT_VERSION > PatchedRubocop::ADAPTED_VERSION 8 | super(source, file) 9 | else 10 | super(cop, source, file) 11 | end 12 | end 13 | end 14 | 15 | CopHelper.prepend(CopHelperCast) 16 | -------------------------------------------------------------------------------- /spec/support/cop_violation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec::Matchers.define :raise_violation do |like, count: 1| 4 | match do |cop| 5 | cop.offenses.size == count && cop.offenses.first.message.match(like) 6 | end 7 | 8 | match_when_negated do |cop| 9 | cop.offenses.empty? 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/support/coverage.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'single_cov' 4 | SingleCov.setup :rspec 5 | 6 | describe 'Coverage' do 7 | it 'does not let users add new untested code' do 8 | SingleCov.assert_used 9 | end 10 | end 11 | --------------------------------------------------------------------------------